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