Skip to main content

fallow_api/
dead_code_codeclimate.rs

1//! Shared dead-code CodeClimate issue construction.
2
3use std::path::Path;
4
5use fallow_config::{RulesConfig, Severity};
6use fallow_output::{
7    CodeClimateIssue, CodeClimateIssueInput, CodeClimateSeverity, build_codeclimate_issue,
8    codeclimate_fingerprint_hash, normalize_uri,
9};
10use fallow_types::output_dead_code::{
11    EffectiveSeverity, GatedFinding, ReachabilityCaveat, caveat_suffix,
12};
13use fallow_types::results::AnalysisResults;
14
15fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
16    match s {
17        Severity::Error => CodeClimateSeverity::Major,
18        Severity::Warn => CodeClimateSeverity::Minor,
19        Severity::Off => unreachable!(),
20    }
21}
22
23/// The CodeClimate severity for one finding: its gate severity when the
24/// finding carries one, otherwise the configured rule severity (a saved report
25/// from an older version has no gate severity).
26///
27/// A saved finding exists, so its rule was on when it was reported. When the
28/// config at render time sets the rule `off`, the severity is `minor`.
29fn gate_codeclimate(effective: Option<EffectiveSeverity>, rule: Severity) -> CodeClimateSeverity {
30    match effective {
31        Some(EffectiveSeverity::Error) => CodeClimateSeverity::Major,
32        Some(EffectiveSeverity::Warn) => CodeClimateSeverity::Minor,
33        None => match rule {
34            Severity::Off => CodeClimateSeverity::Minor,
35            Severity::Error | Severity::Warn => severity_to_codeclimate(rule),
36        },
37    }
38}
39
40fn finding_codeclimate(finding: &impl GatedFinding, rule: Severity) -> CodeClimateSeverity {
41    gate_codeclimate(finding.effective_severity(), rule)
42}
43
44fn cc_path(path: &Path, root: &Path) -> String {
45    normalize_uri(
46        &path
47            .strip_prefix(root)
48            .unwrap_or(path)
49            .display()
50            .to_string(),
51    )
52}
53
54/// The caveat parenthetical a CodeClimate `description` ends with, or an empty
55/// string when the verdict rests on a fully analyzed run.
56///
57/// `description` is the one field GitLab renders inline on the MR diff, and it
58/// is also what `CiIssue` carries into the PR-comment and review-comment
59/// bodies. Those bodies offer a mutation, so the sentence that offers it has to
60/// say when the evidence behind it is incomplete. The suffix is the same
61/// parenthetical the human report and the SARIF message already use, so one
62/// finding reads identically across every surface.
63///
64/// Deliberately NOT part of the fingerprint: `codeclimate_fingerprint_hash` is
65/// fed rule id plus location by every call site in this file, never the
66/// description, so a finding that gains or loses a caveat keeps its identity
67/// and no previously resolved review thread reopens.
68fn cc_caveat_suffix(caveats: &[ReachabilityCaveat]) -> String {
69    caveat_suffix(caveats).unwrap_or_default()
70}
71
72/// Push CodeClimate issues for unused dependencies with a shared structure.
73fn push_dep_cc_issues<'a, I>(
74    issues: &mut Vec<CodeClimateIssue>,
75    deps: I,
76    root: &Path,
77    rule_id: &str,
78    location_label: &str,
79    severity: Severity,
80) where
81    I: IntoIterator<
82        Item = (
83            &'a fallow_types::results::UnusedDependency,
84            &'a [ReachabilityCaveat],
85            Option<EffectiveSeverity>,
86        ),
87    >,
88{
89    for (dep, caveats, effective) in deps {
90        let level = gate_codeclimate(effective, severity);
91        let path = cc_path(&dep.path, root);
92        let line = if dep.line > 0 { Some(dep.line) } else { None };
93        let fp = codeclimate_fingerprint_hash(&[rule_id, &dep.package_name]);
94        let workspace_context = if dep.used_in_workspaces.is_empty() {
95            String::new()
96        } else {
97            let workspaces = dep
98                .used_in_workspaces
99                .iter()
100                .map(|path| cc_path(path, root))
101                .collect::<Vec<_>>()
102                .join(", ");
103            format!("; imported in other workspaces: {workspaces}")
104        };
105        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
106            check_name: rule_id,
107            description: &format!(
108                "Package '{}' is in {location_label} but never imported{workspace_context}{}",
109                dep.package_name,
110                cc_caveat_suffix(caveats)
111            ),
112            severity: level,
113            category: "Bug Risk",
114            path: &path,
115            begin_line: line,
116            fingerprint: &fp,
117        }));
118    }
119}
120
121fn push_unused_file_issues(
122    issues: &mut Vec<CodeClimateIssue>,
123    files: &[fallow_types::output_dead_code::UnusedFileFinding],
124    root: &Path,
125    severity: Severity,
126) {
127    if files.is_empty() {
128        return;
129    }
130    for entry in files {
131        let level = finding_codeclimate(entry, severity);
132        let path = cc_path(&entry.file.path, root);
133        let fp = codeclimate_fingerprint_hash(&["fallow/unused-file", &path]);
134        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
135            check_name: "fallow/unused-file",
136            description: &format!(
137                "File is not reachable from any entry point{}",
138                cc_caveat_suffix(&entry.reachability_caveats)
139            ),
140            severity: level,
141            category: "Bug Risk",
142            path: &path,
143            begin_line: None,
144            fingerprint: &fp,
145        }));
146    }
147}
148
149/// Push CodeClimate issues for unused exports or unused types.
150///
151/// `direct_label` / `re_export_label` let the same helper produce the right
152/// prose for both `unused-export` (Export / Re-export) and `unused-type`
153/// (Type export / Type re-export) rule ids.
154struct UnusedExportIssuesInput<'a, I> {
155    issues: &'a mut Vec<CodeClimateIssue>,
156    exports: I,
157    root: &'a Path,
158    rule_id: &'a str,
159    direct_label: &'a str,
160    re_export_label: &'a str,
161    severity: Severity,
162}
163
164fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
165where
166    I: IntoIterator<
167        Item = (
168            &'a fallow_types::results::UnusedExport,
169            &'a [ReachabilityCaveat],
170            Option<EffectiveSeverity>,
171        ),
172    >,
173{
174    for (export, caveats, effective) in input.exports {
175        let level = gate_codeclimate(effective, input.severity);
176        let path = cc_path(&export.path, input.root);
177        let kind = if export.is_re_export {
178            input.re_export_label
179        } else {
180            input.direct_label
181        };
182        let line_str = export.line.to_string();
183        let fp =
184            codeclimate_fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
185        input
186            .issues
187            .push(build_codeclimate_issue(CodeClimateIssueInput {
188                check_name: input.rule_id,
189                description: &format!(
190                    "{kind} '{}' is never imported by other modules{}",
191                    export.export_name,
192                    cc_caveat_suffix(caveats)
193                ),
194                severity: level,
195                category: "Bug Risk",
196                path: &path,
197                begin_line: Some(export.line),
198                fingerprint: &fp,
199            }));
200    }
201}
202
203fn push_private_type_leak_issues(
204    issues: &mut Vec<CodeClimateIssue>,
205    leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
206    root: &Path,
207    severity: Severity,
208) {
209    if leaks.is_empty() {
210        return;
211    }
212    for entry in leaks {
213        let level = finding_codeclimate(entry, severity);
214        let leak = &entry.leak;
215        let path = cc_path(&leak.path, root);
216        let line_str = leak.line.to_string();
217        let fp = codeclimate_fingerprint_hash(&[
218            "fallow/private-type-leak",
219            &path,
220            &line_str,
221            &leak.export_name,
222            &leak.type_name,
223        ]);
224        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
225            check_name: "fallow/private-type-leak",
226            description: &format!(
227                "Export '{}' references private type '{}'",
228                leak.export_name, leak.type_name
229            ),
230            severity: level,
231            category: "Bug Risk",
232            path: &path,
233            begin_line: Some(leak.line),
234            fingerprint: &fp,
235        }));
236    }
237}
238
239fn push_deprecated_export_issues(
240    issues: &mut Vec<CodeClimateIssue>,
241    findings: &[fallow_types::output_dead_code::DeprecatedExportInUseFinding],
242    root: &Path,
243    severity: Severity,
244) {
245    for entry in findings {
246        let level = finding_codeclimate(entry, severity);
247        let export = &entry.export;
248        let path = cc_path(&export.path, root);
249        let line_str = export.line.to_string();
250        let fp = codeclimate_fingerprint_hash(&[
251            "fallow/deprecated-export-in-use",
252            &path,
253            &line_str,
254            &export.export_name,
255        ]);
256        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
257            check_name: "fallow/deprecated-export-in-use",
258            description: &export.description(),
259            severity: level,
260            category: "Compatibility",
261            path: &path,
262            begin_line: Some(export.line),
263            fingerprint: &fp,
264        }));
265    }
266}
267
268fn push_type_only_dep_issues(
269    issues: &mut Vec<CodeClimateIssue>,
270    deps: &[fallow_types::output_dead_code::TypeOnlyDependencyFinding],
271    root: &Path,
272    severity: Severity,
273) {
274    if deps.is_empty() {
275        return;
276    }
277    for entry in deps {
278        let level = finding_codeclimate(entry, severity);
279        let dep = &entry.dep;
280        let path = cc_path(&dep.path, root);
281        let line = if dep.line > 0 { Some(dep.line) } else { None };
282        let fp = codeclimate_fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
283        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
284            check_name: "fallow/type-only-dependency",
285            description: &format!(
286                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
287                dep.package_name
288            ),
289            severity: level,
290            category: "Bug Risk",
291            path: &path,
292            begin_line: line,
293            fingerprint: &fp,
294        }));
295    }
296}
297
298fn push_test_only_dep_issues(
299    issues: &mut Vec<CodeClimateIssue>,
300    deps: &[fallow_types::output_dead_code::TestOnlyDependencyFinding],
301    root: &Path,
302    severity: Severity,
303) {
304    if deps.is_empty() {
305        return;
306    }
307    for entry in deps {
308        let level = finding_codeclimate(entry, severity);
309        let dep = &entry.dep;
310        let path = cc_path(&dep.path, root);
311        let line = if dep.line > 0 { Some(dep.line) } else { None };
312        let fp = codeclimate_fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
313        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
314            check_name: "fallow/test-only-dependency",
315            description: &format!(
316                "Package '{}' is only imported by test files (consider moving to devDependencies)",
317                dep.package_name
318            ),
319            severity: level,
320            category: "Bug Risk",
321            path: &path,
322            begin_line: line,
323            fingerprint: &fp,
324        }));
325    }
326}
327
328fn push_dev_dep_in_prod_issues(
329    issues: &mut Vec<CodeClimateIssue>,
330    deps: &[fallow_types::output_dead_code::DevDependencyInProductionFinding],
331    root: &Path,
332    severity: Severity,
333) {
334    if deps.is_empty() {
335        return;
336    }
337    for entry in deps {
338        let level = finding_codeclimate(entry, severity);
339        let dep = &entry.dep;
340        let path = cc_path(&dep.path, root);
341        let line = if dep.line > 0 { Some(dep.line) } else { None };
342        let fp = codeclimate_fingerprint_hash(&[
343            "fallow/dev-dependency-in-production",
344            &dep.package_name,
345        ]);
346        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
347            check_name: "fallow/dev-dependency-in-production",
348            description: &format!(
349                "devDependency '{}' is imported by production code at runtime (consider moving to dependencies)",
350                dep.package_name
351            ),
352            severity: level,
353            category: "Bug Risk",
354            path: &path,
355            begin_line: line,
356            fingerprint: &fp,
357        }));
358    }
359}
360
361/// Push CodeClimate issues for unused enum or class members.
362///
363/// `entity_label` is `"Enum"` or `"Class"` so the rendered description reads
364/// "Enum member ..." or "Class member ..." accordingly.
365fn push_unused_member_issues<'a, I>(
366    issues: &mut Vec<CodeClimateIssue>,
367    members: I,
368    root: &Path,
369    rule_id: &str,
370    entity_label: &str,
371    severity: Severity,
372) where
373    I: IntoIterator<
374        Item = (
375            &'a fallow_types::results::UnusedMember,
376            &'a [ReachabilityCaveat],
377            Option<EffectiveSeverity>,
378        ),
379    >,
380{
381    for (member, caveats, effective) in members {
382        let level = gate_codeclimate(effective, severity);
383        let path = cc_path(&member.path, root);
384        let line_str = member.line.to_string();
385        let fp = codeclimate_fingerprint_hash(&[
386            rule_id,
387            &path,
388            &line_str,
389            &member.parent_name,
390            &member.member_name,
391        ]);
392        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
393            check_name: rule_id,
394            description: &format!(
395                "{entity_label} member '{}.{}' is never referenced{}",
396                member.parent_name,
397                member.member_name,
398                cc_caveat_suffix(caveats)
399            ),
400            severity: level,
401            category: "Bug Risk",
402            path: &path,
403            begin_line: Some(member.line),
404            fingerprint: &fp,
405        }));
406    }
407}
408
409fn push_unresolved_import_issues(
410    issues: &mut Vec<CodeClimateIssue>,
411    imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
412    root: &Path,
413    severity: Severity,
414) {
415    if imports.is_empty() {
416        return;
417    }
418    for entry in imports {
419        let level = finding_codeclimate(entry, severity);
420        let import = &entry.import;
421        let path = cc_path(&import.path, root);
422        let line_str = import.line.to_string();
423        let fp = codeclimate_fingerprint_hash(&[
424            "fallow/unresolved-import",
425            &path,
426            &line_str,
427            &import.specifier,
428        ]);
429        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
430            check_name: "fallow/unresolved-import",
431            description: &format!("Import '{}' could not be resolved", import.specifier),
432            severity: level,
433            category: "Bug Risk",
434            path: &path,
435            begin_line: Some(import.line),
436            fingerprint: &fp,
437        }));
438    }
439}
440
441fn push_unlisted_dep_issues(
442    issues: &mut Vec<CodeClimateIssue>,
443    deps: &[fallow_types::output_dead_code::UnlistedDependencyFinding],
444    root: &Path,
445    severity: Severity,
446) {
447    if deps.is_empty() {
448        return;
449    }
450    for entry in deps {
451        let level = finding_codeclimate(entry, severity);
452        let dep = &entry.dep;
453        for site in &dep.imported_from {
454            let path = cc_path(&site.path, root);
455            let line_str = site.line.to_string();
456            let fp = codeclimate_fingerprint_hash(&[
457                "fallow/unlisted-dependency",
458                &path,
459                &line_str,
460                &dep.package_name,
461            ]);
462            issues.push(build_codeclimate_issue(CodeClimateIssueInput {
463                check_name: "fallow/unlisted-dependency",
464                description: &format!(
465                    "Package '{}' is imported but not listed in package.json",
466                    dep.package_name
467                ),
468                severity: level,
469                category: "Bug Risk",
470                path: &path,
471                begin_line: Some(site.line),
472                fingerprint: &fp,
473            }));
474        }
475    }
476}
477
478fn push_duplicate_export_issues(
479    issues: &mut Vec<CodeClimateIssue>,
480    dups: &[fallow_types::output_dead_code::DuplicateExportFinding],
481    root: &Path,
482    severity: Severity,
483) {
484    if dups.is_empty() {
485        return;
486    }
487    for dup in dups {
488        let level = finding_codeclimate(dup, severity);
489        let dup = &dup.export;
490        for loc in &dup.locations {
491            let path = cc_path(&loc.path, root);
492            let line_str = loc.line.to_string();
493            let fp = codeclimate_fingerprint_hash(&[
494                "fallow/duplicate-export",
495                &path,
496                &line_str,
497                &dup.export_name,
498            ]);
499            issues.push(build_codeclimate_issue(CodeClimateIssueInput {
500                check_name: "fallow/duplicate-export",
501                description: &format!("Export '{}' appears in multiple modules", dup.export_name),
502                severity: level,
503                category: "Bug Risk",
504                path: &path,
505                begin_line: Some(loc.line),
506                fingerprint: &fp,
507            }));
508        }
509    }
510}
511
512fn push_circular_dep_issues(
513    issues: &mut Vec<CodeClimateIssue>,
514    cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
515    root: &Path,
516    severity: Severity,
517) {
518    if cycles.is_empty() {
519        return;
520    }
521    for entry in cycles {
522        let level = finding_codeclimate(entry, severity);
523        let cycle = &entry.cycle;
524        let Some(first) = cycle.files.first() else {
525            continue;
526        };
527        let path = cc_path(first, root);
528        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
529        let chain_str = chain.join(":");
530        let fp = codeclimate_fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
531        let line = if cycle.line > 0 {
532            Some(cycle.line)
533        } else {
534            None
535        };
536        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
537            check_name: "fallow/circular-dependency",
538            description: &format!(
539                "Circular dependency{}: {}",
540                if cycle.is_cross_package {
541                    " (cross-package)"
542                } else {
543                    ""
544                },
545                chain.join(" \u{2192} ")
546            ),
547            severity: level,
548            category: "Bug Risk",
549            path: &path,
550            begin_line: line,
551            fingerprint: &fp,
552        }));
553    }
554}
555
556fn push_re_export_cycle_issues(
557    issues: &mut Vec<CodeClimateIssue>,
558    cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
559    root: &Path,
560    severity: Severity,
561) {
562    if cycles.is_empty() {
563        return;
564    }
565    for entry in cycles {
566        let level = finding_codeclimate(entry, severity);
567        let cycle = &entry.cycle;
568        let Some(first) = cycle.files.first() else {
569            continue;
570        };
571        let path = cc_path(first, root);
572        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
573        let chain_str = chain.join(":");
574        let kind_token = match cycle.kind {
575            fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
576            fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
577        };
578        let kind_tag = match cycle.kind {
579            fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
580            fallow_types::results::ReExportCycleKind::MultiNode => "",
581        };
582        let fp = codeclimate_fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
583        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
584            check_name: "fallow/re-export-cycle",
585            description: &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
586            severity: level,
587            category: "Bug Risk",
588            path: &path,
589            begin_line: None,
590            fingerprint: &fp,
591        }));
592    }
593}
594
595fn push_boundary_violation_issues(
596    issues: &mut Vec<CodeClimateIssue>,
597    violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
598    root: &Path,
599    severity: Severity,
600) {
601    if violations.is_empty() {
602        return;
603    }
604    for entry in violations {
605        let level = finding_codeclimate(entry, severity);
606        let v = &entry.violation;
607        let path = cc_path(&v.from_path, root);
608        let to = cc_path(&v.to_path, root);
609        let fp = codeclimate_fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
610        let line = if v.line > 0 { Some(v.line) } else { None };
611        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
612            check_name: "fallow/boundary-violation",
613            description: &format!(
614                "Boundary violation: {} -> {} ({} -> {})",
615                path, to, v.from_zone, v.to_zone
616            ),
617            severity: level,
618            category: "Bug Risk",
619            path: &path,
620            begin_line: line,
621            fingerprint: &fp,
622        }));
623    }
624}
625
626fn push_boundary_coverage_issues(
627    issues: &mut Vec<CodeClimateIssue>,
628    violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
629    root: &Path,
630    severity: Severity,
631) {
632    if violations.is_empty() {
633        return;
634    }
635    for entry in violations {
636        let level = finding_codeclimate(entry, severity);
637        let v = &entry.violation;
638        let path = cc_path(&v.path, root);
639        let fp = codeclimate_fingerprint_hash(&["fallow/boundary-coverage", &path]);
640        let line = if v.line > 0 { Some(v.line) } else { None };
641        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
642            check_name: "fallow/boundary-coverage",
643            description: &format!("Boundary coverage: {path} matches no configured zone"),
644            severity: level,
645            category: "Bug Risk",
646            path: &path,
647            begin_line: line,
648            fingerprint: &fp,
649        }));
650    }
651}
652
653fn push_boundary_call_issues(
654    issues: &mut Vec<CodeClimateIssue>,
655    violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
656    root: &Path,
657    severity: Severity,
658) {
659    if violations.is_empty() {
660        return;
661    }
662    for entry in violations {
663        let level = finding_codeclimate(entry, severity);
664        let v = &entry.violation;
665        let path = cc_path(&v.path, root);
666        let fp =
667            codeclimate_fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
668        let line = if v.line > 0 { Some(v.line) } else { None };
669        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
670            check_name: "fallow/boundary-call-violation",
671            description: &format!(
672                "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
673                v.callee, v.pattern, v.zone
674            ),
675            severity: level,
676            category: "Bug Risk",
677            path: &path,
678            begin_line: line,
679            fingerprint: &fp,
680        }));
681    }
682}
683
684fn push_policy_violation_issues(
685    issues: &mut Vec<CodeClimateIssue>,
686    violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
687    root: &Path,
688) {
689    use fallow_types::results::PolicyViolationSeverity;
690
691    for entry in violations {
692        let v = &entry.violation;
693        let path = cc_path(&v.path, root);
694        let rule = format!("{}/{}", v.pack, v.rule_id);
695        let fp =
696            codeclimate_fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
697        let line = if v.line > 0 { Some(v.line) } else { None };
698        // Severity comes from the EFFECTIVE per-finding value, not the
699        // policy-violation master, so a severity: "error" rule under a warn
700        // master maps to blocker-level just like the exit-code gate.
701        let level = severity_to_codeclimate(match v.severity {
702            PolicyViolationSeverity::Error => Severity::Error,
703            PolicyViolationSeverity::Warn => Severity::Warn,
704        });
705        let message = match &v.message {
706            Some(message) => format!(
707                "Policy violation: `{}` is banned by `{rule}`. {message}",
708                v.matched
709            ),
710            None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
711        };
712        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
713            check_name: "fallow/policy-violation",
714            description: &message,
715            severity: level,
716            category: "Bug Risk",
717            path: &path,
718            begin_line: line,
719            fingerprint: &fp,
720        }));
721    }
722}
723
724fn push_invalid_client_export_issues(
725    issues: &mut Vec<CodeClimateIssue>,
726    findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
727    root: &Path,
728    severity: Severity,
729) {
730    if findings.is_empty() {
731        return;
732    }
733    for entry in findings {
734        let level = finding_codeclimate(entry, severity);
735        let e = &entry.export;
736        let path = cc_path(&e.path, root);
737        let fp =
738            codeclimate_fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
739        let line = if e.line > 0 { Some(e.line) } else { None };
740        let message = format!(
741            "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
742            e.export_name, e.directive
743        );
744        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
745            check_name: "fallow/invalid-client-export",
746            description: &message,
747            severity: level,
748            category: "Bug Risk",
749            path: &path,
750            begin_line: line,
751            fingerprint: &fp,
752        }));
753    }
754}
755
756fn push_mixed_client_server_barrel_issues(
757    issues: &mut Vec<CodeClimateIssue>,
758    findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
759    root: &Path,
760    severity: Severity,
761) {
762    if findings.is_empty() {
763        return;
764    }
765    for entry in findings {
766        let level = finding_codeclimate(entry, severity);
767        let b = &entry.barrel;
768        let path = cc_path(&b.path, root);
769        let fp = codeclimate_fingerprint_hash(&[
770            "fallow/mixed-client-server-barrel",
771            &path,
772            &b.client_origin,
773            &b.server_origin,
774        ]);
775        let line = if b.line > 0 { Some(b.line) } else { None };
776        let message = format!(
777            "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
778            b.client_origin, b.server_origin
779        );
780        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
781            check_name: "fallow/mixed-client-server-barrel",
782            description: &message,
783            severity: level,
784            category: "Bug Risk",
785            path: &path,
786            begin_line: line,
787            fingerprint: &fp,
788        }));
789    }
790}
791
792fn push_misplaced_directive_issues(
793    issues: &mut Vec<CodeClimateIssue>,
794    findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
795    root: &Path,
796    severity: Severity,
797) {
798    if findings.is_empty() {
799        return;
800    }
801    for entry in findings {
802        let level = finding_codeclimate(entry, severity);
803        let d = &entry.directive_site;
804        let path = cc_path(&d.path, root);
805        let fp = codeclimate_fingerprint_hash(&[
806            "fallow/misplaced-directive",
807            &path,
808            &d.line.to_string(),
809            &d.directive,
810        ]);
811        let line = if d.line > 0 { Some(d.line) } else { None };
812        let message = format!(
813            "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
814            d.directive
815        );
816        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
817            check_name: "fallow/misplaced-directive",
818            description: &message,
819            severity: level,
820            category: "Bug Risk",
821            path: &path,
822            begin_line: line,
823            fingerprint: &fp,
824        }));
825    }
826}
827
828fn push_unprovided_inject_issues(
829    issues: &mut Vec<CodeClimateIssue>,
830    findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
831    root: &Path,
832    severity: Severity,
833) {
834    if findings.is_empty() {
835        return;
836    }
837    for entry in findings {
838        let level = finding_codeclimate(entry, severity);
839        let i = &entry.inject;
840        let path = cc_path(&i.path, root);
841        let fp = codeclimate_fingerprint_hash(&[
842            "fallow/unprovided-inject",
843            &path,
844            &i.line.to_string(),
845            &i.key_name,
846        ]);
847        let line = if i.line > 0 { Some(i.line) } else { None };
848        let message = format!(
849            "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
850            i.key_name, i.key_name
851        );
852        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
853            check_name: "fallow/unprovided-inject",
854            description: &message,
855            severity: level,
856            category: "Bug Risk",
857            path: &path,
858            begin_line: line,
859            fingerprint: &fp,
860        }));
861    }
862}
863
864fn push_unrendered_component_issues(
865    issues: &mut Vec<CodeClimateIssue>,
866    findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
867    root: &Path,
868    severity: Severity,
869) {
870    if findings.is_empty() {
871        return;
872    }
873    for entry in findings {
874        let level = finding_codeclimate(entry, severity);
875        let c = &entry.component;
876        let path = cc_path(&c.path, root);
877        let fp = codeclimate_fingerprint_hash(&[
878            "fallow/unrendered-component",
879            &path,
880            &c.line.to_string(),
881            &c.component_name,
882        ]);
883        let line = if c.line > 0 { Some(c.line) } else { None };
884        let message = format!(
885            "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
886            c.component_name
887        );
888        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
889            check_name: "fallow/unrendered-component",
890            description: &message,
891            severity: level,
892            category: "Bug Risk",
893            path: &path,
894            begin_line: line,
895            fingerprint: &fp,
896        }));
897    }
898}
899
900fn push_unused_component_prop_issues(
901    issues: &mut Vec<CodeClimateIssue>,
902    findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
903    root: &Path,
904    severity: Severity,
905) {
906    if findings.is_empty() {
907        return;
908    }
909    for entry in findings {
910        let level = finding_codeclimate(entry, severity);
911        let p = &entry.prop;
912        let path = cc_path(&p.path, root);
913        let fp = codeclimate_fingerprint_hash(&[
914            "fallow/unused-component-prop",
915            &path,
916            &p.line.to_string(),
917            &p.prop_name,
918        ]);
919        let line = if p.line > 0 { Some(p.line) } else { None };
920        let message = format!(
921            "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
922            p.prop_name, p.component_name
923        );
924        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
925            check_name: "fallow/unused-component-prop",
926            description: &message,
927            severity: level,
928            category: "Bug Risk",
929            path: &path,
930            begin_line: line,
931            fingerprint: &fp,
932        }));
933    }
934}
935
936fn push_unused_component_emit_issues(
937    issues: &mut Vec<CodeClimateIssue>,
938    findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
939    root: &Path,
940    severity: Severity,
941) {
942    if findings.is_empty() {
943        return;
944    }
945    for entry in findings {
946        let level = finding_codeclimate(entry, severity);
947        let e = &entry.emit;
948        let path = cc_path(&e.path, root);
949        let fp = codeclimate_fingerprint_hash(&[
950            "fallow/unused-component-emit",
951            &path,
952            &e.line.to_string(),
953            &e.emit_name,
954        ]);
955        let line = if e.line > 0 { Some(e.line) } else { None };
956        let message = format!(
957            "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
958            e.emit_name, e.component_name
959        );
960        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
961            check_name: "fallow/unused-component-emit",
962            description: &message,
963            severity: level,
964            category: "Bug Risk",
965            path: &path,
966            begin_line: line,
967            fingerprint: &fp,
968        }));
969    }
970}
971
972fn push_unused_svelte_event_issues(
973    issues: &mut Vec<CodeClimateIssue>,
974    findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
975    root: &Path,
976    severity: Severity,
977) {
978    if findings.is_empty() {
979        return;
980    }
981    for entry in findings {
982        let level = finding_codeclimate(entry, severity);
983        let e = &entry.event;
984        let path = cc_path(&e.path, root);
985        let fp = codeclimate_fingerprint_hash(&[
986            "fallow/unused-svelte-event",
987            &path,
988            &e.line.to_string(),
989            &e.event_name,
990        ]);
991        let line = if e.line > 0 { Some(e.line) } else { None };
992        let message = format!(
993            "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
994            e.event_name, e.component_name
995        );
996        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
997            check_name: "fallow/unused-svelte-event",
998            description: &message,
999            severity: level,
1000            category: "Bug Risk",
1001            path: &path,
1002            begin_line: line,
1003            fingerprint: &fp,
1004        }));
1005    }
1006}
1007
1008fn push_unused_component_input_issues(
1009    issues: &mut Vec<CodeClimateIssue>,
1010    findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1011    root: &Path,
1012    severity: Severity,
1013) {
1014    if findings.is_empty() {
1015        return;
1016    }
1017    for entry in findings {
1018        let level = finding_codeclimate(entry, severity);
1019        let i = &entry.input;
1020        let path = cc_path(&i.path, root);
1021        let fp = codeclimate_fingerprint_hash(&[
1022            "fallow/unused-component-input",
1023            &path,
1024            &i.line.to_string(),
1025            &i.input_name,
1026        ]);
1027        let line = if i.line > 0 { Some(i.line) } else { None };
1028        let message = format!(
1029            "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1030            i.input_name, i.component_name
1031        );
1032        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1033            check_name: "fallow/unused-component-input",
1034            description: &message,
1035            severity: level,
1036            category: "Bug Risk",
1037            path: &path,
1038            begin_line: line,
1039            fingerprint: &fp,
1040        }));
1041    }
1042}
1043
1044fn push_unused_component_output_issues(
1045    issues: &mut Vec<CodeClimateIssue>,
1046    findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1047    root: &Path,
1048    severity: Severity,
1049) {
1050    if findings.is_empty() {
1051        return;
1052    }
1053    for entry in findings {
1054        let level = finding_codeclimate(entry, severity);
1055        let o = &entry.output;
1056        let path = cc_path(&o.path, root);
1057        let fp = codeclimate_fingerprint_hash(&[
1058            "fallow/unused-component-output",
1059            &path,
1060            &o.line.to_string(),
1061            &o.output_name,
1062        ]);
1063        let line = if o.line > 0 { Some(o.line) } else { None };
1064        let message = format!(
1065            "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1066            o.output_name, o.component_name
1067        );
1068        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1069            check_name: "fallow/unused-component-output",
1070            description: &message,
1071            severity: level,
1072            category: "Bug Risk",
1073            path: &path,
1074            begin_line: line,
1075            fingerprint: &fp,
1076        }));
1077    }
1078}
1079
1080fn push_unused_server_action_issues(
1081    issues: &mut Vec<CodeClimateIssue>,
1082    findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1083    root: &Path,
1084    severity: Severity,
1085) {
1086    if findings.is_empty() {
1087        return;
1088    }
1089    for entry in findings {
1090        let level = finding_codeclimate(entry, severity);
1091        let a = &entry.action;
1092        let path = cc_path(&a.path, root);
1093        let fp = codeclimate_fingerprint_hash(&[
1094            "fallow/unused-server-action",
1095            &path,
1096            &a.line.to_string(),
1097            &a.action_name,
1098        ]);
1099        let line = if a.line > 0 { Some(a.line) } else { None };
1100        let message = format!(
1101            "server action `{}` is exported from a \"use server\" file but no code in this project references it (wire it to a consumer or remove it)",
1102            a.action_name
1103        );
1104        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1105            check_name: "fallow/unused-server-action",
1106            description: &message,
1107            severity: level,
1108            category: "Bug Risk",
1109            path: &path,
1110            begin_line: line,
1111            fingerprint: &fp,
1112        }));
1113    }
1114}
1115
1116fn push_unused_load_data_key_issues(
1117    issues: &mut Vec<CodeClimateIssue>,
1118    findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1119    root: &Path,
1120    severity: Severity,
1121) {
1122    if findings.is_empty() {
1123        return;
1124    }
1125    for entry in findings {
1126        let level = finding_codeclimate(entry, severity);
1127        let k = &entry.key;
1128        let path = cc_path(&k.path, root);
1129        let fp = codeclimate_fingerprint_hash(&[
1130            "fallow/unused-load-data-key",
1131            &path,
1132            &k.line.to_string(),
1133            &k.key_name,
1134        ]);
1135        let line = if k.line > 0 { Some(k.line) } else { None };
1136        let message = format!(
1137            "load() return key `{}` is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
1138            k.key_name
1139        );
1140        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1141            check_name: "fallow/unused-load-data-key",
1142            description: &message,
1143            severity: level,
1144            category: "Bug Risk",
1145            path: &path,
1146            begin_line: line,
1147            fingerprint: &fp,
1148        }));
1149    }
1150}
1151
1152fn push_route_collision_issues(
1153    issues: &mut Vec<CodeClimateIssue>,
1154    findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1155    root: &Path,
1156    severity: Severity,
1157) {
1158    if findings.is_empty() {
1159        return;
1160    }
1161    for entry in findings {
1162        let level = finding_codeclimate(entry, severity);
1163        let c = &entry.collision;
1164        let path = cc_path(&c.path, root);
1165        let fp = codeclimate_fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1166        let line = if c.line > 0 { Some(c.line) } else { None };
1167        let message = format!(
1168            "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1169            c.url,
1170            c.conflicting_paths.len()
1171        );
1172        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1173            check_name: "fallow/route-collision",
1174            description: &message,
1175            severity: level,
1176            category: "Bug Risk",
1177            path: &path,
1178            begin_line: line,
1179            fingerprint: &fp,
1180        }));
1181    }
1182}
1183
1184fn push_dynamic_segment_name_conflict_issues(
1185    issues: &mut Vec<CodeClimateIssue>,
1186    findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1187    root: &Path,
1188    severity: Severity,
1189) {
1190    if findings.is_empty() {
1191        return;
1192    }
1193    for entry in findings {
1194        let level = finding_codeclimate(entry, severity);
1195        let c = &entry.conflict;
1196        let path = cc_path(&c.path, root);
1197        let fp = codeclimate_fingerprint_hash(&[
1198            "fallow/dynamic-segment-name-conflict",
1199            &path,
1200            &c.position,
1201        ]);
1202        let line = if c.line > 0 { Some(c.line) } else { None };
1203        let message = format!(
1204            "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1205            c.position,
1206            c.conflicting_segments.join(", ")
1207        );
1208        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1209            check_name: "fallow/dynamic-segment-name-conflict",
1210            description: &message,
1211            severity: level,
1212            category: "Bug Risk",
1213            path: &path,
1214            begin_line: line,
1215            fingerprint: &fp,
1216        }));
1217    }
1218}
1219
1220fn push_stale_suppression_issues(
1221    issues: &mut Vec<CodeClimateIssue>,
1222    suppressions: &[fallow_types::results::StaleSuppression],
1223    root: &Path,
1224    rules: &RulesConfig,
1225) {
1226    if suppressions.is_empty() {
1227        return;
1228    }
1229    for s in suppressions {
1230        let severity = if s.missing_reason {
1231            rules.require_suppression_reason
1232        } else {
1233            rules.stale_suppressions
1234        };
1235        let level = finding_codeclimate(s, severity);
1236        let path = cc_path(&s.path, root);
1237        let line_str = s.line.to_string();
1238        let check_name = if s.missing_reason {
1239            "fallow/missing-suppression-reason"
1240        } else {
1241            "fallow/stale-suppression"
1242        };
1243        let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str]);
1244        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1245            check_name,
1246            description: &s.display_message(),
1247            severity: level,
1248            category: "Bug Risk",
1249            path: &path,
1250            begin_line: Some(s.line),
1251            fingerprint: &fp,
1252        }));
1253    }
1254}
1255
1256fn push_unused_catalog_entry_issues(
1257    issues: &mut Vec<CodeClimateIssue>,
1258    entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1259    root: &Path,
1260    severity: Severity,
1261) {
1262    if entries.is_empty() {
1263        return;
1264    }
1265    for entry in entries {
1266        let level = finding_codeclimate(entry, severity);
1267        let entry = &entry.entry;
1268        let path = cc_path(&entry.path, root);
1269        let line_str = entry.line.to_string();
1270        let fp = codeclimate_fingerprint_hash(&[
1271            "fallow/unused-catalog-entry",
1272            &path,
1273            &line_str,
1274            &entry.catalog_name,
1275            &entry.entry_name,
1276        ]);
1277        let description = if entry.catalog_name == "default" {
1278            format!(
1279                "Catalog entry '{}' is not referenced by any workspace package",
1280                entry.entry_name
1281            )
1282        } else {
1283            format!(
1284                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1285                entry.entry_name, entry.catalog_name
1286            )
1287        };
1288        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1289            check_name: "fallow/unused-catalog-entry",
1290            description: &description,
1291            severity: level,
1292            category: "Bug Risk",
1293            path: &path,
1294            begin_line: Some(entry.line),
1295            fingerprint: &fp,
1296        }));
1297    }
1298}
1299
1300fn push_unresolved_catalog_reference_issues(
1301    issues: &mut Vec<CodeClimateIssue>,
1302    findings: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1303    root: &Path,
1304    severity: Severity,
1305) {
1306    if findings.is_empty() {
1307        return;
1308    }
1309    for finding in findings {
1310        let level = finding_codeclimate(finding, severity);
1311        let finding = &finding.reference;
1312        let path = cc_path(&finding.path, root);
1313        let line_str = finding.line.to_string();
1314        let fp = codeclimate_fingerprint_hash(&[
1315            "fallow/unresolved-catalog-reference",
1316            &path,
1317            &line_str,
1318            &finding.catalog_name,
1319            &finding.entry_name,
1320        ]);
1321        let catalog_phrase = if finding.catalog_name == "default" {
1322            "the default catalog".to_string()
1323        } else {
1324            format!("catalog '{}'", finding.catalog_name)
1325        };
1326        let mut description = format!(
1327            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1328            finding.entry_name,
1329            if finding.catalog_name == "default" {
1330                ""
1331            } else {
1332                finding.catalog_name.as_str()
1333            },
1334            catalog_phrase,
1335        );
1336        if !finding.available_in_catalogs.is_empty() {
1337            use std::fmt::Write as _;
1338            let _ = write!(
1339                description,
1340                " (available in: {})",
1341                finding.available_in_catalogs.join(", ")
1342            );
1343        }
1344        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1345            check_name: "fallow/unresolved-catalog-reference",
1346            description: &description,
1347            severity: level,
1348            category: "Bug Risk",
1349            path: &path,
1350            begin_line: Some(finding.line),
1351            fingerprint: &fp,
1352        }));
1353    }
1354}
1355
1356fn push_empty_catalog_group_issues(
1357    issues: &mut Vec<CodeClimateIssue>,
1358    groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1359    root: &Path,
1360    severity: Severity,
1361) {
1362    if groups.is_empty() {
1363        return;
1364    }
1365    for group in groups {
1366        let level = finding_codeclimate(group, severity);
1367        let group = &group.group;
1368        let path = cc_path(&group.path, root);
1369        let line_str = group.line.to_string();
1370        let fp = codeclimate_fingerprint_hash(&[
1371            "fallow/empty-catalog-group",
1372            &path,
1373            &line_str,
1374            &group.catalog_name,
1375        ]);
1376        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1377            check_name: "fallow/empty-catalog-group",
1378            description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1379            severity: level,
1380            category: "Bug Risk",
1381            path: &path,
1382            begin_line: Some(group.line),
1383            fingerprint: &fp,
1384        }));
1385    }
1386}
1387
1388fn push_unused_dependency_override_issues(
1389    issues: &mut Vec<CodeClimateIssue>,
1390    findings: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1391    root: &Path,
1392    severity: Severity,
1393) {
1394    if findings.is_empty() {
1395        return;
1396    }
1397    for finding in findings {
1398        let level = finding_codeclimate(finding, severity);
1399        let finding = &finding.entry;
1400        let path = cc_path(&finding.path, root);
1401        let line_str = finding.line.to_string();
1402        let fp = codeclimate_fingerprint_hash(&[
1403            "fallow/unused-dependency-override",
1404            &path,
1405            &line_str,
1406            finding.source.as_label(),
1407            &finding.raw_key,
1408        ]);
1409        let mut description = format!(
1410            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1411            finding.raw_key, finding.version_range, finding.target_package,
1412        );
1413        if let Some(hint) = &finding.hint {
1414            use std::fmt::Write as _;
1415            let _ = write!(description, " ({hint})");
1416        }
1417        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1418            check_name: "fallow/unused-dependency-override",
1419            description: &description,
1420            severity: level,
1421            category: "Bug Risk",
1422            path: &path,
1423            begin_line: Some(finding.line),
1424            fingerprint: &fp,
1425        }));
1426    }
1427}
1428
1429fn push_misconfigured_dependency_override_issues(
1430    issues: &mut Vec<CodeClimateIssue>,
1431    findings: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1432    root: &Path,
1433    severity: Severity,
1434) {
1435    if findings.is_empty() {
1436        return;
1437    }
1438    for finding in findings {
1439        let level = finding_codeclimate(finding, severity);
1440        let finding = &finding.entry;
1441        let path = cc_path(&finding.path, root);
1442        let line_str = finding.line.to_string();
1443        let fp = codeclimate_fingerprint_hash(&[
1444            "fallow/misconfigured-dependency-override",
1445            &path,
1446            &line_str,
1447            finding.source.as_label(),
1448            &finding.raw_key,
1449        ]);
1450        let description = format!(
1451            "Override `{}` -> `{}` is malformed: {}",
1452            finding.raw_key,
1453            finding.raw_value,
1454            finding.reason.describe(),
1455        );
1456        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
1457            check_name: "fallow/misconfigured-dependency-override",
1458            description: &description,
1459            severity: level,
1460            category: "Bug Risk",
1461            path: &path,
1462            begin_line: Some(finding.line),
1463            fingerprint: &fp,
1464        }));
1465    }
1466}
1467
1468/// Build CodeClimate issues from dead-code analysis results.
1469///
1470/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1471/// shape convert via [`fallow_output::codeclimate_issues_to_value`]. The schema
1472/// drift gate locks the per-issue shape against
1473/// [`fallow_output::CodeClimateOutput`].
1474#[must_use]
1475pub fn build_codeclimate(
1476    results: &AnalysisResults,
1477    root: &Path,
1478    rules: &RulesConfig,
1479) -> Vec<CodeClimateIssue> {
1480    CodeClimateBuilder {
1481        issues: Vec::new(),
1482        results,
1483        root,
1484        rules,
1485    }
1486    .build()
1487}
1488
1489struct CodeClimateBuilder<'a> {
1490    issues: Vec<CodeClimateIssue>,
1491    results: &'a AnalysisResults,
1492    root: &'a Path,
1493    rules: &'a RulesConfig,
1494}
1495
1496impl CodeClimateBuilder<'_> {
1497    fn build(mut self) -> Vec<CodeClimateIssue> {
1498        self.push_file_and_export_issues();
1499        self.push_private_type_leak_issues();
1500        push_deprecated_export_issues(
1501            &mut self.issues,
1502            &self.results.deprecated_exports_in_use,
1503            self.root,
1504            self.rules.deprecated_exports_in_use,
1505        );
1506        self.push_package_dependency_issues();
1507        self.push_type_test_dependency_issues();
1508        self.push_member_issues();
1509        self.push_import_and_duplicate_issues();
1510        self.push_graph_issues();
1511        self.push_boundary_issues();
1512        self.push_suppression_and_catalog_issues();
1513        self.push_override_issues();
1514        self.issues
1515    }
1516
1517    fn push_file_and_export_issues(&mut self) {
1518        push_unused_file_issues(
1519            &mut self.issues,
1520            &self.results.unused_files,
1521            self.root,
1522            self.rules.unused_files,
1523        );
1524        push_unused_export_issues(UnusedExportIssuesInput {
1525            issues: &mut self.issues,
1526            exports: self.results.unused_exports.iter().map(|e| {
1527                (
1528                    &e.export,
1529                    e.reachability_caveats.as_slice(),
1530                    e.effective_severity,
1531                )
1532            }),
1533            root: self.root,
1534            rule_id: "fallow/unused-export",
1535            direct_label: "Export",
1536            re_export_label: "Re-export",
1537            severity: self.rules.unused_exports,
1538        });
1539        push_unused_export_issues(UnusedExportIssuesInput {
1540            issues: &mut self.issues,
1541            exports: self.results.unused_types.iter().map(|e| {
1542                (
1543                    &e.export,
1544                    e.reachability_caveats.as_slice(),
1545                    e.effective_severity,
1546                )
1547            }),
1548            root: self.root,
1549            rule_id: "fallow/unused-type",
1550            direct_label: "Type export",
1551            re_export_label: "Type re-export",
1552            severity: self.rules.unused_types,
1553        });
1554    }
1555
1556    fn push_private_type_leak_issues(&mut self) {
1557        push_private_type_leak_issues(
1558            &mut self.issues,
1559            &self.results.private_type_leaks,
1560            self.root,
1561            self.rules.private_type_leaks,
1562        );
1563    }
1564
1565    fn push_package_dependency_issues(&mut self) {
1566        push_dep_cc_issues(
1567            &mut self.issues,
1568            self.results.unused_dependencies.iter().map(|f| {
1569                (
1570                    &f.dep,
1571                    f.reachability_caveats.as_slice(),
1572                    f.effective_severity,
1573                )
1574            }),
1575            self.root,
1576            "fallow/unused-dependency",
1577            "dependencies",
1578            self.rules.unused_dependencies,
1579        );
1580        push_dep_cc_issues(
1581            &mut self.issues,
1582            self.results.unused_dev_dependencies.iter().map(|f| {
1583                (
1584                    &f.dep,
1585                    f.reachability_caveats.as_slice(),
1586                    f.effective_severity,
1587                )
1588            }),
1589            self.root,
1590            "fallow/unused-dev-dependency",
1591            "devDependencies",
1592            self.rules.unused_dev_dependencies,
1593        );
1594        push_dep_cc_issues(
1595            &mut self.issues,
1596            self.results.unused_optional_dependencies.iter().map(|f| {
1597                (
1598                    &f.dep,
1599                    f.reachability_caveats.as_slice(),
1600                    f.effective_severity,
1601                )
1602            }),
1603            self.root,
1604            "fallow/unused-optional-dependency",
1605            "optionalDependencies",
1606            self.rules.unused_optional_dependencies,
1607        );
1608    }
1609
1610    fn push_type_test_dependency_issues(&mut self) {
1611        push_type_only_dep_issues(
1612            &mut self.issues,
1613            &self.results.type_only_dependencies,
1614            self.root,
1615            self.rules.type_only_dependencies,
1616        );
1617        push_test_only_dep_issues(
1618            &mut self.issues,
1619            &self.results.test_only_dependencies,
1620            self.root,
1621            self.rules.test_only_dependencies,
1622        );
1623        push_dev_dep_in_prod_issues(
1624            &mut self.issues,
1625            &self.results.dev_dependencies_in_production,
1626            self.root,
1627            self.rules.dev_dependencies_in_production,
1628        );
1629    }
1630
1631    fn push_member_issues(&mut self) {
1632        push_unused_member_issues(
1633            &mut self.issues,
1634            self.results.unused_enum_members.iter().map(|m| {
1635                (
1636                    &m.member,
1637                    m.reachability_caveats.as_slice(),
1638                    m.effective_severity,
1639                )
1640            }),
1641            self.root,
1642            "fallow/unused-enum-member",
1643            "Enum",
1644            self.rules.unused_enum_members,
1645        );
1646        push_unused_member_issues(
1647            &mut self.issues,
1648            self.results.unused_class_members.iter().map(|m| {
1649                (
1650                    &m.member,
1651                    m.reachability_caveats.as_slice(),
1652                    m.effective_severity,
1653                )
1654            }),
1655            self.root,
1656            "fallow/unused-class-member",
1657            "Class",
1658            self.rules.unused_class_members,
1659        );
1660        push_unused_member_issues(
1661            &mut self.issues,
1662            self.results.unused_store_members.iter().map(|m| {
1663                (
1664                    &m.member,
1665                    m.reachability_caveats.as_slice(),
1666                    m.effective_severity,
1667                )
1668            }),
1669            self.root,
1670            "fallow/unused-store-member",
1671            "Store",
1672            self.rules.unused_store_members,
1673        );
1674    }
1675
1676    fn push_import_and_duplicate_issues(&mut self) {
1677        push_unresolved_import_issues(
1678            &mut self.issues,
1679            &self.results.unresolved_imports,
1680            self.root,
1681            self.rules.unresolved_imports,
1682        );
1683        push_unlisted_dep_issues(
1684            &mut self.issues,
1685            &self.results.unlisted_dependencies,
1686            self.root,
1687            self.rules.unlisted_dependencies,
1688        );
1689        push_duplicate_export_issues(
1690            &mut self.issues,
1691            &self.results.duplicate_exports,
1692            self.root,
1693            self.rules.duplicate_exports,
1694        );
1695    }
1696
1697    fn push_graph_issues(&mut self) {
1698        push_circular_dep_issues(
1699            &mut self.issues,
1700            &self.results.circular_dependencies,
1701            self.root,
1702            self.rules.circular_dependencies,
1703        );
1704        push_re_export_cycle_issues(
1705            &mut self.issues,
1706            &self.results.re_export_cycles,
1707            self.root,
1708            self.rules.re_export_cycle,
1709        );
1710    }
1711
1712    fn push_boundary_issues(&mut self) {
1713        self.push_architecture_boundary_issues();
1714        self.push_client_server_boundary_issues();
1715        self.push_component_boundary_issues();
1716        self.push_framework_route_issues();
1717    }
1718
1719    fn push_architecture_boundary_issues(&mut self) {
1720        push_boundary_violation_issues(
1721            &mut self.issues,
1722            &self.results.boundary_violations,
1723            self.root,
1724            self.rules.boundary_violation,
1725        );
1726        push_boundary_coverage_issues(
1727            &mut self.issues,
1728            &self.results.boundary_coverage_violations,
1729            self.root,
1730            self.rules.boundary_violation,
1731        );
1732        push_boundary_call_issues(
1733            &mut self.issues,
1734            &self.results.boundary_call_violations,
1735            self.root,
1736            self.rules.boundary_violation,
1737        );
1738        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1739    }
1740
1741    fn push_client_server_boundary_issues(&mut self) {
1742        push_invalid_client_export_issues(
1743            &mut self.issues,
1744            &self.results.invalid_client_exports,
1745            self.root,
1746            self.rules.invalid_client_export,
1747        );
1748        push_mixed_client_server_barrel_issues(
1749            &mut self.issues,
1750            &self.results.mixed_client_server_barrels,
1751            self.root,
1752            self.rules.mixed_client_server_barrel,
1753        );
1754        push_misplaced_directive_issues(
1755            &mut self.issues,
1756            &self.results.misplaced_directives,
1757            self.root,
1758            self.rules.misplaced_directive,
1759        );
1760    }
1761
1762    fn push_component_boundary_issues(&mut self) {
1763        push_unprovided_inject_issues(
1764            &mut self.issues,
1765            &self.results.unprovided_injects,
1766            self.root,
1767            self.rules.unprovided_injects,
1768        );
1769        push_unrendered_component_issues(
1770            &mut self.issues,
1771            &self.results.unrendered_components,
1772            self.root,
1773            self.rules.unrendered_components,
1774        );
1775        push_unused_component_prop_issues(
1776            &mut self.issues,
1777            &self.results.unused_component_props,
1778            self.root,
1779            self.rules.unused_component_props,
1780        );
1781        push_unused_component_emit_issues(
1782            &mut self.issues,
1783            &self.results.unused_component_emits,
1784            self.root,
1785            self.rules.unused_component_emits,
1786        );
1787        push_unused_component_input_issues(
1788            &mut self.issues,
1789            &self.results.unused_component_inputs,
1790            self.root,
1791            self.rules.unused_component_inputs,
1792        );
1793        push_unused_component_output_issues(
1794            &mut self.issues,
1795            &self.results.unused_component_outputs,
1796            self.root,
1797            self.rules.unused_component_outputs,
1798        );
1799        push_unused_svelte_event_issues(
1800            &mut self.issues,
1801            &self.results.unused_svelte_events,
1802            self.root,
1803            self.rules.unused_svelte_events,
1804        );
1805    }
1806
1807    fn push_framework_route_issues(&mut self) {
1808        push_unused_server_action_issues(
1809            &mut self.issues,
1810            &self.results.unused_server_actions,
1811            self.root,
1812            self.rules.unused_server_actions,
1813        );
1814        push_unused_load_data_key_issues(
1815            &mut self.issues,
1816            &self.results.unused_load_data_keys,
1817            self.root,
1818            self.rules.unused_load_data_keys,
1819        );
1820        push_route_collision_issues(
1821            &mut self.issues,
1822            &self.results.route_collisions,
1823            self.root,
1824            self.rules.route_collision,
1825        );
1826        push_dynamic_segment_name_conflict_issues(
1827            &mut self.issues,
1828            &self.results.dynamic_segment_name_conflicts,
1829            self.root,
1830            self.rules.dynamic_segment_name_conflict,
1831        );
1832    }
1833
1834    fn push_suppression_and_catalog_issues(&mut self) {
1835        push_stale_suppression_issues(
1836            &mut self.issues,
1837            &self.results.stale_suppressions,
1838            self.root,
1839            self.rules,
1840        );
1841        push_unused_catalog_entry_issues(
1842            &mut self.issues,
1843            &self.results.unused_catalog_entries,
1844            self.root,
1845            self.rules.unused_catalog_entries,
1846        );
1847        push_empty_catalog_group_issues(
1848            &mut self.issues,
1849            &self.results.empty_catalog_groups,
1850            self.root,
1851            self.rules.empty_catalog_groups,
1852        );
1853        push_unresolved_catalog_reference_issues(
1854            &mut self.issues,
1855            &self.results.unresolved_catalog_references,
1856            self.root,
1857            self.rules.unresolved_catalog_references,
1858        );
1859    }
1860
1861    fn push_override_issues(&mut self) {
1862        push_unused_dependency_override_issues(
1863            &mut self.issues,
1864            &self.results.unused_dependency_overrides,
1865            self.root,
1866            self.rules.unused_dependency_overrides,
1867        );
1868        push_misconfigured_dependency_override_issues(
1869            &mut self.issues,
1870            &self.results.misconfigured_dependency_overrides,
1871            self.root,
1872            self.rules.misconfigured_dependency_overrides,
1873        );
1874    }
1875}
1876
1877#[cfg(test)]
1878mod tests {
1879    use std::collections::BTreeSet;
1880
1881    use fallow_output::issue_output_contracts;
1882
1883    fn codeclimate_check_name_literals() -> BTreeSet<String> {
1884        let source = include_str!("dead_code_codeclimate.rs")
1885            .split("#[cfg(test)]")
1886            .next()
1887            .expect("source before tests");
1888        let mut literals = BTreeSet::new();
1889        let mut rest = source;
1890        while let Some(start) = rest.find("\"fallow/") {
1891            let after_quote = &rest[start + 1..];
1892            let Some(end) = after_quote.find('"') else {
1893                break;
1894            };
1895            literals.insert(after_quote[..end].to_owned());
1896            rest = &after_quote[end + 1..];
1897        }
1898        literals
1899    }
1900
1901    #[test]
1902    fn codeclimate_check_names_match_issue_contracts() {
1903        let from_emitter = codeclimate_check_name_literals();
1904        let from_contracts = issue_output_contracts()
1905            .flat_map(|contract| contract.codeclimate_check_names)
1906            .collect::<BTreeSet<_>>();
1907
1908        assert_eq!(from_emitter, from_contracts);
1909    }
1910
1911    mod caveats {
1912        use std::path::{Path, PathBuf};
1913
1914        use fallow_config::RulesConfig;
1915        use fallow_types::extract::MemberKind;
1916        use fallow_types::output_dead_code::{
1917            ReachabilityCaveat, UnusedClassMemberFinding, UnusedDependencyFinding,
1918            UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
1919            UnusedStoreMemberFinding,
1920        };
1921        use fallow_types::results::{
1922            AnalysisResults, DependencyLocation, UnusedDependency, UnusedExport, UnusedFile,
1923            UnusedMember,
1924        };
1925
1926        use crate::dead_code_codeclimate::build_codeclimate;
1927
1928        /// One finding of each caveated kind, caveated or not.
1929        fn results_with(root: &Path, caveated: bool) -> AnalysisResults {
1930            let caveats = if caveated {
1931                vec![ReachabilityCaveat::IncompleteImportGraph]
1932            } else {
1933                Vec::new()
1934            };
1935            let mut results = AnalysisResults::default();
1936
1937            let mut file = UnusedFileFinding::with_actions(UnusedFile {
1938                path: root.join("src/lib.ts"),
1939            });
1940            file.reachability_caveats.clone_from(&caveats);
1941            results.unused_files.push(file);
1942
1943            let mut export = UnusedExportFinding::with_actions(UnusedExport {
1944                path: root.join("src/lib.ts"),
1945                export_name: "needed".to_owned(),
1946                is_type_only: false,
1947                line: 3,
1948                col: 0,
1949                span_start: 0,
1950                is_re_export: false,
1951                deprecated: false,
1952                deprecated_reason: None,
1953            });
1954            export.reachability_caveats.clone_from(&caveats);
1955            results.unused_exports.push(export);
1956
1957            let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
1958                package_name: "left-pad".to_owned(),
1959                location: DependencyLocation::Dependencies,
1960                path: root.join("package.json"),
1961                line: 5,
1962                used_in_workspaces: Vec::new(),
1963            });
1964            dep.reachability_caveats.clone_from(&caveats);
1965            results.unused_dependencies.push(dep);
1966
1967            let member = |parent: &str, name: &str, kind| UnusedMember {
1968                path: root.join("src/lib.ts"),
1969                parent_name: parent.to_owned(),
1970                member_name: name.to_owned(),
1971                kind,
1972                line: 7,
1973                col: 2,
1974            };
1975
1976            let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
1977                "Mode",
1978                "Legacy",
1979                MemberKind::EnumMember,
1980            ));
1981            enum_member.reachability_caveats.clone_from(&caveats);
1982            results.unused_enum_members.push(enum_member);
1983
1984            let mut class_member = UnusedClassMemberFinding::with_actions(member(
1985                "Widget",
1986                "render",
1987                MemberKind::ClassMethod,
1988            ));
1989            class_member.reachability_caveats.clone_from(&caveats);
1990            results.unused_class_members.push(class_member);
1991
1992            let mut store_member = UnusedStoreMemberFinding::with_actions(member(
1993                "useCart",
1994                "subtotal",
1995                MemberKind::StoreMember,
1996            ));
1997            store_member.reachability_caveats.clone_from(&caveats);
1998            results.unused_store_members.push(store_member);
1999
2000            results
2001        }
2002
2003        /// `description` is the field GitLab renders inline on the MR diff, and
2004        /// the field `CiIssue` carries into the PR-comment and review-comment
2005        /// bodies that offer the mutation. A verdict resting on a file the run
2006        /// never read has to say so there.
2007        #[test]
2008        fn descriptions_name_the_caveat() {
2009            let root = PathBuf::from("/project");
2010
2011            let issues =
2012                build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2013
2014            let descriptions: Vec<&str> = issues
2015                .iter()
2016                .map(|issue| issue.description.as_str())
2017                .collect();
2018            assert!(
2019                descriptions
2020                    .iter()
2021                    .all(|description| description.ends_with(" (caveat: incomplete import graph)")),
2022                "every caveated finding hedges its description: {descriptions:?}"
2023            );
2024            assert!(
2025                descriptions.contains(
2026                    &"File is not reachable from any entry point (caveat: incomplete import graph)"
2027                ),
2028                "{descriptions:?}"
2029            );
2030        }
2031
2032        /// A clean run must stay byte-identical, so an integrator diffing
2033        /// reports across versions sees no churn from a mechanism that did not
2034        /// fire.
2035        #[test]
2036        fn a_clean_run_carries_no_caveat_text() {
2037            let root = PathBuf::from("/project");
2038
2039            let issues =
2040                build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2041
2042            assert!(
2043                issues
2044                    .iter()
2045                    .all(|issue| !issue.description.contains("caveat")),
2046                "{:?}",
2047                issues
2048                    .iter()
2049                    .map(|issue| issue.description.as_str())
2050                    .collect::<Vec<_>>()
2051            );
2052        }
2053
2054        /// The fingerprint is GitLab's and the review layer's comment identity.
2055        /// It is computed from rule id plus location, never from the message,
2056        /// so gaining a caveat must not reopen a resolved comment thread.
2057        #[test]
2058        fn the_caveat_does_not_move_the_fingerprint() {
2059            let root = PathBuf::from("/project");
2060
2061            let clean =
2062                build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2063            let caveated =
2064                build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2065
2066            let fingerprints = |issues: &[fallow_output::CodeClimateIssue]| {
2067                issues
2068                    .iter()
2069                    .map(|issue| issue.fingerprint.clone())
2070                    .collect::<Vec<_>>()
2071            };
2072            assert_eq!(fingerprints(&clean), fingerprints(&caveated));
2073            assert_ne!(
2074                clean[0].description, caveated[0].description,
2075                "the guard is only meaningful while the description actually changed"
2076            );
2077        }
2078    }
2079}