1use 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#[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}