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