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