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