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