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