Skip to main content

fallow_output/
health_targets.rs

1//! Refactoring target types, recommendations, effort estimates, and evidence.
2
3/// Adaptive thresholds used for refactoring target scoring.
4///
5/// Derived from the project's metric distribution (percentile-based with floors).
6/// Exposed in JSON output so consumers can interpret scores in context.
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[allow(
10    clippy::struct_field_names,
11    reason = "triggered in bin but not lib, #[expect] would be unfulfilled in lib"
12)]
13pub struct TargetThresholds {
14    /// Fan-in saturation point for priority formula (p95, floor 5).
15    pub fan_in_p95: f64,
16    /// Fan-in moderate threshold for contributing factors (p75, floor 3).
17    pub fan_in_p75: f64,
18    /// Fan-out saturation point for priority formula (p95, floor 8).
19    pub fan_out_p95: f64,
20    /// Fan-out high threshold for rules and contributing factors (p90, floor 5).
21    pub fan_out_p90: usize,
22}
23
24/// Category of refactoring recommendation.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case")]
28pub enum RecommendationCategory {
29    /// Actively-changing file with growing complexity: highest urgency.
30    UrgentChurnComplexity,
31    /// File participates in an import cycle with significant blast radius.
32    BreakCircularDependency,
33    /// High fan-in + high complexity: changes here ripple widely.
34    SplitHighImpact,
35    /// Majority of exports are unused: reduce surface area.
36    RemoveDeadCode,
37    /// Contains functions with very high cognitive complexity.
38    ExtractComplexFunctions,
39    /// Excessive imports reduce testability and increase coupling.
40    ExtractDependencies,
41    /// Multiple complex functions lack test dependency path.
42    AddTestCoverage,
43    /// File changes together with another file in most commits, but no import
44    /// or re-export chain connects them.
45    ResolveHiddenCoupling,
46}
47
48impl RecommendationCategory {
49    /// Human-readable label for terminal output.
50    #[must_use]
51    pub const fn label(&self) -> &'static str {
52        match self {
53            Self::UrgentChurnComplexity => "churn+complexity",
54            Self::BreakCircularDependency => "circular dependency",
55            Self::SplitHighImpact => "high impact",
56            Self::RemoveDeadCode => "dead code",
57            Self::ExtractComplexFunctions => "complexity",
58            Self::ExtractDependencies => "coupling",
59            Self::AddTestCoverage => "untested risk",
60            Self::ResolveHiddenCoupling => "hidden coupling",
61        }
62    }
63
64    /// Machine-parseable label for compact output (no spaces).
65    #[must_use]
66    pub const fn compact_label(&self) -> &'static str {
67        match self {
68            Self::UrgentChurnComplexity => "churn_complexity",
69            Self::BreakCircularDependency => "circular_dep",
70            Self::SplitHighImpact => "high_impact",
71            Self::RemoveDeadCode => "dead_code",
72            Self::ExtractComplexFunctions => "complexity",
73            Self::ExtractDependencies => "coupling",
74            Self::AddTestCoverage => "untested_risk",
75            Self::ResolveHiddenCoupling => "hidden_coupling",
76        }
77    }
78}
79
80/// A contributing factor that triggered or strengthened a recommendation.
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub struct ContributingFactor {
84    /// Metric name (matches JSON field names: `"fan_in"`, `"dead_code_ratio"`, etc.).
85    #[serde(deserialize_with = "crate::static_str::deserialize")]
86    pub metric: crate::static_str::StaticStr,
87    /// Raw metric value for programmatic use.
88    pub value: f64,
89    /// Threshold that was exceeded.
90    pub threshold: f64,
91    /// Human-readable explanation.
92    pub detail: String,
93}
94
95/// A ranked refactoring recommendation for a file.
96///
97/// ## Priority Formula
98///
99/// ```text
100/// priority = min(density, 1) × 30 + hotspot_boost × 25 + dead_code × 20 + fan_in_norm × 15 + fan_out_norm × 10
101/// ```
102///
103/// Fan-in and fan-out normalization uses adaptive percentile-based thresholds
104/// (p95 of the project distribution, with floors) instead of fixed constants.
105///
106/// ## Efficiency (default sort)
107///
108/// ```text
109/// efficiency = priority / effort_numeric   (Low=1, Medium=2, High=3)
110/// ```
111///
112/// Surfaces quick wins: high-priority, low-effort targets rank first.
113/// Effort estimate for a refactoring target.
114#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[serde(rename_all = "snake_case")]
117pub enum EffortEstimate {
118    /// Small file, few functions, low fan-in: quick to address.
119    Low,
120    /// Moderate size or coupling: needs planning.
121    Medium,
122    /// Large file, many functions, or high fan-in: significant effort.
123    High,
124}
125
126impl EffortEstimate {
127    /// Human-readable label for terminal output.
128    #[must_use]
129    pub const fn label(&self) -> &'static str {
130        match self {
131            Self::Low => "low",
132            Self::Medium => "medium",
133            Self::High => "high",
134        }
135    }
136
137    /// Numeric value for arithmetic (efficiency = priority / effort).
138    #[must_use]
139    pub const fn numeric(&self) -> f64 {
140        match self {
141            Self::Low => 1.0,
142            Self::Medium => 2.0,
143            Self::High => 3.0,
144        }
145    }
146}
147
148/// Confidence level for a refactoring recommendation.
149///
150/// Based on the data source reliability:
151/// - **High**: deterministic graph/AST analysis (dead code, circular deps, complexity)
152/// - **Medium**: heuristic thresholds (fan-in/fan-out coupling)
153/// - **Low**: depends on git history quality (churn-based recommendations)
154#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[serde(rename_all = "snake_case")]
157pub enum Confidence {
158    /// Recommendation based on deterministic analysis (graph, AST).
159    High,
160    /// Recommendation based on heuristic thresholds.
161    Medium,
162    /// Recommendation depends on external data quality (git history).
163    Low,
164}
165
166impl Confidence {
167    /// Human-readable label for terminal output.
168    #[must_use]
169    pub const fn label(&self) -> &'static str {
170        match self {
171            Self::High => "high",
172            Self::Medium => "medium",
173            Self::Low => "low",
174        }
175    }
176}
177
178/// Evidence linking a target back to specific analysis data.
179///
180/// Provides enough detail for an AI agent to act on a recommendation
181/// without a second tool call.
182#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct TargetEvidence {
185    /// Names of unused exports (populated for `RemoveDeadCode` targets).
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub unused_exports: Vec<String>,
188    /// Complex functions with line numbers and cognitive scores (populated for `ExtractComplexFunctions`).
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub complex_functions: Vec<EvidenceFunction>,
191    /// Files forming the import cycle (populated for `BreakCircularDependency` targets).
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub cycle_path: Vec<String>,
194    /// Files that directly import this target, with imported and local symbols.
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub direct_callers: Vec<DirectCallerEvidence>,
197    /// Other duplicate-code instances that share a clone group with this target.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub clone_siblings: Vec<CloneSiblingEvidence>,
200    /// Files that change together with this target without a static edge
201    /// (populated for `ResolveHiddenCoupling` targets), strongest first.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub co_change_partners: Vec<CoChangePartnerEvidence>,
204}
205
206/// A hidden change-coupling partner referenced in target evidence.
207#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209pub struct CoChangePartnerEvidence {
210    /// File that changes together with the target.
211    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
212    pub path: std::path::PathBuf,
213    /// Commits that changed both files.
214    pub shared_revisions: u32,
215    /// Coupling degree from 0 to 1.
216    pub degree: f64,
217}
218
219/// A direct importer referenced in target evidence.
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct DirectCallerEvidence {
223    /// File that directly imports the target.
224    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
225    pub path: std::path::PathBuf,
226    /// Symbols imported from the target by this file.
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub symbols: Vec<DirectCallerSymbolEvidence>,
229}
230
231/// Symbol details for a direct importer.
232#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234pub struct DirectCallerSymbolEvidence {
235    /// Imported binding name.
236    pub imported: String,
237    /// Local binding name in the importing file.
238    pub local: String,
239    /// Whether the import is type-only.
240    pub type_only: bool,
241}
242
243/// A duplicate-code sibling referenced in target evidence.
244#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub struct CloneSiblingEvidence {
247    /// File containing the sibling clone instance.
248    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
249    pub path: std::path::PathBuf,
250    /// 1-based start line of the sibling clone.
251    pub start_line: usize,
252    /// 1-based end line of the sibling clone.
253    pub end_line: usize,
254    /// Stable duplicate-group handle, matching `dupes --trace dup:<id>`.
255    pub fingerprint: String,
256}
257
258/// A function referenced in target evidence.
259#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
261pub struct EvidenceFunction {
262    /// Function name.
263    pub name: String,
264    /// 1-based line number.
265    pub line: u32,
266    /// Cognitive complexity score.
267    pub cognitive: u16,
268}
269
270/// One prioritized refactoring recommendation in the health report's
271/// `refactoring_targets` section.
272#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274pub struct RefactoringTarget {
275    /// Absolute file path (stripped to relative in output).
276    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
277    pub path: std::path::PathBuf,
278    /// Priority score (0–100, higher = more urgent).
279    pub priority: f64,
280    /// Efficiency score (priority / effort). Higher = better quick-win value.
281    /// Surfaces low-effort, high-priority targets first.
282    pub efficiency: f64,
283    /// One-line actionable recommendation.
284    pub recommendation: String,
285    /// Recommendation category for tooling/filtering.
286    pub category: RecommendationCategory,
287    /// Estimated effort to address this target.
288    pub effort: EffortEstimate,
289    /// Confidence in this recommendation based on data source reliability.
290    pub confidence: Confidence,
291    /// Contributing factors that triggered this recommendation. Empty array
292    /// omitted from JSON.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    #[cfg_attr(feature = "schema", schemars(default))]
295    pub factors: Vec<ContributingFactor>,
296    /// Structured evidence linking to specific analysis data.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub evidence: Option<TargetEvidence>,
299}
300
301#[cfg(test)]
302#[allow(
303    clippy::unwrap_used,
304    reason = "tests use unwrap to keep serialization assertions concise"
305)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn category_labels_are_unique() {
311        let categories = [
312            RecommendationCategory::UrgentChurnComplexity,
313            RecommendationCategory::BreakCircularDependency,
314            RecommendationCategory::SplitHighImpact,
315            RecommendationCategory::RemoveDeadCode,
316            RecommendationCategory::ExtractComplexFunctions,
317            RecommendationCategory::ExtractDependencies,
318            RecommendationCategory::AddTestCoverage,
319            RecommendationCategory::ResolveHiddenCoupling,
320        ];
321        let labels: Vec<&str> = categories
322            .iter()
323            .map(RecommendationCategory::label)
324            .collect();
325        let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
326        assert_eq!(labels.len(), unique.len(), "category labels must be unique");
327    }
328
329    #[test]
330    fn category_serializes_as_snake_case() {
331        let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
332        assert_eq!(json, r#""urgent_churn_complexity""#);
333
334        let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
335        assert_eq!(json, r#""break_circular_dependency""#);
336    }
337
338    #[test]
339    fn refactoring_target_skips_empty_factors() {
340        let target = RefactoringTarget {
341            path: std::path::PathBuf::from("/src/foo.ts"),
342            priority: 75.0,
343            efficiency: 75.0,
344            recommendation: "Test recommendation".into(),
345            category: RecommendationCategory::RemoveDeadCode,
346            effort: EffortEstimate::Low,
347            confidence: Confidence::High,
348            factors: vec![],
349            evidence: None,
350        };
351        let json = serde_json::to_string(&target).unwrap();
352        assert!(!json.contains("factors"));
353        assert!(!json.contains("evidence"));
354    }
355
356    #[test]
357    fn effort_numeric_values() {
358        assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
359        assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
360        assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
361    }
362
363    #[test]
364    fn confidence_serializes_as_snake_case() {
365        let json = serde_json::to_string(&Confidence::High).unwrap();
366        assert_eq!(json, r#""high""#);
367        let json = serde_json::to_string(&Confidence::Medium).unwrap();
368        assert_eq!(json, r#""medium""#);
369        let json = serde_json::to_string(&Confidence::Low).unwrap();
370        assert_eq!(json, r#""low""#);
371    }
372
373    #[test]
374    fn contributing_factor_serializes_correctly() {
375        let factor = ContributingFactor {
376            metric: "fan_in",
377            value: 15.0,
378            threshold: 10.0,
379            detail: "15 files depend on this".into(),
380        };
381        let json = serde_json::to_string(&factor).unwrap();
382        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
383        assert_eq!(parsed["metric"], "fan_in");
384        assert_eq!(parsed["value"], 15.0);
385        assert_eq!(parsed["threshold"], 10.0);
386    }
387
388    #[test]
389    fn category_compact_labels_are_unique() {
390        let categories = [
391            RecommendationCategory::UrgentChurnComplexity,
392            RecommendationCategory::BreakCircularDependency,
393            RecommendationCategory::SplitHighImpact,
394            RecommendationCategory::RemoveDeadCode,
395            RecommendationCategory::ExtractComplexFunctions,
396            RecommendationCategory::ExtractDependencies,
397            RecommendationCategory::AddTestCoverage,
398            RecommendationCategory::ResolveHiddenCoupling,
399        ];
400        let labels: Vec<&str> = categories
401            .iter()
402            .map(RecommendationCategory::compact_label)
403            .collect();
404        let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
405        assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
406    }
407
408    #[test]
409    fn category_compact_labels_have_no_spaces() {
410        let categories = [
411            RecommendationCategory::UrgentChurnComplexity,
412            RecommendationCategory::BreakCircularDependency,
413            RecommendationCategory::SplitHighImpact,
414            RecommendationCategory::RemoveDeadCode,
415            RecommendationCategory::ExtractComplexFunctions,
416            RecommendationCategory::ExtractDependencies,
417            RecommendationCategory::AddTestCoverage,
418            RecommendationCategory::ResolveHiddenCoupling,
419        ];
420        for cat in &categories {
421            assert!(
422                !cat.compact_label().contains(' '),
423                "compact_label for {:?} should not contain spaces: '{}'",
424                cat,
425                cat.compact_label()
426            );
427        }
428    }
429
430    #[test]
431    fn effort_serializes_as_snake_case() {
432        assert_eq!(
433            serde_json::to_string(&EffortEstimate::Low).unwrap(),
434            r#""low""#
435        );
436        assert_eq!(
437            serde_json::to_string(&EffortEstimate::Medium).unwrap(),
438            r#""medium""#
439        );
440        assert_eq!(
441            serde_json::to_string(&EffortEstimate::High).unwrap(),
442            r#""high""#
443        );
444    }
445
446    #[test]
447    fn target_evidence_skips_empty_fields() {
448        let evidence = TargetEvidence {
449            unused_exports: vec![],
450            complex_functions: vec![],
451            cycle_path: vec![],
452            direct_callers: vec![],
453            clone_siblings: vec![],
454            co_change_partners: vec![],
455        };
456        let json = serde_json::to_string(&evidence).unwrap();
457        assert!(!json.contains("unused_exports"));
458        assert!(!json.contains("complex_functions"));
459        assert!(!json.contains("cycle_path"));
460        assert!(!json.contains("direct_callers"));
461        assert!(!json.contains("clone_siblings"));
462    }
463
464    #[test]
465    fn target_evidence_with_data() {
466        let evidence = TargetEvidence {
467            unused_exports: vec!["foo".to_string(), "bar".to_string()],
468            complex_functions: vec![EvidenceFunction {
469                name: "processData".into(),
470                line: 42,
471                cognitive: 30,
472            }],
473            cycle_path: vec![],
474            direct_callers: vec![DirectCallerEvidence {
475                path: "src/consumer.ts".into(),
476                symbols: vec![DirectCallerSymbolEvidence {
477                    imported: "processData".into(),
478                    local: "processData".into(),
479                    type_only: false,
480                }],
481            }],
482            clone_siblings: vec![CloneSiblingEvidence {
483                path: "src/peer.ts".into(),
484                start_line: 12,
485                end_line: 20,
486                fingerprint: "dup:12345678".into(),
487            }],
488            co_change_partners: vec![],
489        };
490        let json = serde_json::to_string(&evidence).unwrap();
491        assert!(json.contains("unused_exports"));
492        assert!(json.contains("complex_functions"));
493        assert!(json.contains("processData"));
494        assert!(json.contains("direct_callers"));
495        assert!(json.contains("clone_siblings"));
496        assert!(!json.contains("cycle_path"));
497    }
498}