Skip to main content

fallow_cli/health_types/
finding.rs

1//! Health-finding wrappers, action context, and typed action builders.
2//!
3//! Three typed envelope wrappers live in this module:
4//!
5//! - [`HealthFinding`] flattens [`ComplexityViolation`] for the
6//!   `findings[]` array and carries the typed `actions` list plus the
7//!   optional audit-mode `introduced` flag.
8//! - [`HotspotFinding`] flattens [`HotspotEntry`] for the
9//!   `hotspots[]` array and carries a typed [`HotspotAction`] list.
10//! - [`RefactoringTargetFinding`] flattens [`RefactoringTarget`] for the
11//!   `targets[]` array and carries a typed [`RefactoringTargetAction`]
12//!   list.
13//!
14//! Wire compatibility: `#[serde(flatten)]` on the inner type means every
15//! array item continues to expose the inner fields at the top level
16//! alongside `actions` (and `introduced` for [`HealthFinding`] only).
17//! Consumers that hand-parse the JSON see no shape change.
18//!
19//! Audit-mode asymmetry: [`HealthFinding`] carries `introduced` because
20//! `fallow audit` attributes complexity findings to the introduced /
21//! inherited diff. [`HotspotFinding`] and [`RefactoringTargetFinding`] do
22//! NOT carry `introduced` because hotspot ranking and refactoring targets
23//! are not produced by the audit base-snapshot classifier; the pre-wrapper
24//! `finding_augmentation` set `include_introduced: false` for both and the
25//! typed wrappers preserve that.
26//!
27//! [`ComplexityViolation`]: crate::health_types::scores::ComplexityViolation
28
29use fallow_types::output_health::{
30    HealthFindingAction, HealthFindingActionType, HotspotAction, HotspotActionHeuristic,
31    HotspotActionType, RefactoringTargetAction, RefactoringTargetActionType,
32};
33use std::ops::Deref;
34use std::path::Path;
35
36use crate::health_types::scores::{ComplexityViolation, CoverageTier, HotspotEntry};
37use crate::health_types::targets::{RecommendationCategory, RefactoringTarget};
38
39/// Cyclomatic distance from `max_cyclomatic_threshold` at which a
40/// CRAP-only finding still warrants a secondary `refactor-function` action.
41///
42/// Reasoning: a function whose cyclomatic count is within this band of the
43/// configured threshold is "almost too complex" already, so refactoring is a
44/// useful complement to the primary coverage action. Keeping the boundary
45/// expressed as a band (threshold minus N) rather than a ratio links it
46/// to the existing `health.maxCyclomatic` knob: tightening the threshold
47/// automatically widens the population that gets the secondary suggestion.
48const SECONDARY_REFACTOR_BAND: u16 = 5;
49
50/// Options controlling how the action builder populates a health finding's
51/// `actions` array.
52///
53/// `omit_suppress_line` skips the `suppress-line` action across every
54/// health finding. Set when:
55/// - A baseline is active (`opts.baseline.is_some()` or
56///   `opts.save_baseline.is_some()`): the baseline file already suppresses
57///   findings, and adding `// fallow-ignore-next-line` comments on top
58///   creates dead annotations once the baseline regenerates.
59/// - The team has opted out via `health.suggestInlineSuppression: false`.
60///
61/// When omitted, a top-level `actions_meta` object on the report records
62/// the omission and the reason so consumers can audit "where did
63/// health finding suppress-line go?" without having to grep the config
64/// or CLI history. Wire shape is documented by
65/// [`crate::health_types::HealthActionsMeta`].
66#[derive(Debug, Clone, Copy, Default)]
67pub struct HealthActionOptions {
68    /// Skip emission of `suppress-line` action entries.
69    pub omit_suppress_line: bool,
70    /// Human-readable reason surfaced in the `actions_meta` breadcrumb when
71    /// `omit_suppress_line` is true. Stable codes:
72    /// - `"baseline-active"`: `--baseline` or `--save-baseline` was passed
73    /// - `"config-disabled"`: `health.suggestInlineSuppression: false`
74    pub omit_reason: Option<&'static str>,
75}
76
77/// Construction-time context for [`HealthFinding::with_actions`].
78///
79/// Bundles the action-emission options and the complexity thresholds the
80/// action selector needs. Computed once per `HealthReport` build (or once
81/// per group when `--group-by` partitions the run) and reused across every
82/// finding so the action list is byte-for-byte equivalent to the prior
83/// `inject_health_actions` post-pass output.
84#[derive(Debug, Clone, Copy)]
85pub struct HealthActionContext {
86    /// Action-emission options (suppress-line gating + audit reason).
87    pub opts: HealthActionOptions,
88    /// Cyclomatic-complexity ceiling beyond which a function is flagged.
89    /// Sourced from `summary.max_cyclomatic_threshold`.
90    pub max_cyclomatic_threshold: u16,
91    /// Cognitive-complexity ceiling. Sourced from
92    /// `summary.max_cognitive_threshold`.
93    pub max_cognitive_threshold: u16,
94    /// CRAP ceiling. Sourced from `summary.max_crap_threshold`.
95    pub max_crap_threshold: f64,
96}
97
98/// Wire envelope for a single complexity finding.
99///
100/// Flattens [`ComplexityViolation`] for wire continuity and adds the typed
101/// `actions` list plus the audit-mode `introduced` flag. The
102/// `#[serde(flatten)]` keeps each `findings[]` item byte-identical to the
103/// pre-wrapper shape: inner fields (`path`, `name`, `line`, `cyclomatic`,
104/// ...) sit at the top level alongside `actions` and optional `introduced`.
105///
106/// Construct via [`HealthFinding::with_actions`] in the typical health
107/// pipeline (the wrapper computes its own `actions` from a
108/// [`HealthActionContext`]) or via [`HealthFinding::new`] when the caller
109/// already has the action list (e.g., tests, audit cross-attribution).
110#[derive(Debug, Clone, serde::Serialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct HealthFinding {
113    /// Inner complexity-violation payload. Flattened on the wire.
114    #[serde(flatten)]
115    pub violation: ComplexityViolation,
116    /// Machine-actionable fix and suppress hints. Always populated; never
117    /// empty in the typical pipeline (the action selector emits at least
118    /// `suppress-line` or `suppress-file` unless suppressed by the
119    /// context).
120    pub actions: Vec<HealthFindingAction>,
121    /// Audit-mode flag indicating whether the finding is new versus the
122    /// audit base snapshot. `Some(true)` when introduced in the diff,
123    /// `Some(false)` when present in both snapshots, `None` outside audit
124    /// mode (the field is skipped from the wire).
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub introduced: Option<bool>,
127}
128
129impl Deref for HealthFinding {
130    type Target = ComplexityViolation;
131
132    fn deref(&self) -> &Self::Target {
133        &self.violation
134    }
135}
136
137impl From<ComplexityViolation> for HealthFinding {
138    /// Convenience conversion: wrap a violation with an empty `actions`
139    /// list and no `introduced` flag. Used by tests and fixture builders
140    /// that don't exercise the action-selection path. Production code
141    /// should call [`HealthFinding::with_actions`] (or
142    /// [`HealthFinding::new`] when the action list is already computed)
143    /// so the wire shape carries the typed actions.
144    fn from(violation: ComplexityViolation) -> Self {
145        Self {
146            violation,
147            actions: Vec::new(),
148            introduced: None,
149        }
150    }
151}
152
153impl HealthFinding {
154    /// Construct a wrapper around a pre-computed action list.
155    ///
156    /// Used by audit cross-attribution paths and tests where the caller
157    /// already has the actions in hand. Prefer [`Self::with_actions`] in
158    /// the typical pipeline.
159    #[must_use]
160    #[allow(
161        dead_code,
162        reason = "intentional public constructor for audit / test paths that supply their own actions; with_actions is the production constructor"
163    )]
164    pub fn new(
165        violation: ComplexityViolation,
166        actions: Vec<HealthFindingAction>,
167        introduced: Option<bool>,
168    ) -> Self {
169        Self {
170            violation,
171            actions,
172            introduced,
173        }
174    }
175
176    /// Construct a wrapper with the `actions` list computed from the
177    /// finding's measured signals plus the report-wide context.
178    ///
179    /// The `introduced` field is left at `None`; audit-mode callers set it
180    /// after construction once base-snapshot attribution runs.
181    #[must_use]
182    pub fn with_actions(violation: ComplexityViolation, ctx: &HealthActionContext) -> Self {
183        let actions = build_health_finding_actions(&violation, ctx);
184        Self {
185            violation,
186            actions,
187            introduced: None,
188        }
189    }
190}
191
192/// Compute the typed `actions` list for a complexity finding.
193///
194/// Selection rules:
195///
196/// - Exceeded cyclomatic/cognitive only (no CRAP): `refactor-function`.
197/// - Exceeded CRAP, tier `none` or absent: `add-tests` (no test path
198///   reaches this function; start from scratch).
199/// - Exceeded CRAP, tier `partial`/`high`: `increase-coverage` (file
200///   already has some test path; add targeted assertions for uncovered
201///   branches).
202/// - Exceeded CRAP, full coverage cannot clear CRAP: `refactor-function`
203///   because reducing cyclomatic complexity is the remaining lever.
204/// - Exceeded both CRAP and cyclomatic/cognitive: emit BOTH the
205///   tier-appropriate coverage action AND `refactor-function`.
206/// - CRAP-only with cyclomatic within `SECONDARY_REFACTOR_BAND` of the
207///   threshold AND cognitive past the cognitive floor: also append
208///   `refactor-function` as a secondary action; the function is
209///   "almost too complex" already.
210///
211/// A trailing `suppress-line` (or `suppress-file` for Angular `.html`
212/// templates) is appended unless `ctx.opts.omit_suppress_line` is true.
213#[must_use]
214pub fn build_health_finding_actions(
215    violation: &ComplexityViolation,
216    ctx: &HealthActionContext,
217) -> Vec<HealthFindingAction> {
218    let name = violation.name.as_str();
219    let exceeded = violation.exceeded;
220    let includes_crap = exceeded.includes_crap();
221    let crap_only = matches!(exceeded, crate::health_types::ExceededThreshold::Crap);
222    let cyclomatic = violation.cyclomatic;
223    let cognitive = violation.cognitive;
224    let full_coverage_can_clear_crap =
225        !includes_crap || f64::from(cyclomatic) < ctx.max_crap_threshold;
226
227    let mut actions: Vec<HealthFindingAction> = Vec::new();
228
229    // Coverage-leaning action: only emitted when CRAP contributed. For
230    // synthetic <template> findings whose CRAP was inherited from the
231    // owning .component.ts via the inverse templateUrl edge, the action
232    // description must point AI agents at the component file rather than
233    // the .html template, otherwise agents will hallucinate Angular
234    // template test harnesses or try to scaffold a spec for the .html
235    // path directly (which is structurally impossible). The inherited_from
236    // string is the project-relative .ts path emitted alongside the
237    // coverage_source discriminator.
238    let inherited_from = violation.inherited_from.as_deref();
239    if includes_crap
240        && let Some(action) = build_crap_coverage_action(
241            name,
242            violation.coverage_tier,
243            full_coverage_can_clear_crap,
244            inherited_from,
245        )
246    {
247        actions.push(action);
248    }
249
250    // Refactor action conditions:
251    //   1. Exceeded cyclomatic/cognitive (with or without CRAP), or
252    //   2. CRAP-only where even full coverage cannot bring CRAP below the
253    //      configured threshold, so reducing complexity is the remaining
254    //      lever, or
255    //   3. CRAP-only with cyclomatic within SECONDARY_REFACTOR_BAND of the
256    //      threshold AND cognitive complexity past the cognitive floor (the
257    //      function is almost too complex anyway and the cognitive signal
258    //      confirms that refactoring would actually help). Without the
259    //      cognitive floor, flat type-tag dispatchers and JSX render maps
260    //      (high CC, near-zero cog) get a misleading refactor suggestion.
261    //
262    // `build_crap_coverage_action` returns `None` for case 2 instead of
263    // pushing `refactor-function` itself, so this branch unconditionally
264    // pushes the refactor entry without needing to dedupe.
265    let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
266    let cognitive_floor = ctx.max_cognitive_threshold / 2;
267    let near_cyclomatic_threshold = crap_only
268        && cyclomatic > 0
269        && cyclomatic
270            >= ctx
271                .max_cyclomatic_threshold
272                .saturating_sub(SECONDARY_REFACTOR_BAND)
273        && cognitive >= cognitive_floor;
274    let is_template = name == "<template>";
275    let is_component = name == "<component>";
276    if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
277        let (description, note): (String, &str) = if is_component {
278            // Component rollup: name is the literal "<component>"; the
279            // breakdown lives in `component_rollup`. Direct AI agents at the
280            // component as the unit so they consider splitting the template
281            // OR refactoring the worst class method, not just one of them.
282            let rollup = violation.component_rollup.as_ref();
283            let class_name = rollup.map_or("the component", |r| r.component.as_str());
284            let worst_method = rollup.map_or("the worst class method", |r| {
285                r.class_worst_function.as_str()
286            });
287            let class_cyc = rollup.map_or(0_u16, |r| r.class_cyclomatic);
288            let template_cyc = rollup.map_or(0_u16, |r| r.template_cyclomatic);
289            (
290                format!(
291                    "Refactor `{class_name}` to reduce component complexity (rolled-up cyclomatic {cyclomatic} = {class_cyc} on `{worst_method}` + {template_cyc} on the template)"
292                ),
293                "Consider splitting the template into smaller components OR extracting helpers from the worst class method; the rollup reflects the component as one complexity unit",
294            )
295        } else if is_template {
296            (
297                format!(
298                    "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
299                ),
300                "Consider splitting complex template branches into smaller components or simpler bindings",
301            )
302        } else {
303            (
304                format!(
305                    "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
306                ),
307                "Consider splitting into smaller functions with single responsibilities",
308            )
309        };
310        actions.push(HealthFindingAction {
311            kind: HealthFindingActionType::RefactorFunction,
312            auto_fixable: false,
313            description,
314            note: Some(note.to_string()),
315            comment: None,
316            placement: None,
317            target_path: None,
318        });
319    }
320
321    if !ctx.opts.omit_suppress_line {
322        if is_template
323            && violation
324                .path
325                .extension()
326                .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
327        {
328            actions.push(HealthFindingAction {
329                kind: HealthFindingActionType::SuppressFile,
330                auto_fixable: false,
331                description: "Suppress with an HTML comment at the top of the template".to_string(),
332                note: None,
333                comment: Some("<!-- fallow-ignore-file complexity -->".to_string()),
334                placement: Some("top-of-template".to_string()),
335                target_path: None,
336            });
337        } else if is_template {
338            actions.push(HealthFindingAction {
339                kind: HealthFindingActionType::SuppressLine,
340                auto_fixable: false,
341                description: "Suppress with an inline comment above the Angular decorator"
342                    .to_string(),
343                note: None,
344                comment: Some("// fallow-ignore-next-line complexity".to_string()),
345                placement: Some("above-angular-decorator".to_string()),
346                target_path: None,
347            });
348        } else if is_component {
349            // Rollup anchors at the worst class function's line; the same
350            // suppression that hides the worst function also hides the
351            // rollup, but the description tells the user which line it
352            // lands on so they don't expect the comment above the
353            // @Component decorator (which would NOT match the rollup's line).
354            actions.push(HealthFindingAction {
355                kind: HealthFindingActionType::SuppressLine,
356                auto_fixable: false,
357                description: "Suppress with an inline comment above the worst class method (the rollup is anchored at that method's line, so a comment above it hides both the function finding and the rollup)".to_string(),
358                note: None,
359                comment: Some("// fallow-ignore-next-line complexity".to_string()),
360                placement: Some("above-component-worst-method".to_string()),
361                target_path: None,
362            });
363        } else {
364            actions.push(HealthFindingAction {
365                kind: HealthFindingActionType::SuppressLine,
366                auto_fixable: false,
367                description: "Suppress with an inline comment above the function declaration"
368                    .to_string(),
369                note: None,
370                comment: Some("// fallow-ignore-next-line complexity".to_string()),
371                placement: Some("above-function-declaration".to_string()),
372                target_path: None,
373            });
374        }
375    }
376
377    actions
378}
379
380/// Build the coverage-leaning action for a CRAP-contributing finding.
381///
382/// Returns `None` when even 100% coverage could not bring the function
383/// below the configured CRAP threshold. In that case the primary action
384/// becomes `refactor-function`, which the caller emits separately.
385fn build_crap_coverage_action(
386    name: &str,
387    tier: Option<CoverageTier>,
388    full_coverage_can_clear_crap: bool,
389    inherited_from: Option<&Path>,
390) -> Option<HealthFindingAction> {
391    if !full_coverage_can_clear_crap {
392        return None;
393    }
394
395    // Inherited-coverage path: when the CRAP score on a `<template>`
396    // finding was derived from the owning Angular component .ts file, the
397    // test surface to act on is the component, not the .html. Override
398    // the description so agents do not try to scaffold tests against the
399    // template path directly.
400    if let Some(owner) = inherited_from {
401        let owner_str = owner.to_string_lossy().into_owned();
402        return Some(HealthFindingAction {
403            kind: HealthFindingActionType::IncreaseCoverage,
404            auto_fixable: false,
405            description: format!(
406                "Increase test coverage on `{owner_str}` (the CRAP score on `{name}` is inherited from this Angular component; add component tests there rather than against the template)"
407            ),
408            note: Some(
409                "CRAP = CC^2 * (1 - cov/100)^3 + CC; .html templates are exercised through their @Component class, so the test target is the .ts file referenced by `inherited_from`".to_string(),
410            ),
411            comment: None,
412            placement: None,
413            target_path: Some(owner_str),
414        });
415    }
416
417    match tier {
418        // Partial / high coverage: the file already has some test path.
419        // Pivot the action description from "add tests" to "increase
420        // coverage" so agents add targeted assertions for uncovered
421        // branches instead of scaffolding new tests from scratch.
422        Some(CoverageTier::Partial | CoverageTier::High) => Some(HealthFindingAction {
423            kind: HealthFindingActionType::IncreaseCoverage,
424            auto_fixable: false,
425            description: format!(
426                "Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"
427            ),
428            note: Some(
429                "CRAP = CC^2 * (1 - cov/100)^3 + CC; targeted branch coverage is more efficient than scaffolding new test files when the file already has coverage".to_string(),
430            ),
431            comment: None,
432            placement: None,
433            target_path: None,
434        }),
435        // None / unknown tier: keep the original "add-tests" message.
436        _ => Some(HealthFindingAction {
437            kind: HealthFindingActionType::AddTests,
438            auto_fixable: false,
439            description: format!(
440                "Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"
441            ),
442            note: Some(
443                "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold".to_string(),
444            ),
445            comment: None,
446            placement: None,
447            target_path: None,
448        }),
449    }
450}
451
452// ────────────────────────────────────────────────────────────────────
453// HotspotFinding
454// ────────────────────────────────────────────────────────────────────
455
456/// Wire envelope for a single hotspot entry.
457///
458/// Flattens [`HotspotEntry`] for wire continuity and adds the typed
459/// `actions` list. The `#[serde(flatten)]` keeps each `hotspots[]` item
460/// byte-identical to the pre-wrapper shape: inner fields (`path`,
461/// `score`, `commits`, `weighted_commits`, ...) sit at the top level
462/// alongside `actions`. Optional inner fields (`ownership`,
463/// `is_test_path`) keep their original `skip_serializing_if` behaviour
464/// because serde applies the flatten before the parent serializer runs.
465///
466/// Construct via [`HotspotFinding::with_actions`] in the typical health
467/// pipeline (the typed action builder operates on the inner
468/// [`HotspotEntry`]) or via [`HotspotFinding::from`] for fixture and
469/// test code.
470#[derive(Debug, Clone, serde::Serialize)]
471#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
472pub struct HotspotFinding {
473    /// Inner hotspot payload. Flattened on the wire.
474    #[serde(flatten)]
475    pub entry: HotspotEntry,
476    /// Machine-actionable refactor and review hints. Always populated;
477    /// the list never empties because the action selector unconditionally
478    /// emits `refactor-file` plus `add-tests`. Ownership-derived variants
479    /// (`low-bus-factor`, `unowned-hotspot`, `ownership-drift`) are
480    /// appended when `--ownership` is active and the corresponding signal
481    /// fires.
482    pub actions: Vec<HotspotAction>,
483}
484
485impl Deref for HotspotFinding {
486    type Target = HotspotEntry;
487
488    fn deref(&self) -> &Self::Target {
489        &self.entry
490    }
491}
492
493impl From<HotspotEntry> for HotspotFinding {
494    /// Convenience conversion: wrap a hotspot entry with an empty
495    /// `actions` list. Used by tests and fixture builders. Production
496    /// code should call [`HotspotFinding::with_actions`] so the wire
497    /// shape carries the typed actions.
498    fn from(entry: HotspotEntry) -> Self {
499        Self {
500            entry,
501            actions: Vec::new(),
502        }
503    }
504}
505
506impl HotspotFinding {
507    /// Construct a wrapper with the `actions` list computed from the
508    /// hotspot's measured signals plus its ownership block (when
509    /// present).
510    ///
511    /// `root` is the project root used to strip the absolute
512    /// [`HotspotEntry::path`] when composing action descriptions like
513    /// `"Refactor `{path}`, ..."`.
514    /// The JSON post-pass that this wrapper retires ran AFTER
515    /// `strip_root_prefix`, so the typed builder must apply the same
516    /// stripping here for byte-identical wire output.
517    #[must_use]
518    pub fn with_actions(entry: HotspotEntry, root: &Path) -> Self {
519        let actions = build_hotspot_actions(&entry, root);
520        Self { entry, actions }
521    }
522}
523
524/// Compute the typed `actions` list for a hotspot entry.
525///
526/// The list always begins with `refactor-file` plus `add-tests`. The
527/// ownership-derived variants (`low-bus-factor`, `unowned-hotspot`,
528/// `ownership-drift`) are appended when [`HotspotEntry::ownership`] is
529/// present and the corresponding signal fires.
530fn build_hotspot_actions(entry: &HotspotEntry, root: &Path) -> Vec<HotspotAction> {
531    let relative = entry.path.strip_prefix(root).unwrap_or(&entry.path);
532    // Normalise Windows backslashes to forward slashes. The retired JSON
533    // post-pass read the path AFTER `strip_root_prefix` (which calls
534    // `normalize_uri` to flip `\\` to `/`), so action descriptions on
535    // Windows used forward slashes; the typed builder runs before
536    // serialisation and must apply the same normalisation for cross-
537    // platform wire parity.
538    let path = relative.to_string_lossy().replace('\\', "/");
539
540    let mut actions = vec![
541        HotspotAction {
542            kind: HotspotActionType::RefactorFile,
543            auto_fixable: false,
544            description: format!(
545                "Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"
546            ),
547            note: Some(
548                "Prioritize extracting complex functions, adding tests, or splitting the module"
549                    .to_string(),
550            ),
551            suggested_pattern: None,
552            heuristic: None,
553        },
554        HotspotAction {
555            kind: HotspotActionType::AddTests,
556            auto_fixable: false,
557            description: format!("Add test coverage for `{path}` to reduce change risk"),
558            note: Some(
559                "Frequently changed complex files benefit most from comprehensive test coverage"
560                    .to_string(),
561            ),
562            suggested_pattern: None,
563            heuristic: None,
564        },
565    ];
566
567    let Some(ownership) = entry.ownership.as_ref() else {
568        return actions;
569    };
570
571    // Bus factor of 1 is the canonical "single point of failure" signal.
572    if ownership.bus_factor == 1 {
573        let top = &ownership.top_contributor;
574        let owner = top.identifier.as_str();
575        let commits = top.commits;
576        // File-specific note: name the candidate reviewers from the
577        // `suggested_reviewers` array when any exist, fall back to
578        // softened framing for low-commit files, and otherwise omit
579        // the note entirely (the description already carries the
580        // actionable ask; adding generic boilerplate wastes tokens).
581        let suggested: Vec<&str> = ownership
582            .suggested_reviewers
583            .iter()
584            .map(|r| r.identifier.as_str())
585            .collect();
586        let note = if suggested.is_empty() {
587            if commits < 5 {
588                Some(
589                    "Single recent contributor on a low-commit file. Consider a pair review for major changes."
590                        .to_string(),
591                )
592            } else {
593                // else: omit `note` entirely. The description already carries the ask.
594                None
595            }
596        } else {
597            let list = suggested
598                .iter()
599                .map(|s| format!("@{s}"))
600                .collect::<Vec<_>>()
601                .join(", ");
602            Some(format!("Candidate reviewers: {list}"))
603        };
604        actions.push(HotspotAction {
605            kind: HotspotActionType::LowBusFactor,
606            auto_fixable: false,
607            description: format!(
608                "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
609            ),
610            note,
611            suggested_pattern: None,
612            heuristic: None,
613        });
614    }
615
616    // Unowned-hotspot: file matches no CODEOWNERS rule. Skip when None
617    // (no CODEOWNERS file discovered) or Some(false) (a rule matches).
618    if ownership.unowned == Some(true) {
619        actions.push(HotspotAction {
620            kind: HotspotActionType::UnownedHotspot,
621            auto_fixable: false,
622            description: format!("Add a CODEOWNERS entry for `{path}`"),
623            note: Some(
624                "Frequently-changed files without declared owners create review bottlenecks"
625                    .to_string(),
626            ),
627            suggested_pattern: Some(suggest_codeowners_pattern(&path)),
628            heuristic: Some(HotspotActionHeuristic::DirectoryDeepest),
629        });
630    }
631
632    // Drift: original author no longer maintains; add a notice action so
633    // agents can route the next change to the new top contributor.
634    if ownership.drift {
635        let reason = ownership
636            .drift_reason
637            .as_deref()
638            .unwrap_or("ownership has shifted from the original author");
639        actions.push(HotspotAction {
640            kind: HotspotActionType::OwnershipDrift,
641            auto_fixable: false,
642            description: format!("Update CODEOWNERS for `{path}`: {reason}"),
643            note: Some(
644                "Drift suggests the declared or original owner is no longer the right reviewer"
645                    .to_string(),
646            ),
647            suggested_pattern: None,
648            heuristic: None,
649        });
650    }
651
652    actions
653}
654
655/// Suggest a CODEOWNERS pattern for an unowned hotspot.
656///
657/// Picks the deepest directory containing the file
658/// (e.g. `src/api/users/handlers.ts` -> `/src/api/users/`) so agents can
659/// paste a tightly-scoped default. Earlier versions used the first two
660/// directory levels but that catches too many siblings in monorepos
661/// (`/src/api/` could span 200 files across 8 sub-domains). The deepest
662/// directory keeps the suggestion reviewable while still being a directory
663/// pattern rather than a per-file rule.
664///
665/// The action emits this alongside
666/// [`HotspotActionHeuristic::DirectoryDeepest`] so consumers can branch
667/// on the strategy if it evolves.
668fn suggest_codeowners_pattern(path: &str) -> String {
669    let normalized = path.replace('\\', "/");
670    let trimmed = normalized.trim_start_matches('/');
671    let mut components: Vec<&str> = trimmed.split('/').collect();
672    components.pop(); // drop the file itself
673    if components.is_empty() {
674        return format!("/{trimmed}");
675    }
676    format!("/{}/", components.join("/"))
677}
678
679// ────────────────────────────────────────────────────────────────────
680// RefactoringTargetFinding
681// ────────────────────────────────────────────────────────────────────
682
683/// Wire envelope for a single refactoring target.
684///
685/// Flattens [`RefactoringTarget`] for wire continuity and adds the typed
686/// `actions` list. The `#[serde(flatten)]` keeps each `targets[]` item
687/// byte-identical to the pre-wrapper shape: inner fields (`path`,
688/// `priority`, `efficiency`, `recommendation`, `category`, ...) sit at
689/// the top level alongside `actions`. Optional inner fields (`factors`,
690/// `evidence`) keep their original `skip_serializing_if` behaviour.
691///
692/// Construct via [`RefactoringTargetFinding::with_actions`] in the
693/// typical health pipeline or via [`RefactoringTargetFinding::from`] for
694/// fixture and test code.
695#[derive(Debug, Clone, serde::Serialize)]
696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
697pub struct RefactoringTargetFinding {
698    /// Inner refactoring target payload. Flattened on the wire.
699    #[serde(flatten)]
700    pub target: RefactoringTarget,
701    /// Machine-actionable refactoring and suppression hints. Always
702    /// populated; the list never empties because the action selector
703    /// unconditionally emits `apply-refactoring`. A trailing
704    /// `suppress-line` is appended only when the target carries
705    /// [`RefactoringTarget::evidence`] linking to specific functions.
706    pub actions: Vec<RefactoringTargetAction>,
707}
708
709impl Deref for RefactoringTargetFinding {
710    type Target = RefactoringTarget;
711
712    fn deref(&self) -> &Self::Target {
713        &self.target
714    }
715}
716
717impl From<RefactoringTarget> for RefactoringTargetFinding {
718    /// Convenience conversion: wrap a refactoring target with an empty
719    /// `actions` list. Used by tests and fixture builders. Production
720    /// code should call [`RefactoringTargetFinding::with_actions`] so
721    /// the wire shape carries the typed actions.
722    fn from(target: RefactoringTarget) -> Self {
723        Self {
724            target,
725            actions: Vec::new(),
726        }
727    }
728}
729
730impl RefactoringTargetFinding {
731    /// Construct a wrapper with the `actions` list computed from the
732    /// target's `recommendation`, `category`, and optional `evidence`.
733    ///
734    /// Asymmetry with [`HotspotFinding::with_actions`]: this constructor
735    /// does NOT take a `root: &Path` because refactoring-target action
736    /// descriptions never interpolate the file path; they pass
737    /// [`RefactoringTarget::recommendation`] verbatim into the
738    /// `apply-refactoring` action. The [`RefactoringTarget::category`]
739    /// field flows into the action's `category` field as the serde
740    /// snake-case form.
741    #[must_use]
742    pub fn with_actions(target: RefactoringTarget) -> Self {
743        let actions = build_refactoring_target_actions(&target);
744        Self { target, actions }
745    }
746}
747
748/// Compute the typed `actions` list for a refactoring target.
749///
750/// The list always begins with `apply-refactoring`. A trailing
751/// `suppress-line` is appended only when the target carries
752/// [`RefactoringTarget::evidence`] linking to specific functions.
753fn build_refactoring_target_actions(target: &RefactoringTarget) -> Vec<RefactoringTargetAction> {
754    let mut actions = vec![RefactoringTargetAction {
755        kind: RefactoringTargetActionType::ApplyRefactoring,
756        auto_fixable: false,
757        description: target.recommendation.clone(),
758        category: Some(category_snake_case(&target.category).to_string()),
759        comment: None,
760    }];
761
762    if target.evidence.is_some() {
763        actions.push(RefactoringTargetAction {
764            kind: RefactoringTargetActionType::SuppressLine,
765            auto_fixable: false,
766            description: "Suppress the underlying complexity finding".to_string(),
767            category: None,
768            comment: Some("// fallow-ignore-next-line complexity".to_string()),
769        });
770    }
771
772    actions
773}
774
775/// Serde-rename_all-snake_case form of a [`RecommendationCategory`]
776/// variant.
777///
778/// `RefactoringTargetAction.category` is `Option<String>` carrying the
779/// serde-encoded form of [`RecommendationCategory`]. The JSON post-pass
780/// retired by issue #408 read this string from the serialized JSON
781/// value; the typed action builder needs the same form without paying
782/// for a serde round-trip per target. The
783/// `recommendation_category_snake_case_round_trips` test in this module
784/// asserts every variant matches `serde_json::to_value` byte-for-byte,
785/// so silent drift between this function and the
786/// `#[serde(rename_all = "snake_case")]` attribute is caught at test
787/// time.
788const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
789    match cat {
790        RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
791        RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
792        RecommendationCategory::SplitHighImpact => "split_high_impact",
793        RecommendationCategory::RemoveDeadCode => "remove_dead_code",
794        RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
795        RecommendationCategory::ExtractDependencies => "extract_dependencies",
796        RecommendationCategory::AddTestCoverage => "add_test_coverage",
797    }
798}
799
800#[cfg(test)]
801mod hotspot_target_tests {
802    use super::*;
803    use crate::health_types::scores::{
804        ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics,
805    };
806    use fallow_core::churn::ChurnTrend;
807    use std::path::PathBuf;
808
809    fn sample_entry(path: &str) -> HotspotEntry {
810        HotspotEntry {
811            path: PathBuf::from(path),
812            score: 80.0,
813            commits: 12,
814            weighted_commits: 8.0,
815            lines_added: 100,
816            lines_deleted: 40,
817            complexity_density: 1.5,
818            fan_in: 3,
819            trend: ChurnTrend::Stable,
820            ownership: None,
821            is_test_path: false,
822        }
823    }
824
825    fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
826        ContributorEntry {
827            identifier: identifier.to_string(),
828            format: ContributorIdentifierFormat::Handle,
829            share: 1.0,
830            stale_days: 1,
831            commits,
832        }
833    }
834
835    fn sample_target() -> RefactoringTarget {
836        RefactoringTarget {
837            path: PathBuf::from("/root/src/foo.ts"),
838            priority: 75.0,
839            efficiency: 75.0,
840            recommendation: "Extract `handleRequest` into helpers".to_string(),
841            category: RecommendationCategory::ExtractComplexFunctions,
842            effort: crate::health_types::EffortEstimate::Low,
843            confidence: crate::health_types::Confidence::High,
844            factors: Vec::new(),
845            evidence: None,
846        }
847    }
848
849    #[test]
850    fn hotspot_finding_flattens_inner_fields_at_top_level() {
851        let entry = sample_entry("/root/src/api.ts");
852        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
853        let json = serde_json::to_value(&finding).unwrap();
854        let obj = json.as_object().unwrap();
855        // Inner fields at the top level via flatten.
856        assert!(obj.contains_key("score"));
857        assert!(obj.contains_key("commits"));
858        assert!(obj.contains_key("weighted_commits"));
859        // Wrapper-only field.
860        assert!(obj.contains_key("actions"));
861        // Optional inner fields with skip_serializing_if respect their attrs.
862        assert!(!obj.contains_key("ownership"));
863        assert!(!obj.contains_key("is_test_path"));
864    }
865
866    #[test]
867    fn hotspot_actions_default_pair_when_ownership_absent() {
868        let entry = sample_entry("/root/src/api.ts");
869        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
870        assert_eq!(finding.actions.len(), 2);
871        assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
872        assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
873        assert!(finding.actions[0].description.contains("src/api.ts"));
874    }
875
876    #[test]
877    fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
878        let mut entry = sample_entry("/root/src/api.ts");
879        entry.ownership = Some(OwnershipMetrics {
880            bus_factor: 1,
881            contributor_count: 1,
882            top_contributor: contributor("alice", 30),
883            recent_contributors: Vec::new(),
884            suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
885            declared_owner: None,
886            unowned: None,
887            drift: false,
888            drift_reason: None,
889        });
890        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
891        let low_bus = finding
892            .actions
893            .iter()
894            .find(|a| a.kind == HotspotActionType::LowBusFactor)
895            .expect("low-bus-factor action present");
896        assert_eq!(
897            low_bus.note.as_deref(),
898            Some("Candidate reviewers: @bob, @carol"),
899        );
900    }
901
902    #[test]
903    fn hotspot_low_bus_factor_softens_for_low_commit_files() {
904        let mut entry = sample_entry("/root/src/api.ts");
905        entry.ownership = Some(OwnershipMetrics {
906            bus_factor: 1,
907            contributor_count: 1,
908            top_contributor: contributor("alice", 3),
909            recent_contributors: Vec::new(),
910            suggested_reviewers: Vec::new(),
911            declared_owner: None,
912            unowned: None,
913            drift: false,
914            drift_reason: None,
915        });
916        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
917        let low_bus = finding
918            .actions
919            .iter()
920            .find(|a| a.kind == HotspotActionType::LowBusFactor)
921            .expect("low-bus-factor action present");
922        assert_eq!(
923            low_bus.note.as_deref(),
924            Some(
925                "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
926            ),
927        );
928    }
929
930    #[test]
931    fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
932        let mut entry = sample_entry("/root/src/api.ts");
933        entry.ownership = Some(OwnershipMetrics {
934            bus_factor: 1,
935            contributor_count: 1,
936            top_contributor: contributor("alice", 50),
937            recent_contributors: Vec::new(),
938            suggested_reviewers: Vec::new(),
939            declared_owner: None,
940            unowned: None,
941            drift: false,
942            drift_reason: None,
943        });
944        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
945        let low_bus = finding
946            .actions
947            .iter()
948            .find(|a| a.kind == HotspotActionType::LowBusFactor)
949            .expect("low-bus-factor action present");
950        assert!(low_bus.note.is_none());
951    }
952
953    #[test]
954    fn hotspot_unowned_action_carries_deepest_directory_pattern() {
955        let mut entry = sample_entry("/root/src/api/users/handlers.ts");
956        entry.ownership = Some(OwnershipMetrics {
957            bus_factor: 2,
958            contributor_count: 3,
959            top_contributor: contributor("alice", 10),
960            recent_contributors: Vec::new(),
961            suggested_reviewers: Vec::new(),
962            declared_owner: None,
963            unowned: Some(true),
964            drift: false,
965            drift_reason: None,
966        });
967        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
968        let unowned = finding
969            .actions
970            .iter()
971            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
972            .expect("unowned-hotspot action present");
973        assert_eq!(
974            unowned.suggested_pattern.as_deref(),
975            Some("/src/api/users/")
976        );
977        assert_eq!(
978            unowned.heuristic,
979            Some(HotspotActionHeuristic::DirectoryDeepest)
980        );
981    }
982
983    #[test]
984    fn hotspot_action_descriptions_normalise_windows_separators() {
985        // Cross-platform parity: the retired JSON post-pass read the path
986        // AFTER `strip_root_prefix` (which normalises backslashes via
987        // `normalize_uri`). The typed builder runs before serialisation
988        // and must apply the same normalisation, otherwise action
989        // descriptions on Windows would contain `src\api.ts` while macOS
990        // / Linux see `src/api.ts`. Simulating the Windows shape by
991        // constructing a path with embedded backslashes is cross-platform
992        // safe because `Path` on Unix treats the entire literal as one
993        // component (no `strip_prefix` match) and the builder falls
994        // back to the input path, which the `replace('\\', "/")` then
995        // normalises to forward slashes for description embedding.
996        let mut entry = sample_entry("src\\api\\users.ts");
997        entry.ownership = Some(OwnershipMetrics {
998            bus_factor: 2,
999            contributor_count: 3,
1000            top_contributor: contributor("alice", 10),
1001            recent_contributors: Vec::new(),
1002            suggested_reviewers: Vec::new(),
1003            declared_owner: None,
1004            unowned: Some(true),
1005            drift: false,
1006            drift_reason: None,
1007        });
1008        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
1009        let refactor = finding
1010            .actions
1011            .iter()
1012            .find(|a| a.kind == HotspotActionType::RefactorFile)
1013            .expect("refactor-file action present");
1014        assert!(refactor.description.contains("src/api/users.ts"));
1015        assert!(!refactor.description.contains('\\'));
1016        let unowned = finding
1017            .actions
1018            .iter()
1019            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
1020            .expect("unowned-hotspot action present");
1021        assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
1022    }
1023
1024    #[test]
1025    fn hotspot_drift_action_uses_provided_reason() {
1026        let mut entry = sample_entry("/root/src/api.ts");
1027        entry.ownership = Some(OwnershipMetrics {
1028            bus_factor: 2,
1029            contributor_count: 4,
1030            top_contributor: contributor("alice", 10),
1031            recent_contributors: Vec::new(),
1032            suggested_reviewers: Vec::new(),
1033            declared_owner: None,
1034            unowned: Some(false),
1035            drift: true,
1036            drift_reason: Some("top contributor changed in last 6 months".to_string()),
1037        });
1038        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
1039        let drift = finding
1040            .actions
1041            .iter()
1042            .find(|a| a.kind == HotspotActionType::OwnershipDrift)
1043            .expect("ownership-drift action present");
1044        assert!(
1045            drift
1046                .description
1047                .contains("top contributor changed in last 6 months"),
1048        );
1049    }
1050
1051    #[test]
1052    fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
1053        let target = sample_target();
1054        let finding = RefactoringTargetFinding::with_actions(target);
1055        let json = serde_json::to_value(&finding).unwrap();
1056        let obj = json.as_object().unwrap();
1057        // Inner fields at the top level via flatten.
1058        assert!(obj.contains_key("priority"));
1059        assert!(obj.contains_key("efficiency"));
1060        assert!(obj.contains_key("recommendation"));
1061        assert!(obj.contains_key("category"));
1062        // Wrapper-only field.
1063        assert!(obj.contains_key("actions"));
1064        // Optional inner fields skipped when empty / None.
1065        assert!(!obj.contains_key("factors"));
1066        assert!(!obj.contains_key("evidence"));
1067    }
1068
1069    #[test]
1070    fn refactoring_target_actions_default_to_apply_only_without_evidence() {
1071        let target = sample_target();
1072        let finding = RefactoringTargetFinding::with_actions(target);
1073        assert_eq!(finding.actions.len(), 1);
1074        assert_eq!(
1075            finding.actions[0].kind,
1076            RefactoringTargetActionType::ApplyRefactoring,
1077        );
1078        assert_eq!(
1079            finding.actions[0].category.as_deref(),
1080            Some("extract_complex_functions"),
1081        );
1082        assert_eq!(
1083            finding.actions[0].description,
1084            "Extract `handleRequest` into helpers",
1085        );
1086    }
1087
1088    #[test]
1089    fn refactoring_target_actions_append_suppress_when_evidence_present() {
1090        let mut target = sample_target();
1091        target.evidence = Some(crate::health_types::TargetEvidence {
1092            unused_exports: Vec::new(),
1093            complex_functions: vec![crate::health_types::EvidenceFunction {
1094                name: "handleRequest".to_string(),
1095                line: 12,
1096                cognitive: 30,
1097            }],
1098            cycle_path: Vec::new(),
1099        });
1100        let finding = RefactoringTargetFinding::with_actions(target);
1101        assert_eq!(finding.actions.len(), 2);
1102        assert_eq!(
1103            finding.actions[1].kind,
1104            RefactoringTargetActionType::SuppressLine,
1105        );
1106        assert_eq!(
1107            finding.actions[1].comment.as_deref(),
1108            Some("// fallow-ignore-next-line complexity"),
1109        );
1110    }
1111
1112    #[test]
1113    fn codeowners_pattern_uses_deepest_directory() {
1114        // Deepest dir keeps the suggestion tightly-scoped; the prior
1115        // "first two levels" heuristic over-generalized in monorepos.
1116        assert_eq!(
1117            suggest_codeowners_pattern("src/api/users/handlers.ts"),
1118            "/src/api/users/",
1119        );
1120    }
1121
1122    #[test]
1123    fn codeowners_pattern_for_root_file() {
1124        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
1125    }
1126
1127    #[test]
1128    fn codeowners_pattern_normalizes_backslashes() {
1129        assert_eq!(
1130            suggest_codeowners_pattern("src\\api\\users.ts"),
1131            "/src/api/",
1132        );
1133    }
1134
1135    #[test]
1136    fn codeowners_pattern_two_level_path() {
1137        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1138    }
1139
1140    #[test]
1141    fn recommendation_category_snake_case_round_trips_through_serde() {
1142        // Hard gate against drift between `category_snake_case` and the
1143        // `#[serde(rename_all = "snake_case")]` attribute on
1144        // `RecommendationCategory`. If a future contributor adds a new
1145        // variant and forgets to extend the match, this test will fail
1146        // because `serde_json::to_value` will emit one form and the
1147        // hand-rolled mapper will emit another.
1148        let variants = [
1149            RecommendationCategory::UrgentChurnComplexity,
1150            RecommendationCategory::BreakCircularDependency,
1151            RecommendationCategory::SplitHighImpact,
1152            RecommendationCategory::RemoveDeadCode,
1153            RecommendationCategory::ExtractComplexFunctions,
1154            RecommendationCategory::ExtractDependencies,
1155            RecommendationCategory::AddTestCoverage,
1156        ];
1157        for cat in &variants {
1158            let via_serde = serde_json::to_value(cat).unwrap();
1159            let serde_str = via_serde.as_str().unwrap();
1160            assert_eq!(
1161                serde_str,
1162                category_snake_case(cat),
1163                "category_snake_case for {cat:?} drifted from serde rename_all",
1164            );
1165        }
1166    }
1167}