Skip to main content

fallow_output/
health_findings.rs

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