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    if target.evidence.is_some() {
717        actions.push(RefactoringTargetAction {
718            kind: RefactoringTargetActionType::SuppressLine,
719            auto_fixable: false,
720            description: "Suppress the underlying complexity finding".to_string(),
721            category: None,
722            comment: Some("// fallow-ignore-next-line complexity".to_string()),
723        });
724    }
725
726    actions
727}
728
729/// Serde-rename_all-snake_case form of a [`RecommendationCategory`]
730/// variant.
731///
732/// `RefactoringTargetAction.category` is `Option<String>` carrying the
733/// serde-encoded form of [`RecommendationCategory`]. The JSON post-pass
734/// retired by issue #408 read this string from the serialized JSON
735/// value; the typed action builder needs the same form without paying
736/// for a serde round-trip per target. The
737/// `recommendation_category_snake_case_round_trips` test in this module
738/// asserts every variant matches `serde_json::to_value` byte-for-byte,
739/// so silent drift between this function and the
740/// `#[serde(rename_all = "snake_case")]` attribute is caught at test
741/// time.
742const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
743    match cat {
744        RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
745        RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
746        RecommendationCategory::SplitHighImpact => "split_high_impact",
747        RecommendationCategory::RemoveDeadCode => "remove_dead_code",
748        RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
749        RecommendationCategory::ExtractDependencies => "extract_dependencies",
750        RecommendationCategory::AddTestCoverage => "add_test_coverage",
751    }
752}
753
754#[cfg(test)]
755mod hotspot_target_tests {
756    use super::*;
757    use crate::{
758        Confidence, ContributorEntry, ContributorIdentifierFormat, EffortEstimate,
759        EvidenceFunction, OwnershipMetrics, OwnershipState, TargetEvidence,
760    };
761    use fallow_types::churn::ChurnTrend;
762    use std::path::PathBuf;
763
764    fn sample_entry(path: &str) -> HotspotEntry {
765        HotspotEntry {
766            path: PathBuf::from(path),
767            score: 80.0,
768            commits: 12,
769            weighted_commits: 8.0,
770            lines_added: 100,
771            lines_deleted: 40,
772            complexity_density: 1.5,
773            fan_in: 3,
774            trend: ChurnTrend::Stable,
775            ownership: None,
776            is_test_path: false,
777        }
778    }
779
780    fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
781        ContributorEntry {
782            identifier: identifier.to_string(),
783            format: ContributorIdentifierFormat::Handle,
784            share: 1.0,
785            stale_days: 1,
786            commits,
787        }
788    }
789
790    fn sample_target() -> RefactoringTarget {
791        RefactoringTarget {
792            path: PathBuf::from("/root/src/foo.ts"),
793            priority: 75.0,
794            efficiency: 75.0,
795            recommendation: "Extract `handleRequest` into helpers".to_string(),
796            category: RecommendationCategory::ExtractComplexFunctions,
797            effort: EffortEstimate::Low,
798            confidence: Confidence::High,
799            factors: Vec::new(),
800            evidence: None,
801        }
802    }
803
804    #[test]
805    fn hotspot_finding_flattens_inner_fields_at_top_level() {
806        let entry = sample_entry("/root/src/api.ts");
807        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
808        let json = serde_json::to_value(&finding).expect("hotspot finding should serialize");
809        let obj = json
810            .as_object()
811            .expect("hotspot finding should serialize as object");
812        assert!(obj.contains_key("score"));
813        assert!(obj.contains_key("commits"));
814        assert!(obj.contains_key("weighted_commits"));
815        assert!(obj.contains_key("actions"));
816        assert!(!obj.contains_key("ownership"));
817        assert!(!obj.contains_key("is_test_path"));
818    }
819
820    #[test]
821    fn hotspot_actions_default_pair_when_ownership_absent() {
822        let entry = sample_entry("/root/src/api.ts");
823        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
824        assert_eq!(finding.actions.len(), 2);
825        assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
826        assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
827        assert!(finding.actions[0].description.contains("src/api.ts"));
828    }
829
830    #[test]
831    fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
832        let mut entry = sample_entry("/root/src/api.ts");
833        entry.ownership = Some(OwnershipMetrics {
834            bus_factor: 1,
835            contributor_count: 1,
836            top_contributor: contributor("alice", 30),
837            recent_contributors: Vec::new(),
838            suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
839            declared_owner: None,
840            unowned: None,
841            ownership_state: OwnershipState::Active,
842            drift: false,
843            drift_reason: None,
844        });
845        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
846        let low_bus = finding
847            .actions
848            .iter()
849            .find(|a| a.kind == HotspotActionType::LowBusFactor)
850            .expect("low-bus-factor action present");
851        assert_eq!(
852            low_bus.note.as_deref(),
853            Some("Candidate reviewers: @bob, @carol"),
854        );
855    }
856
857    #[test]
858    fn hotspot_low_bus_factor_softens_for_low_commit_files() {
859        let mut entry = sample_entry("/root/src/api.ts");
860        entry.ownership = Some(OwnershipMetrics {
861            bus_factor: 1,
862            contributor_count: 1,
863            top_contributor: contributor("alice", 3),
864            recent_contributors: Vec::new(),
865            suggested_reviewers: Vec::new(),
866            declared_owner: None,
867            unowned: None,
868            ownership_state: OwnershipState::Active,
869            drift: false,
870            drift_reason: None,
871        });
872        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
873        let low_bus = finding
874            .actions
875            .iter()
876            .find(|a| a.kind == HotspotActionType::LowBusFactor)
877            .expect("low-bus-factor action present");
878        assert_eq!(
879            low_bus.note.as_deref(),
880            Some(
881                "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
882            ),
883        );
884    }
885
886    #[test]
887    fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
888        let mut entry = sample_entry("/root/src/api.ts");
889        entry.ownership = Some(OwnershipMetrics {
890            bus_factor: 1,
891            contributor_count: 1,
892            top_contributor: contributor("alice", 50),
893            recent_contributors: Vec::new(),
894            suggested_reviewers: Vec::new(),
895            declared_owner: None,
896            unowned: None,
897            ownership_state: OwnershipState::Active,
898            drift: false,
899            drift_reason: None,
900        });
901        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
902        let low_bus = finding
903            .actions
904            .iter()
905            .find(|a| a.kind == HotspotActionType::LowBusFactor)
906            .expect("low-bus-factor action present");
907        assert!(low_bus.note.is_none());
908    }
909
910    #[test]
911    fn hotspot_unowned_action_carries_deepest_directory_pattern() {
912        let mut entry = sample_entry("/root/src/api/users/handlers.ts");
913        entry.ownership = Some(OwnershipMetrics {
914            bus_factor: 2,
915            contributor_count: 3,
916            top_contributor: contributor("alice", 10),
917            recent_contributors: Vec::new(),
918            suggested_reviewers: Vec::new(),
919            declared_owner: None,
920            unowned: Some(true),
921            ownership_state: OwnershipState::Unowned,
922            drift: false,
923            drift_reason: None,
924        });
925        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
926        let unowned = finding
927            .actions
928            .iter()
929            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
930            .expect("unowned-hotspot action present");
931        assert_eq!(
932            unowned.suggested_pattern.as_deref(),
933            Some("/src/api/users/")
934        );
935        assert_eq!(
936            unowned.heuristic,
937            Some(HotspotActionHeuristic::DirectoryDeepest)
938        );
939    }
940
941    #[test]
942    fn hotspot_action_descriptions_normalise_windows_separators() {
943        let mut entry = sample_entry("src\\api\\users.ts");
944        entry.ownership = Some(OwnershipMetrics {
945            bus_factor: 2,
946            contributor_count: 3,
947            top_contributor: contributor("alice", 10),
948            recent_contributors: Vec::new(),
949            suggested_reviewers: Vec::new(),
950            declared_owner: None,
951            unowned: Some(true),
952            ownership_state: OwnershipState::Unowned,
953            drift: false,
954            drift_reason: None,
955        });
956        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
957        let refactor = finding
958            .actions
959            .iter()
960            .find(|a| a.kind == HotspotActionType::RefactorFile)
961            .expect("refactor-file action present");
962        assert!(refactor.description.contains("src/api/users.ts"));
963        assert!(!refactor.description.contains('\\'));
964        let unowned = finding
965            .actions
966            .iter()
967            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
968            .expect("unowned-hotspot action present");
969        assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
970    }
971
972    #[test]
973    fn hotspot_drift_action_uses_provided_reason() {
974        let mut entry = sample_entry("/root/src/api.ts");
975        entry.ownership = Some(OwnershipMetrics {
976            bus_factor: 2,
977            contributor_count: 4,
978            top_contributor: contributor("alice", 10),
979            recent_contributors: Vec::new(),
980            suggested_reviewers: Vec::new(),
981            declared_owner: None,
982            unowned: Some(false),
983            ownership_state: OwnershipState::Drifting,
984            drift: true,
985            drift_reason: Some("top contributor changed in last 6 months".to_string()),
986        });
987        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
988        let drift = finding
989            .actions
990            .iter()
991            .find(|a| a.kind == HotspotActionType::OwnershipDrift)
992            .expect("ownership-drift action present");
993        assert!(
994            drift
995                .description
996                .contains("top contributor changed in last 6 months"),
997        );
998    }
999
1000    #[test]
1001    fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
1002        let target = sample_target();
1003        let finding = RefactoringTargetFinding::with_actions(target);
1004        let json =
1005            serde_json::to_value(&finding).expect("refactoring target finding should serialize");
1006        let obj = json
1007            .as_object()
1008            .expect("refactoring target finding should serialize as object");
1009        assert!(obj.contains_key("priority"));
1010        assert!(obj.contains_key("efficiency"));
1011        assert!(obj.contains_key("recommendation"));
1012        assert!(obj.contains_key("category"));
1013        assert!(obj.contains_key("actions"));
1014        assert!(!obj.contains_key("factors"));
1015        assert!(!obj.contains_key("evidence"));
1016    }
1017
1018    #[test]
1019    fn refactoring_target_actions_default_to_apply_only_without_evidence() {
1020        let target = sample_target();
1021        let finding = RefactoringTargetFinding::with_actions(target);
1022        assert_eq!(finding.actions.len(), 1);
1023        assert_eq!(
1024            finding.actions[0].kind,
1025            RefactoringTargetActionType::ApplyRefactoring,
1026        );
1027        assert_eq!(
1028            finding.actions[0].category.as_deref(),
1029            Some("extract_complex_functions"),
1030        );
1031        assert_eq!(
1032            finding.actions[0].description,
1033            "Extract `handleRequest` into helpers",
1034        );
1035    }
1036
1037    #[test]
1038    fn refactoring_target_actions_append_suppress_when_evidence_present() {
1039        let mut target = sample_target();
1040        target.evidence = Some(TargetEvidence {
1041            unused_exports: Vec::new(),
1042            complex_functions: vec![EvidenceFunction {
1043                name: "handleRequest".to_string(),
1044                line: 12,
1045                cognitive: 30,
1046            }],
1047            cycle_path: Vec::new(),
1048            ..Default::default()
1049        });
1050        let finding = RefactoringTargetFinding::with_actions(target);
1051        assert_eq!(finding.actions.len(), 2);
1052        assert_eq!(
1053            finding.actions[1].kind,
1054            RefactoringTargetActionType::SuppressLine,
1055        );
1056        assert_eq!(
1057            finding.actions[1].comment.as_deref(),
1058            Some("// fallow-ignore-next-line complexity"),
1059        );
1060    }
1061
1062    #[test]
1063    fn codeowners_pattern_uses_deepest_directory() {
1064        assert_eq!(
1065            suggest_codeowners_pattern("src/api/users/handlers.ts"),
1066            "/src/api/users/",
1067        );
1068    }
1069
1070    #[test]
1071    fn codeowners_pattern_for_root_file() {
1072        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
1073    }
1074
1075    #[test]
1076    fn codeowners_pattern_normalizes_backslashes() {
1077        assert_eq!(
1078            suggest_codeowners_pattern("src\\api\\users.ts"),
1079            "/src/api/",
1080        );
1081    }
1082
1083    #[test]
1084    fn codeowners_pattern_two_level_path() {
1085        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1086    }
1087
1088    #[test]
1089    fn recommendation_category_snake_case_round_trips_through_serde() {
1090        let variants = [
1091            RecommendationCategory::UrgentChurnComplexity,
1092            RecommendationCategory::BreakCircularDependency,
1093            RecommendationCategory::SplitHighImpact,
1094            RecommendationCategory::RemoveDeadCode,
1095            RecommendationCategory::ExtractComplexFunctions,
1096            RecommendationCategory::ExtractDependencies,
1097            RecommendationCategory::AddTestCoverage,
1098        ];
1099        for cat in &variants {
1100            let via_serde = serde_json::to_value(cat).expect("category should serialize");
1101            let serde_str = via_serde
1102                .as_str()
1103                .expect("category should serialize as string");
1104            assert_eq!(
1105                serde_str,
1106                category_snake_case(cat),
1107                "category_snake_case for {cat:?} drifted from serde rename_all",
1108            );
1109        }
1110    }
1111}
1112
1113#[cfg(test)]
1114mod crap_action_tests {
1115    use super::*;
1116
1117    #[test]
1118    fn inherited_coverage_redirects_to_the_owning_component() {
1119        let owner = Path::new("src/host.component.ts");
1120        let action = build_crap_coverage_action("<template>", None, true, Some(owner))
1121            .expect("an inherited-coverage finding gets an action");
1122        assert!(matches!(
1123            action.kind,
1124            HealthFindingActionType::IncreaseCoverage
1125        ));
1126        assert_eq!(action.target_path.as_deref(), Some("src/host.component.ts"));
1127    }
1128
1129    #[test]
1130    fn a_real_function_with_no_coverage_still_gets_add_tests() {
1131        let action = build_crap_coverage_action("parseExpression", None, true, None)
1132            .expect("an untested function gets an action");
1133        assert!(matches!(action.kind, HealthFindingActionType::AddTests));
1134    }
1135
1136    #[test]
1137    fn snippet_units_on_svelte_route_to_the_sfc_suppress_comment() {
1138        let mut violation = crate::ComplexityViolation {
1139            path: std::path::PathBuf::from("src/Widget.svelte"),
1140            name: "<snippet:rowBody>".to_string(),
1141            line: 12,
1142            col: 2,
1143            cyclomatic: 16,
1144            cognitive: 13,
1145            line_count: 20,
1146            param_count: 0,
1147            react_hook_count: 0,
1148            react_jsx_max_depth: 0,
1149            react_prop_count: 0,
1150            react_hook_profile: None,
1151            exceeded: ExceededThreshold::Cognitive,
1152            severity: crate::FindingSeverity::Moderate,
1153            effective_severity: None,
1154            crap: None,
1155            coverage_pct: None,
1156            coverage_tier: None,
1157            coverage_source: None,
1158            inherited_from: None,
1159            component_rollup: None,
1160            contributions: Vec::new(),
1161            effective_thresholds: None,
1162            threshold_source: None,
1163        };
1164        let action = build_suppress_action(&violation, true, false);
1165        assert_eq!(
1166            action.comment.as_deref(),
1167            Some(SFC_TEMPLATE_SUPPRESS_COMMENT),
1168            "a // comment is rendered text in Svelte markup and suppresses nothing"
1169        );
1170        assert_eq!(
1171            action.placement.as_deref(),
1172            Some("above-template-anchor-line")
1173        );
1174
1175        violation.name = "<template>".to_string();
1176        let template_action = build_suppress_action(&violation, true, false);
1177        assert_eq!(
1178            template_action.placement.as_deref(),
1179            Some("above-template-anchor-line")
1180        );
1181    }
1182}