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