Skip to main content

fallow_api/
health_codeclimate.rs

1//! Shared health CodeClimate issue construction.
2
3use std::path::Path;
4
5use fallow_output::{
6    CodeClimateIssue, CodeClimateIssueInput, CodeClimateSeverity, ComplexityViolation,
7    CoverageIntelligenceFinding, CoverageIntelligenceRecommendation, CoverageIntelligenceVerdict,
8    ExceededThreshold, FindingSeverity, HealthReport, RuntimeCoverageFinding,
9    RuntimeCoverageVerdict, StylingFinding, StylingFindingSeverity, UntestedExportFinding,
10    UntestedFileFinding, build_codeclimate_issue, codeclimate_fingerprint_hash, normalize_uri,
11};
12
13struct HealthCodeClimateContext<'a> {
14    root: &'a Path,
15    cyc_t: u16,
16    cog_t: u16,
17    crap_t: f64,
18}
19
20impl HealthCodeClimateContext<'_> {
21    fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
22        let path = codeclimate_path(&finding.path, self.root);
23        let check_name = complexity_check_name(finding);
24        let line_str = finding.line.to_string();
25        let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
26        build_codeclimate_issue(CodeClimateIssueInput {
27            check_name,
28            description: &self.complexity_description(finding),
29            severity: health_finding_severity(finding.severity),
30            category: "Complexity",
31            path: &path,
32            begin_line: Some(finding.line),
33            fingerprint: &fp,
34        })
35    }
36
37    fn styling_issue(&self, finding: &StylingFinding) -> CodeClimateIssue {
38        let path = codeclimate_path(Path::new(&finding.path), self.root);
39        let check_name = format!("fallow/{}", finding.code);
40        let description = format!("[{}] {}: {}", finding.code, finding.sub_kind, finding.value);
41        let line_str = finding.line.to_string();
42        let fp = codeclimate_fingerprint_hash(&[
43            &check_name,
44            &path,
45            &line_str,
46            &finding.sub_kind,
47            &finding.value,
48        ]);
49        build_codeclimate_issue(CodeClimateIssueInput {
50            check_name: &check_name,
51            description: &description,
52            severity: styling_finding_severity(finding.effective_severity),
53            category: "Style",
54            path: &path,
55            begin_line: Some(finding.line),
56            fingerprint: &fp,
57        })
58    }
59
60    fn complexity_description(&self, finding: &ComplexityViolation) -> String {
61        match finding.exceeded {
62            ExceededThreshold::Both => format!(
63                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
64                finding.name, finding.cyclomatic, self.cyc_t, finding.cognitive, self.cog_t
65            ),
66            ExceededThreshold::Cyclomatic => format!(
67                "'{}' has cyclomatic complexity {} (threshold: {})",
68                finding.name, finding.cyclomatic, self.cyc_t
69            ),
70            ExceededThreshold::Cognitive => format!(
71                "'{}' has cognitive complexity {} (threshold: {})",
72                finding.name, finding.cognitive, self.cog_t
73            ),
74            ExceededThreshold::Crap
75            | ExceededThreshold::CyclomaticCrap
76            | ExceededThreshold::CognitiveCrap
77            | ExceededThreshold::All => {
78                let crap = finding.crap.unwrap_or(0.0);
79                let coverage = finding
80                    .coverage_pct
81                    .map(|pct| format!(", coverage {pct:.0}%"))
82                    .unwrap_or_default();
83                format!(
84                    "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
85                    finding.name, self.crap_t, finding.cyclomatic,
86                )
87            }
88        }
89    }
90
91    fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
92        let path = codeclimate_path(&finding.path, self.root);
93        let check_name = runtime_coverage_check_name(finding.verdict);
94        let invocations_hint = finding.invocations.map_or_else(
95            || "untracked".to_owned(),
96            |hits| format!("{hits} invocations"),
97        );
98        let description = format!(
99            "'{}' runtime coverage verdict: {} ({})",
100            finding.function,
101            finding.verdict.human_label(),
102            invocations_hint,
103        );
104        let fp = codeclimate_fingerprint_hash(&[
105            check_name,
106            &path,
107            &finding.line.to_string(),
108            &finding.function,
109        ]);
110        build_codeclimate_issue(CodeClimateIssueInput {
111            check_name,
112            description: &description,
113            severity: runtime_coverage_severity(finding.verdict),
114            category: "Bug Risk",
115            path: &path,
116            begin_line: Some(finding.line),
117            fingerprint: &fp,
118        })
119    }
120
121    fn coverage_intelligence_issue(
122        &self,
123        finding: &CoverageIntelligenceFinding,
124    ) -> Option<CodeClimateIssue> {
125        let severity = coverage_intelligence_severity(finding.verdict)?;
126        let path = codeclimate_path(&finding.path, self.root);
127        let check_name = coverage_intelligence_check_name(finding.recommendation);
128        let identity = finding.identity.as_deref().unwrap_or("code");
129        let description = format!(
130            "'{}' coverage intelligence verdict: {} ({})",
131            identity, finding.verdict, finding.recommendation,
132        );
133        let fp = codeclimate_fingerprint_hash(&[
134            check_name,
135            &path,
136            &finding.line.to_string(),
137            identity,
138            &finding.id,
139        ]);
140        Some(build_codeclimate_issue(CodeClimateIssueInput {
141            check_name,
142            description: &description,
143            severity,
144            category: "Bug Risk",
145            path: &path,
146            begin_line: Some(finding.line),
147            fingerprint: &fp,
148        }))
149    }
150
151    fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
152        let path = codeclimate_path(&item.file.path, self.root);
153        let description = format!(
154            "File is runtime-reachable but has no test dependency path ({} value export{})",
155            item.file.value_export_count,
156            if item.file.value_export_count == 1 {
157                ""
158            } else {
159                "s"
160            },
161        );
162        let fp = codeclimate_fingerprint_hash(&["fallow/untested-file", &path]);
163        build_codeclimate_issue(CodeClimateIssueInput {
164            check_name: "fallow/untested-file",
165            description: &description,
166            severity: CodeClimateSeverity::Minor,
167            category: "Coverage",
168            path: &path,
169            begin_line: None,
170            fingerprint: &fp,
171        })
172    }
173
174    fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
175        let path = codeclimate_path(&item.export.path, self.root);
176        let description = format!(
177            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
178            item.export.export_name
179        );
180        let line_str = item.export.line.to_string();
181        let fp = codeclimate_fingerprint_hash(&[
182            "fallow/untested-export",
183            &path,
184            &line_str,
185            &item.export.export_name,
186        ]);
187        build_codeclimate_issue(CodeClimateIssueInput {
188            check_name: "fallow/untested-export",
189            description: &description,
190            severity: CodeClimateSeverity::Minor,
191            category: "Coverage",
192            path: &path,
193            begin_line: Some(item.export.line),
194            fingerprint: &fp,
195        })
196    }
197}
198
199/// Build CodeClimate issues from health / complexity analysis results.
200#[must_use]
201pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
202    let mut issues = Vec::new();
203    let ctx = HealthCodeClimateContext {
204        root,
205        cyc_t: report.summary.max_cyclomatic_threshold,
206        cog_t: report.summary.max_cognitive_threshold,
207        crap_t: report.summary.max_crap_threshold,
208    };
209
210    for finding in &report.findings {
211        issues.push(ctx.complexity_issue(finding));
212    }
213    for finding in &report.styling_findings {
214        issues.push(ctx.styling_issue(finding));
215    }
216
217    if let Some(ref production) = report.runtime_coverage {
218        for finding in &production.findings {
219            issues.push(ctx.runtime_coverage_issue(finding));
220        }
221    }
222
223    if let Some(ref intelligence) = report.coverage_intelligence {
224        for finding in &intelligence.findings {
225            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
226                issues.push(issue);
227            }
228        }
229    }
230
231    if let Some(ref gaps) = report.coverage_gaps {
232        for item in &gaps.files {
233            issues.push(ctx.untested_file_issue(item));
234        }
235
236        for item in &gaps.exports {
237            issues.push(ctx.untested_export_issue(item));
238        }
239    }
240
241    issues
242}
243
244fn codeclimate_path(path: &Path, root: &Path) -> String {
245    normalize_uri(
246        &path
247            .strip_prefix(root)
248            .unwrap_or(path)
249            .display()
250            .to_string(),
251    )
252}
253
254const fn coverage_intelligence_check_name(
255    recommendation: CoverageIntelligenceRecommendation,
256) -> &'static str {
257    match recommendation {
258        CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
259            "fallow/coverage-intelligence-risky-change"
260        }
261        CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
262            "fallow/coverage-intelligence-delete"
263        }
264        CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
265            "fallow/coverage-intelligence-review"
266        }
267        CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
268            "fallow/coverage-intelligence-refactor"
269        }
270    }
271}
272
273const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
274    match finding.exceeded {
275        ExceededThreshold::Both => "fallow/high-complexity",
276        ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
277        ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
278        ExceededThreshold::Crap
279        | ExceededThreshold::CyclomaticCrap
280        | ExceededThreshold::CognitiveCrap
281        | ExceededThreshold::All => "fallow/high-crap-score",
282    }
283}
284
285const fn health_finding_severity(severity: FindingSeverity) -> CodeClimateSeverity {
286    match severity {
287        FindingSeverity::Critical => CodeClimateSeverity::Critical,
288        FindingSeverity::High => CodeClimateSeverity::Major,
289        FindingSeverity::Moderate => CodeClimateSeverity::Minor,
290    }
291}
292
293const fn styling_finding_severity(severity: StylingFindingSeverity) -> CodeClimateSeverity {
294    match severity {
295        StylingFindingSeverity::Error => CodeClimateSeverity::Major,
296        StylingFindingSeverity::Warn => CodeClimateSeverity::Minor,
297    }
298}
299
300const fn runtime_coverage_check_name(verdict: RuntimeCoverageVerdict) -> &'static str {
301    match verdict {
302        RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
303        RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
304        RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
305        RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
306        RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
307            "fallow/runtime-coverage"
308        }
309    }
310}
311
312const fn runtime_coverage_severity(verdict: RuntimeCoverageVerdict) -> CodeClimateSeverity {
313    match verdict {
314        RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
315        RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
316        _ => CodeClimateSeverity::Minor,
317    }
318}
319
320const fn coverage_intelligence_severity(
321    verdict: CoverageIntelligenceVerdict,
322) -> Option<CodeClimateSeverity> {
323    match verdict {
324        CoverageIntelligenceVerdict::RiskyChangeDetected
325        | CoverageIntelligenceVerdict::HighConfidenceDelete => Some(CodeClimateSeverity::Major),
326        CoverageIntelligenceVerdict::ReviewRequired
327        | CoverageIntelligenceVerdict::RefactorCarefully => Some(CodeClimateSeverity::Minor),
328        CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => None,
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use std::path::{Path, PathBuf};
335
336    use fallow_output::{
337        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthReport, HealthSummary,
338        StylingFinding, StylingFindingSeverity,
339    };
340
341    use super::*;
342
343    #[test]
344    fn health_codeclimate_uses_relative_normalized_paths() {
345        let report = HealthReport {
346            summary: HealthSummary {
347                max_cyclomatic_threshold: 10,
348                max_cognitive_threshold: 8,
349                max_crap_threshold: 30.0,
350                ..HealthSummary::default()
351            },
352            findings: vec![
353                ComplexityViolation {
354                    path: PathBuf::from("/root/app/[id]/page.tsx"),
355                    name: "render".to_string(),
356                    line: 7,
357                    col: 0,
358                    cyclomatic: 12,
359                    cognitive: 9,
360                    line_count: 20,
361                    param_count: 1,
362                    react_hook_count: 0,
363                    react_jsx_max_depth: 0,
364                    react_prop_count: 0,
365                    react_hook_profile: None,
366                    exceeded: ExceededThreshold::Both,
367                    severity: FindingSeverity::High,
368                    coverage_pct: None,
369                    crap: None,
370                    coverage_tier: None,
371                    coverage_source: None,
372                    inherited_from: None,
373                    component_rollup: None,
374                    contributions: Vec::new(),
375                    effective_thresholds: None,
376                    threshold_source: None,
377                }
378                .into(),
379            ],
380            ..HealthReport::default()
381        };
382
383        let issues = build_health_codeclimate(&report, Path::new("/root"));
384
385        assert_eq!(issues.len(), 1);
386        let issue = &issues[0];
387        assert_eq!(issue.check_name, "fallow/high-complexity");
388        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
389        assert_eq!(issue.location.lines.begin, 7);
390        assert_eq!(issue.severity, CodeClimateSeverity::Major);
391    }
392
393    #[test]
394    fn health_codeclimate_includes_styling_findings() {
395        let report = HealthReport {
396            styling_findings: vec![StylingFinding {
397                code: "css-selector-complexity".to_string(),
398                sub_kind: "high-specificity".to_string(),
399                path: "src/styles.css".to_string(),
400                line: 4,
401                value: "#app .card .title".to_string(),
402                effective_severity: StylingFindingSeverity::Error,
403                blast_radius: None,
404                confidence: None,
405                agent_disposition: None,
406                nearest_token: None,
407                fix_hint: None,
408                actions: Vec::new(),
409            }],
410            ..HealthReport::default()
411        };
412
413        let issues = build_health_codeclimate(&report, Path::new("/root"));
414
415        assert_eq!(issues.len(), 1);
416        let issue = &issues[0];
417        assert_eq!(issue.check_name, "fallow/css-selector-complexity");
418        assert_eq!(issue.location.path, "src/styles.css");
419        assert_eq!(issue.location.lines.begin, 4);
420        assert_eq!(issue.severity, CodeClimateSeverity::Major);
421    }
422}