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