use std::path::Path;
use std::process::ExitCode;
use fallow_config::{RulesConfig, Severity};
use fallow_core::duplicates::DuplicationReport;
use fallow_core::results::AnalysisResults;
use super::ci::{fingerprint, severity};
use super::grouping::{self, OwnershipResolver};
use super::{emit_json, normalize_uri, relative_path};
use crate::health_types::{ExceededThreshold, HealthReport};
use crate::output_envelope::{
CodeClimateIssue, CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation,
CodeClimateSeverity,
};
fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
severity::codeclimate_severity(s)
}
fn cc_path(path: &Path, root: &Path) -> String {
normalize_uri(&relative_path(path, root).display().to_string())
}
fn fingerprint_hash(parts: &[&str]) -> String {
fingerprint::fingerprint_hash(parts)
}
fn cc_issue(
check_name: &str,
description: &str,
severity: CodeClimateSeverity,
category: &str,
path: &str,
begin_line: Option<u32>,
fingerprint: &str,
) -> CodeClimateIssue {
CodeClimateIssue {
kind: CodeClimateIssueKind::Issue,
check_name: check_name.to_string(),
description: description.to_string(),
categories: vec![category.to_string()],
severity,
fingerprint: fingerprint.to_string(),
location: CodeClimateLocation {
path: path.to_string(),
lines: CodeClimateLines {
begin: begin_line.unwrap_or(1),
},
},
}
}
fn push_dep_cc_issues<'a, I>(
issues: &mut Vec<CodeClimateIssue>,
deps: I,
root: &Path,
rule_id: &str,
location_label: &str,
severity: Severity,
) where
I: IntoIterator<Item = &'a fallow_core::results::UnusedDependency>,
{
for dep in deps {
let level = severity_to_codeclimate(severity);
let path = cc_path(&dep.path, root);
let line = if dep.line > 0 { Some(dep.line) } else { None };
let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
let workspace_context = if dep.used_in_workspaces.is_empty() {
String::new()
} else {
let workspaces = dep
.used_in_workspaces
.iter()
.map(|path| cc_path(path, root))
.collect::<Vec<_>>()
.join(", ");
format!("; imported in other workspaces: {workspaces}")
};
issues.push(cc_issue(
rule_id,
&format!(
"Package '{}' is in {location_label} but never imported{workspace_context}",
dep.package_name
),
level,
"Bug Risk",
&path,
line,
&fp,
));
}
}
fn push_unused_file_issues(
issues: &mut Vec<CodeClimateIssue>,
files: &[fallow_types::output_dead_code::UnusedFileFinding],
root: &Path,
severity: Severity,
) {
if files.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in files {
let path = cc_path(&entry.file.path, root);
let fp = fingerprint_hash(&["fallow/unused-file", &path]);
issues.push(cc_issue(
"fallow/unused-file",
"File is not reachable from any entry point",
level,
"Bug Risk",
&path,
None,
&fp,
));
}
}
fn push_unused_export_issues<'a, I>(
issues: &mut Vec<CodeClimateIssue>,
exports: I,
root: &Path,
rule_id: &str,
direct_label: &str,
re_export_label: &str,
severity: Severity,
) where
I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
{
for export in exports {
let level = severity_to_codeclimate(severity);
let path = cc_path(&export.path, root);
let kind = if export.is_re_export {
re_export_label
} else {
direct_label
};
let line_str = export.line.to_string();
let fp = fingerprint_hash(&[rule_id, &path, &line_str, &export.export_name]);
issues.push(cc_issue(
rule_id,
&format!(
"{kind} '{}' is never imported by other modules",
export.export_name
),
level,
"Bug Risk",
&path,
Some(export.line),
&fp,
));
}
}
fn push_private_type_leak_issues(
issues: &mut Vec<CodeClimateIssue>,
leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
root: &Path,
severity: Severity,
) {
if leaks.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in leaks {
let leak = &entry.leak;
let path = cc_path(&leak.path, root);
let line_str = leak.line.to_string();
let fp = fingerprint_hash(&[
"fallow/private-type-leak",
&path,
&line_str,
&leak.export_name,
&leak.type_name,
]);
issues.push(cc_issue(
"fallow/private-type-leak",
&format!(
"Export '{}' references private type '{}'",
leak.export_name, leak.type_name
),
level,
"Bug Risk",
&path,
Some(leak.line),
&fp,
));
}
}
fn push_type_only_dep_issues(
issues: &mut Vec<CodeClimateIssue>,
deps: &[fallow_core::results::TypeOnlyDependencyFinding],
root: &Path,
severity: Severity,
) {
if deps.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in deps {
let dep = &entry.dep;
let path = cc_path(&dep.path, root);
let line = if dep.line > 0 { Some(dep.line) } else { None };
let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
issues.push(cc_issue(
"fallow/type-only-dependency",
&format!(
"Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
dep.package_name
),
level,
"Bug Risk",
&path,
line,
&fp,
));
}
}
fn push_test_only_dep_issues(
issues: &mut Vec<CodeClimateIssue>,
deps: &[fallow_core::results::TestOnlyDependencyFinding],
root: &Path,
severity: Severity,
) {
if deps.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in deps {
let dep = &entry.dep;
let path = cc_path(&dep.path, root);
let line = if dep.line > 0 { Some(dep.line) } else { None };
let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
issues.push(cc_issue(
"fallow/test-only-dependency",
&format!(
"Package '{}' is only imported by test files (consider moving to devDependencies)",
dep.package_name
),
level,
"Bug Risk",
&path,
line,
&fp,
));
}
}
fn push_unused_member_issues<'a, I>(
issues: &mut Vec<CodeClimateIssue>,
members: I,
root: &Path,
rule_id: &str,
entity_label: &str,
severity: Severity,
) where
I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
{
for member in members {
let level = severity_to_codeclimate(severity);
let path = cc_path(&member.path, root);
let line_str = member.line.to_string();
let fp = fingerprint_hash(&[
rule_id,
&path,
&line_str,
&member.parent_name,
&member.member_name,
]);
issues.push(cc_issue(
rule_id,
&format!(
"{entity_label} member '{}.{}' is never referenced",
member.parent_name, member.member_name
),
level,
"Bug Risk",
&path,
Some(member.line),
&fp,
));
}
}
fn push_unresolved_import_issues(
issues: &mut Vec<CodeClimateIssue>,
imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
root: &Path,
severity: Severity,
) {
if imports.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in imports {
let import = &entry.import;
let path = cc_path(&import.path, root);
let line_str = import.line.to_string();
let fp = fingerprint_hash(&[
"fallow/unresolved-import",
&path,
&line_str,
&import.specifier,
]);
issues.push(cc_issue(
"fallow/unresolved-import",
&format!("Import '{}' could not be resolved", import.specifier),
level,
"Bug Risk",
&path,
Some(import.line),
&fp,
));
}
}
fn push_unlisted_dep_issues(
issues: &mut Vec<CodeClimateIssue>,
deps: &[fallow_core::results::UnlistedDependencyFinding],
root: &Path,
severity: Severity,
) {
if deps.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in deps {
let dep = &entry.dep;
for site in &dep.imported_from {
let path = cc_path(&site.path, root);
let line_str = site.line.to_string();
let fp = fingerprint_hash(&[
"fallow/unlisted-dependency",
&path,
&line_str,
&dep.package_name,
]);
issues.push(cc_issue(
"fallow/unlisted-dependency",
&format!(
"Package '{}' is imported but not listed in package.json",
dep.package_name
),
level,
"Bug Risk",
&path,
Some(site.line),
&fp,
));
}
}
}
fn push_duplicate_export_issues(
issues: &mut Vec<CodeClimateIssue>,
dups: &[fallow_core::results::DuplicateExportFinding],
root: &Path,
severity: Severity,
) {
if dups.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for dup in dups {
let dup = &dup.export;
for loc in &dup.locations {
let path = cc_path(&loc.path, root);
let line_str = loc.line.to_string();
let fp = fingerprint_hash(&[
"fallow/duplicate-export",
&path,
&line_str,
&dup.export_name,
]);
issues.push(cc_issue(
"fallow/duplicate-export",
&format!("Export '{}' appears in multiple modules", dup.export_name),
level,
"Bug Risk",
&path,
Some(loc.line),
&fp,
));
}
}
}
fn push_circular_dep_issues(
issues: &mut Vec<CodeClimateIssue>,
cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
root: &Path,
severity: Severity,
) {
if cycles.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in cycles {
let cycle = &entry.cycle;
let Some(first) = cycle.files.first() else {
continue;
};
let path = cc_path(first, root);
let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
let chain_str = chain.join(":");
let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
let line = if cycle.line > 0 {
Some(cycle.line)
} else {
None
};
issues.push(cc_issue(
"fallow/circular-dependency",
&format!(
"Circular dependency{}: {}",
if cycle.is_cross_package {
" (cross-package)"
} else {
""
},
chain.join(" \u{2192} ")
),
level,
"Bug Risk",
&path,
line,
&fp,
));
}
}
fn push_re_export_cycle_issues(
issues: &mut Vec<CodeClimateIssue>,
cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
root: &Path,
severity: Severity,
) {
if cycles.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in cycles {
let cycle = &entry.cycle;
let Some(first) = cycle.files.first() else {
continue;
};
let path = cc_path(first, root);
let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
let chain_str = chain.join(":");
let kind_token = match cycle.kind {
fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
};
let kind_tag = match cycle.kind {
fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
fallow_core::results::ReExportCycleKind::MultiNode => "",
};
let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
issues.push(cc_issue(
"fallow/re-export-cycle",
&format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
level,
"Bug Risk",
&path,
None,
&fp,
));
}
}
fn push_boundary_violation_issues(
issues: &mut Vec<CodeClimateIssue>,
violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
root: &Path,
severity: Severity,
) {
if violations.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in violations {
let v = &entry.violation;
let path = cc_path(&v.from_path, root);
let to = cc_path(&v.to_path, root);
let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
let line = if v.line > 0 { Some(v.line) } else { None };
issues.push(cc_issue(
"fallow/boundary-violation",
&format!(
"Boundary violation: {} -> {} ({} -> {})",
path, to, v.from_zone, v.to_zone
),
level,
"Bug Risk",
&path,
line,
&fp,
));
}
}
fn push_stale_suppression_issues(
issues: &mut Vec<CodeClimateIssue>,
suppressions: &[fallow_core::results::StaleSuppression],
root: &Path,
severity: Severity,
) {
if suppressions.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for s in suppressions {
let path = cc_path(&s.path, root);
let line_str = s.line.to_string();
let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
issues.push(cc_issue(
"fallow/stale-suppression",
&s.display_message(),
level,
"Bug Risk",
&path,
Some(s.line),
&fp,
));
}
}
fn push_unused_catalog_entry_issues(
issues: &mut Vec<CodeClimateIssue>,
entries: &[fallow_core::results::UnusedCatalogEntryFinding],
root: &Path,
severity: Severity,
) {
if entries.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for entry in entries {
let entry = &entry.entry;
let path = cc_path(&entry.path, root);
let line_str = entry.line.to_string();
let fp = fingerprint_hash(&[
"fallow/unused-catalog-entry",
&path,
&line_str,
&entry.catalog_name,
&entry.entry_name,
]);
let description = if entry.catalog_name == "default" {
format!(
"Catalog entry '{}' is not referenced by any workspace package",
entry.entry_name
)
} else {
format!(
"Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
entry.entry_name, entry.catalog_name
)
};
issues.push(cc_issue(
"fallow/unused-catalog-entry",
&description,
level,
"Bug Risk",
&path,
Some(entry.line),
&fp,
));
}
}
fn push_unresolved_catalog_reference_issues(
issues: &mut Vec<CodeClimateIssue>,
findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
root: &Path,
severity: Severity,
) {
if findings.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for finding in findings {
let finding = &finding.reference;
let path = cc_path(&finding.path, root);
let line_str = finding.line.to_string();
let fp = fingerprint_hash(&[
"fallow/unresolved-catalog-reference",
&path,
&line_str,
&finding.catalog_name,
&finding.entry_name,
]);
let catalog_phrase = if finding.catalog_name == "default" {
"the default catalog".to_string()
} else {
format!("catalog '{}'", finding.catalog_name)
};
let mut description = format!(
"Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
finding.entry_name,
if finding.catalog_name == "default" {
""
} else {
finding.catalog_name.as_str()
},
catalog_phrase,
);
if !finding.available_in_catalogs.is_empty() {
use std::fmt::Write as _;
let _ = write!(
description,
" (available in: {})",
finding.available_in_catalogs.join(", ")
);
}
issues.push(cc_issue(
"fallow/unresolved-catalog-reference",
&description,
level,
"Bug Risk",
&path,
Some(finding.line),
&fp,
));
}
}
fn push_empty_catalog_group_issues(
issues: &mut Vec<CodeClimateIssue>,
groups: &[fallow_core::results::EmptyCatalogGroupFinding],
root: &Path,
severity: Severity,
) {
if groups.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for group in groups {
let group = &group.group;
let path = cc_path(&group.path, root);
let line_str = group.line.to_string();
let fp = fingerprint_hash(&[
"fallow/empty-catalog-group",
&path,
&line_str,
&group.catalog_name,
]);
issues.push(cc_issue(
"fallow/empty-catalog-group",
&format!("Catalog group '{}' has no entries", group.catalog_name),
level,
"Bug Risk",
&path,
Some(group.line),
&fp,
));
}
}
fn push_unused_dependency_override_issues(
issues: &mut Vec<CodeClimateIssue>,
findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
root: &Path,
severity: Severity,
) {
if findings.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for finding in findings {
let finding = &finding.entry;
let path = cc_path(&finding.path, root);
let line_str = finding.line.to_string();
let fp = fingerprint_hash(&[
"fallow/unused-dependency-override",
&path,
&line_str,
finding.source.as_label(),
&finding.raw_key,
]);
let mut description = format!(
"Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
finding.raw_key, finding.version_range, finding.target_package,
);
if let Some(hint) = &finding.hint {
use std::fmt::Write as _;
let _ = write!(description, " ({hint})");
}
issues.push(cc_issue(
"fallow/unused-dependency-override",
&description,
level,
"Bug Risk",
&path,
Some(finding.line),
&fp,
));
}
}
fn push_misconfigured_dependency_override_issues(
issues: &mut Vec<CodeClimateIssue>,
findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
root: &Path,
severity: Severity,
) {
if findings.is_empty() {
return;
}
let level = severity_to_codeclimate(severity);
for finding in findings {
let finding = &finding.entry;
let path = cc_path(&finding.path, root);
let line_str = finding.line.to_string();
let fp = fingerprint_hash(&[
"fallow/misconfigured-dependency-override",
&path,
&line_str,
finding.source.as_label(),
&finding.raw_key,
]);
let description = format!(
"Override `{}` -> `{}` is malformed: {}",
finding.raw_key,
finding.raw_value,
finding.reason.describe(),
);
issues.push(cc_issue(
"fallow/misconfigured-dependency-override",
&description,
level,
"Bug Risk",
&path,
Some(finding.line),
&fp,
));
}
}
#[must_use]
pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
}
#[must_use]
#[expect(
clippy::too_many_lines,
reason = "orchestration function: one push_<kind>_issues call per issue type, each one a flat 3-5 line block; splitting would just shuffle the same lines into helpers without aiding readability"
)]
pub fn build_codeclimate(
results: &AnalysisResults,
root: &Path,
rules: &RulesConfig,
) -> Vec<CodeClimateIssue> {
let mut issues = Vec::new();
push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
push_unused_export_issues(
&mut issues,
results.unused_exports.iter().map(|e| &e.export),
root,
"fallow/unused-export",
"Export",
"Re-export",
rules.unused_exports,
);
push_unused_export_issues(
&mut issues,
results.unused_types.iter().map(|e| &e.export),
root,
"fallow/unused-type",
"Type export",
"Type re-export",
rules.unused_types,
);
push_private_type_leak_issues(
&mut issues,
&results.private_type_leaks,
root,
rules.private_type_leaks,
);
push_dep_cc_issues(
&mut issues,
results.unused_dependencies.iter().map(|f| &f.dep),
root,
"fallow/unused-dependency",
"dependencies",
rules.unused_dependencies,
);
push_dep_cc_issues(
&mut issues,
results.unused_dev_dependencies.iter().map(|f| &f.dep),
root,
"fallow/unused-dev-dependency",
"devDependencies",
rules.unused_dev_dependencies,
);
push_dep_cc_issues(
&mut issues,
results.unused_optional_dependencies.iter().map(|f| &f.dep),
root,
"fallow/unused-optional-dependency",
"optionalDependencies",
rules.unused_optional_dependencies,
);
push_type_only_dep_issues(
&mut issues,
&results.type_only_dependencies,
root,
rules.type_only_dependencies,
);
push_test_only_dep_issues(
&mut issues,
&results.test_only_dependencies,
root,
rules.test_only_dependencies,
);
push_unused_member_issues(
&mut issues,
results.unused_enum_members.iter().map(|m| &m.member),
root,
"fallow/unused-enum-member",
"Enum",
rules.unused_enum_members,
);
push_unused_member_issues(
&mut issues,
results.unused_class_members.iter().map(|m| &m.member),
root,
"fallow/unused-class-member",
"Class",
rules.unused_class_members,
);
push_unresolved_import_issues(
&mut issues,
&results.unresolved_imports,
root,
rules.unresolved_imports,
);
push_unlisted_dep_issues(
&mut issues,
&results.unlisted_dependencies,
root,
rules.unlisted_dependencies,
);
push_duplicate_export_issues(
&mut issues,
&results.duplicate_exports,
root,
rules.duplicate_exports,
);
push_circular_dep_issues(
&mut issues,
&results.circular_dependencies,
root,
rules.circular_dependencies,
);
push_re_export_cycle_issues(
&mut issues,
&results.re_export_cycles,
root,
rules.re_export_cycle,
);
push_boundary_violation_issues(
&mut issues,
&results.boundary_violations,
root,
rules.boundary_violation,
);
push_stale_suppression_issues(
&mut issues,
&results.stale_suppressions,
root,
rules.stale_suppressions,
);
push_unused_catalog_entry_issues(
&mut issues,
&results.unused_catalog_entries,
root,
rules.unused_catalog_entries,
);
push_empty_catalog_group_issues(
&mut issues,
&results.empty_catalog_groups,
root,
rules.empty_catalog_groups,
);
push_unresolved_catalog_reference_issues(
&mut issues,
&results.unresolved_catalog_references,
root,
rules.unresolved_catalog_references,
);
push_unused_dependency_override_issues(
&mut issues,
&results.unused_dependency_overrides,
root,
rules.unused_dependency_overrides,
);
push_misconfigured_dependency_override_issues(
&mut issues,
&results.misconfigured_dependency_overrides,
root,
rules.misconfigured_dependency_overrides,
);
issues
}
pub(super) fn print_codeclimate(
results: &AnalysisResults,
root: &Path,
rules: &RulesConfig,
) -> ExitCode {
let issues = build_codeclimate(results, root, rules);
let value = issues_to_value(&issues);
emit_json(&value, "CodeClimate")
}
pub(super) fn print_grouped_codeclimate(
results: &AnalysisResults,
root: &Path,
rules: &RulesConfig,
resolver: &OwnershipResolver,
) -> ExitCode {
let issues = build_codeclimate(results, root, rules);
let mut value = issues_to_value(&issues);
if let Some(items) = value.as_array_mut() {
for issue in items {
let path = issue
.pointer("/location/path")
.and_then(|v| v.as_str())
.unwrap_or("");
let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
issue
.as_object_mut()
.expect("CodeClimate issue should be an object")
.insert("owner".to_string(), serde_json::Value::String(owner));
}
}
emit_json(&value, "CodeClimate")
}
#[must_use]
#[expect(
clippy::too_many_lines,
reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
)]
pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
let mut issues = Vec::new();
let cyc_t = report.summary.max_cyclomatic_threshold;
let cog_t = report.summary.max_cognitive_threshold;
let crap_t = report.summary.max_crap_threshold;
for finding in &report.findings {
let path = cc_path(&finding.path, root);
let description = match finding.exceeded {
ExceededThreshold::Both => format!(
"'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
),
ExceededThreshold::Cyclomatic => format!(
"'{}' has cyclomatic complexity {} (threshold: {})",
finding.name, finding.cyclomatic, cyc_t
),
ExceededThreshold::Cognitive => format!(
"'{}' has cognitive complexity {} (threshold: {})",
finding.name, finding.cognitive, cog_t
),
ExceededThreshold::Crap
| ExceededThreshold::CyclomaticCrap
| ExceededThreshold::CognitiveCrap
| ExceededThreshold::All => {
let crap = finding.crap.unwrap_or(0.0);
let coverage = finding
.coverage_pct
.map(|pct| format!(", coverage {pct:.0}%"))
.unwrap_or_default();
format!(
"'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
finding.name, finding.cyclomatic,
)
}
};
let check_name = match finding.exceeded {
ExceededThreshold::Both => "fallow/high-complexity",
ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
ExceededThreshold::Crap
| ExceededThreshold::CyclomaticCrap
| ExceededThreshold::CognitiveCrap
| ExceededThreshold::All => "fallow/high-crap-score",
};
let severity = match finding.severity {
crate::health_types::FindingSeverity::Critical => CodeClimateSeverity::Critical,
crate::health_types::FindingSeverity::High => CodeClimateSeverity::Major,
crate::health_types::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
};
let line_str = finding.line.to_string();
let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
issues.push(cc_issue(
check_name,
&description,
severity,
"Complexity",
&path,
Some(finding.line),
&fp,
));
}
if let Some(ref production) = report.runtime_coverage {
for finding in &production.findings {
let path = cc_path(&finding.path, root);
let check_name = match finding.verdict {
crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
"fallow/runtime-safe-to-delete"
}
crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
"fallow/runtime-review-required"
}
crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
"fallow/runtime-low-traffic"
}
crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
"fallow/runtime-coverage-unavailable"
}
crate::health_types::RuntimeCoverageVerdict::Active
| crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
};
let invocations_hint = finding.invocations.map_or_else(
|| "untracked".to_owned(),
|hits| format!("{hits} invocations"),
);
let description = format!(
"'{}' runtime coverage verdict: {} ({})",
finding.function,
finding.verdict.human_label(),
invocations_hint,
);
let severity = match finding.verdict {
crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
CodeClimateSeverity::Critical
}
crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
CodeClimateSeverity::Major
}
_ => CodeClimateSeverity::Minor,
};
let fp = fingerprint_hash(&[
check_name,
&path,
&finding.line.to_string(),
&finding.function,
]);
issues.push(cc_issue(
check_name,
&description,
severity,
"Bug Risk",
&path,
Some(finding.line),
&fp,
));
}
}
if let Some(ref gaps) = report.coverage_gaps {
for item in &gaps.files {
let path = cc_path(&item.file.path, root);
let description = format!(
"File is runtime-reachable but has no test dependency path ({} value export{})",
item.file.value_export_count,
if item.file.value_export_count == 1 {
""
} else {
"s"
},
);
let fp = fingerprint_hash(&["fallow/untested-file", &path]);
issues.push(cc_issue(
"fallow/untested-file",
&description,
CodeClimateSeverity::Minor,
"Coverage",
&path,
None,
&fp,
));
}
for item in &gaps.exports {
let path = cc_path(&item.export.path, root);
let description = format!(
"Export '{}' is runtime-reachable but never referenced by test-reachable modules",
item.export.export_name
);
let line_str = item.export.line.to_string();
let fp = fingerprint_hash(&[
"fallow/untested-export",
&path,
&line_str,
&item.export.export_name,
]);
issues.push(cc_issue(
"fallow/untested-export",
&description,
CodeClimateSeverity::Minor,
"Coverage",
&path,
Some(item.export.line),
&fp,
));
}
}
issues
}
pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
let issues = build_health_codeclimate(report, root);
let value = issues_to_value(&issues);
emit_json(&value, "CodeClimate")
}
pub(super) fn print_grouped_health_codeclimate(
report: &HealthReport,
root: &Path,
resolver: &OwnershipResolver,
) -> ExitCode {
let issues = build_health_codeclimate(report, root);
let mut value = issues_to_value(&issues);
if let Some(items) = value.as_array_mut() {
for issue in items {
let path = issue
.pointer("/location/path")
.and_then(|v| v.as_str())
.unwrap_or("");
let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
issue
.as_object_mut()
.expect("CodeClimate issue should be an object")
.insert("group".to_string(), serde_json::Value::String(group));
}
}
emit_json(&value, "CodeClimate")
}
#[must_use]
#[expect(
clippy::cast_possible_truncation,
reason = "line numbers are bounded by source size"
)]
pub fn build_duplication_codeclimate(
report: &DuplicationReport,
root: &Path,
) -> Vec<CodeClimateIssue> {
let mut issues = Vec::new();
for (i, group) in report.clone_groups.iter().enumerate() {
let token_str = group.token_count.to_string();
let line_count_str = group.line_count.to_string();
let fragment_prefix: String = group
.instances
.first()
.map(|inst| inst.fragment.chars().take(64).collect())
.unwrap_or_default();
for instance in &group.instances {
let path = cc_path(&instance.file, root);
let start_str = instance.start_line.to_string();
let fp = fingerprint_hash(&[
"fallow/code-duplication",
&path,
&start_str,
&token_str,
&line_count_str,
&fragment_prefix,
]);
issues.push(cc_issue(
"fallow/code-duplication",
&format!(
"Code clone group {} ({} lines, {} instances)",
i + 1,
group.line_count,
group.instances.len()
),
CodeClimateSeverity::Minor,
"Duplication",
&path,
Some(instance.start_line as u32),
&fp,
));
}
}
issues
}
pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
let issues = build_duplication_codeclimate(report, root);
let value = issues_to_value(&issues);
emit_json(&value, "CodeClimate")
}
pub(super) fn print_grouped_duplication_codeclimate(
report: &DuplicationReport,
root: &Path,
resolver: &OwnershipResolver,
) -> ExitCode {
let issues = build_duplication_codeclimate(report, root);
let mut value = issues_to_value(&issues);
use rustc_hash::FxHashMap;
let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
for group in &report.clone_groups {
let owner = super::dupes_grouping::largest_owner(group, root, resolver);
for instance in &group.instances {
let path = cc_path(&instance.file, root);
path_to_owner.insert(path, owner.clone());
}
}
if let Some(items) = value.as_array_mut() {
for issue in items {
let path = issue
.pointer("/location/path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let group = path_to_owner
.get(&path)
.cloned()
.unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
issue
.as_object_mut()
.expect("CodeClimate issue should be an object")
.insert("group".to_string(), serde_json::Value::String(group));
}
}
emit_json(&value, "CodeClimate")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::test_helpers::sample_results;
use fallow_config::RulesConfig;
use fallow_core::results::*;
use std::path::PathBuf;
fn health_severity(value: u16, threshold: u16) -> &'static str {
if threshold == 0 {
return "minor";
}
let ratio = f64::from(value) / f64::from(threshold);
if ratio > 2.5 {
"critical"
} else if ratio > 1.5 {
"major"
} else {
"minor"
}
}
#[test]
fn codeclimate_empty_results_produces_empty_array() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let arr = output.as_array().unwrap();
assert!(arr.is_empty());
}
#[test]
fn codeclimate_produces_array_of_issues() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
assert!(output.is_array());
let arr = output.as_array().unwrap();
assert!(!arr.is_empty());
}
#[test]
fn codeclimate_issue_has_required_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/dead.ts"),
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let issue = &output.as_array().unwrap()[0];
assert_eq!(issue["type"], "issue");
assert_eq!(issue["check_name"], "fallow/unused-file");
assert!(issue["description"].is_string());
assert!(issue["categories"].is_array());
assert!(issue["severity"].is_string());
assert!(issue["fingerprint"].is_string());
assert!(issue["location"].is_object());
assert!(issue["location"]["path"].is_string());
assert!(issue["location"]["lines"].is_object());
}
#[test]
fn codeclimate_unused_file_severity_follows_rules() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/dead.ts"),
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
assert_eq!(output[0]["severity"], "major");
let rules = RulesConfig {
unused_files: Severity::Warn,
..RulesConfig::default()
};
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
assert_eq!(output[0]["severity"], "minor");
}
#[test]
fn codeclimate_unused_export_has_line_number() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_exports
.push(UnusedExportFinding::with_actions(UnusedExport {
path: root.join("src/utils.ts"),
export_name: "helperFn".to_string(),
is_type_only: false,
line: 10,
col: 4,
span_start: 120,
is_re_export: false,
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let issue = &output[0];
assert_eq!(issue["location"]["lines"]["begin"], 10);
}
#[test]
fn codeclimate_unused_file_line_defaults_to_1() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/dead.ts"),
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let issue = &output[0];
assert_eq!(issue["location"]["lines"]["begin"], 1);
}
#[test]
fn codeclimate_paths_are_relative() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/deep/nested/file.ts"),
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let path = output[0]["location"]["path"].as_str().unwrap();
assert_eq!(path, "src/deep/nested/file.ts");
assert!(!path.starts_with("/project"));
}
#[test]
fn codeclimate_re_export_label_in_description() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_exports
.push(UnusedExportFinding::with_actions(UnusedExport {
path: root.join("src/index.ts"),
export_name: "reExported".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: true,
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let desc = output[0]["description"].as_str().unwrap();
assert!(desc.contains("Re-export"));
}
#[test]
fn codeclimate_unlisted_dep_one_issue_per_import_site() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unlisted_dependencies
.push(UnlistedDependencyFinding::with_actions(
UnlistedDependency {
package_name: "chalk".to_string(),
imported_from: vec![
ImportSite {
path: root.join("src/a.ts"),
line: 1,
col: 0,
},
ImportSite {
path: root.join("src/b.ts"),
line: 5,
col: 0,
},
],
},
));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let arr = output.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
}
#[test]
fn codeclimate_duplicate_export_one_issue_per_location() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.duplicate_exports
.push(DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Config".to_string(),
locations: vec![
DuplicateLocation {
path: root.join("src/a.ts"),
line: 10,
col: 0,
},
DuplicateLocation {
path: root.join("src/b.ts"),
line: 20,
col: 0,
},
DuplicateLocation {
path: root.join("src/c.ts"),
line: 30,
col: 0,
},
],
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let arr = output.as_array().unwrap();
assert_eq!(arr.len(), 3);
}
#[test]
fn codeclimate_circular_dep_emits_chain_in_description() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.circular_dependencies
.push(CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
length: 2,
line: 3,
col: 0,
is_cross_package: false,
},
));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let desc = output[0]["description"].as_str().unwrap();
assert!(desc.contains("Circular dependency"));
assert!(desc.contains("src/a.ts"));
assert!(desc.contains("src/b.ts"));
}
#[test]
fn codeclimate_fingerprints_are_deterministic() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let rules = RulesConfig::default();
let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
let fps1: Vec<&str> = output1
.as_array()
.unwrap()
.iter()
.map(|i| i["fingerprint"].as_str().unwrap())
.collect();
let fps2: Vec<&str> = output2
.as_array()
.unwrap()
.iter()
.map(|i| i["fingerprint"].as_str().unwrap())
.collect();
assert_eq!(fps1, fps2);
}
#[test]
fn codeclimate_fingerprints_are_unique() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
let mut fps: Vec<&str> = output
.as_array()
.unwrap()
.iter()
.map(|i| i["fingerprint"].as_str().unwrap())
.collect();
let original_len = fps.len();
fps.sort_unstable();
fps.dedup();
assert_eq!(fps.len(), original_len, "fingerprints should be unique");
}
#[test]
fn codeclimate_type_only_dep_has_correct_check_name() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.type_only_dependencies
.push(TypeOnlyDependencyFinding::with_actions(
TypeOnlyDependency {
package_name: "zod".to_string(),
path: root.join("package.json"),
line: 8,
},
));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
let desc = output[0]["description"].as_str().unwrap();
assert!(desc.contains("zod"));
assert!(desc.contains("type-only"));
}
#[test]
fn codeclimate_dep_with_zero_line_omits_line_number() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_dependencies
.push(UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("package.json"),
line: 0,
used_in_workspaces: Vec::new(),
}));
let rules = RulesConfig::default();
let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
assert_eq!(output[0]["location"]["lines"]["begin"], 1);
}
#[test]
fn fingerprint_hash_different_inputs_differ() {
let h1 = fingerprint_hash(&["a", "b"]);
let h2 = fingerprint_hash(&["a", "c"]);
assert_ne!(h1, h2);
}
#[test]
fn fingerprint_hash_order_matters() {
let h1 = fingerprint_hash(&["a", "b"]);
let h2 = fingerprint_hash(&["b", "a"]);
assert_ne!(h1, h2);
}
#[test]
fn fingerprint_hash_separator_prevents_collision() {
let h1 = fingerprint_hash(&["ab", "c"]);
let h2 = fingerprint_hash(&["a", "bc"]);
assert_ne!(h1, h2);
}
#[test]
fn fingerprint_hash_is_16_hex_chars() {
let h = fingerprint_hash(&["test"]);
assert_eq!(h.len(), 16);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn severity_error_maps_to_major() {
assert_eq!(
severity_to_codeclimate(Severity::Error),
CodeClimateSeverity::Major
);
}
#[test]
fn severity_warn_maps_to_minor() {
assert_eq!(
severity_to_codeclimate(Severity::Warn),
CodeClimateSeverity::Minor
);
}
#[test]
#[should_panic(expected = "internal error: entered unreachable code")]
fn severity_off_is_unreachable() {
let _ = severity_to_codeclimate(Severity::Off);
}
#[test]
fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let rules = RulesConfig {
unused_dependencies: Severity::Off,
unused_dev_dependencies: Severity::Off,
unused_optional_dependencies: Severity::Off,
unused_exports: Severity::Off,
unused_types: Severity::Off,
unused_enum_members: Severity::Off,
unused_class_members: Severity::Off,
..RulesConfig::default()
};
let issues = build_codeclimate(&results, &root, &rules);
assert!(issues.is_empty());
}
#[test]
fn health_severity_zero_threshold_returns_minor() {
assert_eq!(health_severity(100, 0), "minor");
}
#[test]
fn health_severity_at_threshold_returns_minor() {
assert_eq!(health_severity(10, 10), "minor");
}
#[test]
fn health_severity_1_5x_threshold_returns_minor() {
assert_eq!(health_severity(15, 10), "minor");
}
#[test]
fn health_severity_above_1_5x_returns_major() {
assert_eq!(health_severity(16, 10), "major");
}
#[test]
fn health_severity_at_2_5x_returns_major() {
assert_eq!(health_severity(25, 10), "major");
}
#[test]
fn health_severity_above_2_5x_returns_critical() {
assert_eq!(health_severity(26, 10), "critical");
}
#[test]
fn health_codeclimate_includes_coverage_gaps() {
use crate::health_types::*;
let root = PathBuf::from("/project");
let report = HealthReport {
summary: HealthSummary {
files_analyzed: 10,
functions_analyzed: 50,
..Default::default()
},
coverage_gaps: Some(CoverageGaps {
summary: CoverageGapSummary {
runtime_files: 2,
covered_files: 0,
file_coverage_pct: 0.0,
untested_files: 1,
untested_exports: 1,
},
files: vec![UntestedFileFinding::with_actions(
UntestedFile {
path: root.join("src/app.ts"),
value_export_count: 2,
},
&root,
)],
exports: vec![UntestedExportFinding::with_actions(
UntestedExport {
path: root.join("src/app.ts"),
export_name: "loader".into(),
line: 12,
col: 4,
},
&root,
)],
}),
..Default::default()
};
let output = issues_to_value(&build_health_codeclimate(&report, &root));
let issues = output.as_array().unwrap();
assert_eq!(issues.len(), 2);
assert_eq!(issues[0]["check_name"], "fallow/untested-file");
assert_eq!(issues[0]["categories"][0], "Coverage");
assert_eq!(issues[0]["location"]["path"], "src/app.ts");
assert_eq!(issues[1]["check_name"], "fallow/untested-export");
assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
assert!(
issues[1]["description"]
.as_str()
.unwrap()
.contains("loader")
);
}
#[test]
fn health_codeclimate_crap_only_uses_crap_check_name() {
use crate::health_types::{
ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
};
let root = PathBuf::from("/project");
let report = HealthReport {
findings: vec![
ComplexityViolation {
path: root.join("src/untested.ts"),
name: "risky".to_string(),
line: 7,
col: 0,
cyclomatic: 10,
cognitive: 10,
line_count: 20,
param_count: 1,
exceeded: crate::health_types::ExceededThreshold::Crap,
severity: FindingSeverity::High,
crap: Some(60.0),
coverage_pct: Some(25.0),
coverage_tier: None,
coverage_source: None,
inherited_from: None,
component_rollup: None,
}
.into(),
],
summary: HealthSummary {
functions_analyzed: 10,
functions_above_threshold: 1,
..Default::default()
},
..Default::default()
};
let json = issues_to_value(&build_health_codeclimate(&report, &root));
let issues = json.as_array().unwrap();
assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
assert_eq!(issues[0]["severity"], "major");
let description = issues[0]["description"].as_str().unwrap();
assert!(description.contains("CRAP score"), "desc: {description}");
assert!(description.contains("coverage 25%"), "desc: {description}");
}
}