1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::{normalize_uri, relative_path};
9use crate::health_types::{ExceededThreshold, HealthReport};
10
11const fn severity_to_codeclimate(s: Severity) -> &'static str {
13 match s {
14 Severity::Error => "major",
15 Severity::Warn | Severity::Off => "minor",
16 }
17}
18
19fn cc_path(path: &Path, root: &Path) -> String {
24 normalize_uri(&relative_path(path, root).display().to_string())
25}
26
27fn fingerprint_hash(parts: &[&str]) -> String {
32 let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for part in parts {
34 for byte in part.bytes() {
35 hash ^= u64::from(byte);
36 hash = hash.wrapping_mul(0x0100_0000_01b3); }
38 hash ^= 0xff;
40 hash = hash.wrapping_mul(0x0100_0000_01b3);
41 }
42 format!("{hash:016x}")
43}
44
45fn cc_issue(
47 check_name: &str,
48 description: &str,
49 severity: &str,
50 category: &str,
51 path: &str,
52 begin_line: Option<u32>,
53 fingerprint: &str,
54) -> serde_json::Value {
55 let lines = match begin_line {
56 Some(line) => serde_json::json!({ "begin": line }),
57 None => serde_json::json!({ "begin": 1 }),
58 };
59
60 serde_json::json!({
61 "type": "issue",
62 "check_name": check_name,
63 "description": description,
64 "categories": [category],
65 "severity": severity,
66 "fingerprint": fingerprint,
67 "location": {
68 "path": path,
69 "lines": lines
70 }
71 })
72}
73
74pub fn build_codeclimate(
76 results: &AnalysisResults,
77 root: &Path,
78 rules: &RulesConfig,
79) -> serde_json::Value {
80 let mut issues = Vec::new();
81
82 let level = severity_to_codeclimate(rules.unused_files);
84 for file in &results.unused_files {
85 let path = cc_path(&file.path, root);
86 let fp = fingerprint_hash(&["fallow/unused-file", &path]);
87 issues.push(cc_issue(
88 "fallow/unused-file",
89 "File is not reachable from any entry point",
90 level,
91 "Bug Risk",
92 &path,
93 None,
94 &fp,
95 ));
96 }
97
98 let level = severity_to_codeclimate(rules.unused_exports);
100 for export in &results.unused_exports {
101 let path = cc_path(&export.path, root);
102 let kind = if export.is_re_export {
103 "Re-export"
104 } else {
105 "Export"
106 };
107 let line_str = export.line.to_string();
108 let fp = fingerprint_hash(&[
109 "fallow/unused-export",
110 &path,
111 &line_str,
112 &export.export_name,
113 ]);
114 issues.push(cc_issue(
115 "fallow/unused-export",
116 &format!(
117 "{kind} '{}' is never imported by other modules",
118 export.export_name
119 ),
120 level,
121 "Bug Risk",
122 &path,
123 Some(export.line),
124 &fp,
125 ));
126 }
127
128 let level = severity_to_codeclimate(rules.unused_types);
130 for export in &results.unused_types {
131 let path = cc_path(&export.path, root);
132 let kind = if export.is_re_export {
133 "Type re-export"
134 } else {
135 "Type export"
136 };
137 let line_str = export.line.to_string();
138 let fp = fingerprint_hash(&["fallow/unused-type", &path, &line_str, &export.export_name]);
139 issues.push(cc_issue(
140 "fallow/unused-type",
141 &format!(
142 "{kind} '{}' is never imported by other modules",
143 export.export_name
144 ),
145 level,
146 "Bug Risk",
147 &path,
148 Some(export.line),
149 &fp,
150 ));
151 }
152
153 let push_deps = |issues: &mut Vec<serde_json::Value>,
155 deps: &[fallow_core::results::UnusedDependency],
156 rule_id: &str,
157 location_label: &str,
158 severity: Severity| {
159 let level = severity_to_codeclimate(severity);
160 for dep in deps {
161 let path = cc_path(&dep.path, root);
162 let line = if dep.line > 0 { Some(dep.line) } else { None };
163 let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
164 issues.push(cc_issue(
165 rule_id,
166 &format!(
167 "Package '{}' is in {location_label} but never imported",
168 dep.package_name
169 ),
170 level,
171 "Bug Risk",
172 &path,
173 line,
174 &fp,
175 ));
176 }
177 };
178
179 push_deps(
180 &mut issues,
181 &results.unused_dependencies,
182 "fallow/unused-dependency",
183 "dependencies",
184 rules.unused_dependencies,
185 );
186 push_deps(
187 &mut issues,
188 &results.unused_dev_dependencies,
189 "fallow/unused-dev-dependency",
190 "devDependencies",
191 rules.unused_dev_dependencies,
192 );
193 push_deps(
194 &mut issues,
195 &results.unused_optional_dependencies,
196 "fallow/unused-optional-dependency",
197 "optionalDependencies",
198 rules.unused_optional_dependencies,
199 );
200
201 let level = severity_to_codeclimate(rules.type_only_dependencies);
203 for dep in &results.type_only_dependencies {
204 let path = cc_path(&dep.path, root);
205 let line = if dep.line > 0 { Some(dep.line) } else { None };
206 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
207 issues.push(cc_issue(
208 "fallow/type-only-dependency",
209 &format!(
210 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
211 dep.package_name
212 ),
213 level,
214 "Bug Risk",
215 &path,
216 line,
217 &fp,
218 ));
219 }
220
221 let level = severity_to_codeclimate(rules.unused_enum_members);
223 for member in &results.unused_enum_members {
224 let path = cc_path(&member.path, root);
225 let line_str = member.line.to_string();
226 let fp = fingerprint_hash(&[
227 "fallow/unused-enum-member",
228 &path,
229 &line_str,
230 &member.parent_name,
231 &member.member_name,
232 ]);
233 issues.push(cc_issue(
234 "fallow/unused-enum-member",
235 &format!(
236 "Enum member '{}.{}' is never referenced",
237 member.parent_name, member.member_name
238 ),
239 level,
240 "Bug Risk",
241 &path,
242 Some(member.line),
243 &fp,
244 ));
245 }
246
247 let level = severity_to_codeclimate(rules.unused_class_members);
249 for member in &results.unused_class_members {
250 let path = cc_path(&member.path, root);
251 let line_str = member.line.to_string();
252 let fp = fingerprint_hash(&[
253 "fallow/unused-class-member",
254 &path,
255 &line_str,
256 &member.parent_name,
257 &member.member_name,
258 ]);
259 issues.push(cc_issue(
260 "fallow/unused-class-member",
261 &format!(
262 "Class member '{}.{}' is never referenced",
263 member.parent_name, member.member_name
264 ),
265 level,
266 "Bug Risk",
267 &path,
268 Some(member.line),
269 &fp,
270 ));
271 }
272
273 let level = severity_to_codeclimate(rules.unresolved_imports);
275 for import in &results.unresolved_imports {
276 let path = cc_path(&import.path, root);
277 let line_str = import.line.to_string();
278 let fp = fingerprint_hash(&[
279 "fallow/unresolved-import",
280 &path,
281 &line_str,
282 &import.specifier,
283 ]);
284 issues.push(cc_issue(
285 "fallow/unresolved-import",
286 &format!("Import '{}' could not be resolved", import.specifier),
287 level,
288 "Bug Risk",
289 &path,
290 Some(import.line),
291 &fp,
292 ));
293 }
294
295 let level = severity_to_codeclimate(rules.unlisted_dependencies);
297 for dep in &results.unlisted_dependencies {
298 for site in &dep.imported_from {
299 let path = cc_path(&site.path, root);
300 let line_str = site.line.to_string();
301 let fp = fingerprint_hash(&[
302 "fallow/unlisted-dependency",
303 &path,
304 &line_str,
305 &dep.package_name,
306 ]);
307 issues.push(cc_issue(
308 "fallow/unlisted-dependency",
309 &format!(
310 "Package '{}' is imported but not listed in package.json",
311 dep.package_name
312 ),
313 level,
314 "Bug Risk",
315 &path,
316 Some(site.line),
317 &fp,
318 ));
319 }
320 }
321
322 let level = severity_to_codeclimate(rules.duplicate_exports);
324 for dup in &results.duplicate_exports {
325 for loc in &dup.locations {
326 let path = cc_path(&loc.path, root);
327 let line_str = loc.line.to_string();
328 let fp = fingerprint_hash(&[
329 "fallow/duplicate-export",
330 &path,
331 &line_str,
332 &dup.export_name,
333 ]);
334 issues.push(cc_issue(
335 "fallow/duplicate-export",
336 &format!("Export '{}' appears in multiple modules", dup.export_name),
337 level,
338 "Bug Risk",
339 &path,
340 Some(loc.line),
341 &fp,
342 ));
343 }
344 }
345
346 let level = severity_to_codeclimate(rules.circular_dependencies);
348 for cycle in &results.circular_dependencies {
349 let Some(first) = cycle.files.first() else {
350 continue;
351 };
352 let path = cc_path(first, root);
353 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
354 let chain_str = chain.join(":");
355 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
356 let line = if cycle.line > 0 {
357 Some(cycle.line)
358 } else {
359 None
360 };
361 issues.push(cc_issue(
362 "fallow/circular-dependency",
363 &format!("Circular dependency: {}", chain.join(" \u{2192} ")),
364 level,
365 "Bug Risk",
366 &path,
367 line,
368 &fp,
369 ));
370 }
371
372 serde_json::Value::Array(issues)
373}
374
375pub(super) fn print_codeclimate(
377 results: &AnalysisResults,
378 root: &Path,
379 rules: &RulesConfig,
380) -> ExitCode {
381 let value = build_codeclimate(results, root, rules);
382 match serde_json::to_string_pretty(&value) {
383 Ok(json) => {
384 println!("{json}");
385 ExitCode::SUCCESS
386 }
387 Err(e) => {
388 eprintln!("Error: failed to serialize CodeClimate output: {e}");
389 ExitCode::from(2)
390 }
391 }
392}
393
394fn health_severity(value: u16, threshold: u16) -> &'static str {
400 if threshold == 0 {
401 return "minor";
402 }
403 let ratio = f64::from(value) / f64::from(threshold);
404 if ratio > 2.5 {
405 "critical"
406 } else if ratio > 1.5 {
407 "major"
408 } else {
409 "minor"
410 }
411}
412
413pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
415 let mut issues = Vec::new();
416
417 let cyc_t = report.summary.max_cyclomatic_threshold;
418 let cog_t = report.summary.max_cognitive_threshold;
419
420 for finding in &report.findings {
421 let path = cc_path(&finding.path, root);
422 let description = match finding.exceeded {
423 ExceededThreshold::Both => format!(
424 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
425 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
426 ),
427 ExceededThreshold::Cyclomatic => format!(
428 "'{}' has cyclomatic complexity {} (threshold: {})",
429 finding.name, finding.cyclomatic, cyc_t
430 ),
431 ExceededThreshold::Cognitive => format!(
432 "'{}' has cognitive complexity {} (threshold: {})",
433 finding.name, finding.cognitive, cog_t
434 ),
435 };
436 let check_name = match finding.exceeded {
437 ExceededThreshold::Both => "fallow/high-complexity",
438 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
439 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
440 };
441 let severity = match finding.exceeded {
443 ExceededThreshold::Both => {
444 let cyc_sev = health_severity(finding.cyclomatic, cyc_t);
445 let cog_sev = health_severity(finding.cognitive, cog_t);
446 match (cyc_sev, cog_sev) {
448 ("critical", _) | (_, "critical") => "critical",
449 ("major", _) | (_, "major") => "major",
450 _ => "minor",
451 }
452 }
453 ExceededThreshold::Cyclomatic => health_severity(finding.cyclomatic, cyc_t),
454 ExceededThreshold::Cognitive => health_severity(finding.cognitive, cog_t),
455 };
456 let line_str = finding.line.to_string();
457 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
458 issues.push(cc_issue(
459 check_name,
460 &description,
461 severity,
462 "Complexity",
463 &path,
464 Some(finding.line),
465 &fp,
466 ));
467 }
468
469 serde_json::Value::Array(issues)
470}
471
472pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
474 let value = build_health_codeclimate(report, root);
475 match serde_json::to_string_pretty(&value) {
476 Ok(json) => {
477 println!("{json}");
478 ExitCode::SUCCESS
479 }
480 Err(e) => {
481 eprintln!("Error: failed to serialize CodeClimate output: {e}");
482 ExitCode::from(2)
483 }
484 }
485}
486
487pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
489 let mut issues = Vec::new();
490
491 for (i, group) in report.clone_groups.iter().enumerate() {
492 let token_str = group.token_count.to_string();
495 let line_count_str = group.line_count.to_string();
496 let fragment_prefix: String = group
497 .instances
498 .first()
499 .map(|inst| inst.fragment.chars().take(64).collect())
500 .unwrap_or_default();
501
502 for instance in &group.instances {
503 let path = cc_path(&instance.file, root);
504 let start_str = instance.start_line.to_string();
505 let fp = fingerprint_hash(&[
506 "fallow/code-duplication",
507 &path,
508 &start_str,
509 &token_str,
510 &line_count_str,
511 &fragment_prefix,
512 ]);
513 issues.push(cc_issue(
514 "fallow/code-duplication",
515 &format!(
516 "Code clone group {} ({} lines, {} instances)",
517 i + 1,
518 group.line_count,
519 group.instances.len()
520 ),
521 "minor",
522 "Duplication",
523 &path,
524 Some(instance.start_line as u32),
525 &fp,
526 ));
527 }
528 }
529
530 serde_json::Value::Array(issues)
531}
532
533pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
535 let value = build_duplication_codeclimate(report, root);
536 match serde_json::to_string_pretty(&value) {
537 Ok(json) => {
538 println!("{json}");
539 ExitCode::SUCCESS
540 }
541 Err(e) => {
542 eprintln!("Error: failed to serialize CodeClimate output: {e}");
543 ExitCode::from(2)
544 }
545 }
546}