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