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::identity::dead_code_finding_id;
11use fallow_types::output_dead_code::{
12    EffectiveSeverity, GatedFinding, ReachabilityCaveat, caveat_suffix,
13};
14use fallow_types::results::AnalysisResults;
15
16fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
17    match s {
18        Severity::Error => CodeClimateSeverity::Major,
19        Severity::Warn => CodeClimateSeverity::Minor,
20        Severity::Off => unreachable!(),
21    }
22}
23
24/// The CodeClimate severity for one finding: its gate severity when the
25/// finding carries one, otherwise the configured rule severity (a saved report
26/// from an older version has no gate severity).
27///
28/// A saved finding exists, so its rule was on when it was reported. When the
29/// config at render time sets the rule `off`, the severity is `minor`.
30fn gate_codeclimate(effective: Option<EffectiveSeverity>, rule: Severity) -> CodeClimateSeverity {
31    match effective {
32        Some(EffectiveSeverity::Error) => CodeClimateSeverity::Major,
33        Some(EffectiveSeverity::Warn) => CodeClimateSeverity::Minor,
34        None => match rule {
35            Severity::Off => CodeClimateSeverity::Minor,
36            Severity::Error | Severity::Warn => severity_to_codeclimate(rule),
37        },
38    }
39}
40
41fn finding_codeclimate(finding: &impl GatedFinding, rule: Severity) -> CodeClimateSeverity {
42    gate_codeclimate(finding.effective_severity(), rule)
43}
44
45fn cc_path(path: &Path, root: &Path) -> String {
46    normalize_uri(
47        &path
48            .strip_prefix(root)
49            .unwrap_or(path)
50            .display()
51            .to_string(),
52    )
53}
54
55/// The caveat parenthetical a CodeClimate `description` ends with, or an empty
56/// string when the verdict rests on a fully analyzed run.
57///
58/// `description` is the one field GitLab renders inline on the MR diff, and it
59/// is also what `CiIssue` carries into the PR-comment and review-comment
60/// bodies. Those bodies offer a mutation, so the sentence that offers it has to
61/// say when the evidence behind it is incomplete. The suffix is the same
62/// parenthetical the human report and the SARIF message already use, so one
63/// finding reads identically across every surface.
64///
65/// Deliberately NOT part of the fingerprint: `codeclimate_fingerprint_hash` is
66/// fed rule id plus location by every call site in this file, never the
67/// description, so a finding that gains or loses a caveat keeps its identity
68/// and no previously resolved review thread reopens.
69fn cc_caveat_suffix(caveats: &[ReachabilityCaveat]) -> String {
70    caveat_suffix(caveats).unwrap_or_default()
71}
72
73/// Build one dead-code issue with its stable fingerprint.
74///
75/// `input.fingerprint` is the legacy fingerprint: the value that Fallow gave
76/// this issue before findings had a `finding_id`. For most kinds it holds the
77/// line, so a line shift above the finding changed it.
78///
79/// When the finding has a `finding_id`, the fingerprint is the FNV-1a 64 hash
80/// of that id plus `discriminator`, as 16 lowercase hex digits. The id holds
81/// no line and no column, so a line shift keeps the fingerprint. The hash
82/// covers the full id, not only the hex part of the id, so the `~k` tiebreak
83/// suffix keeps two findings with the same base id apart. The 16-hex form is
84/// the form that GitLab Code Quality and the review marker regex accept.
85/// `discriminator` separates the issues of one finding that emits one issue
86/// per location (duplicate exports, unlisted-dependency import sites). See
87/// [`location_discriminators`].
88///
89/// A finding without an id (a saved report from an older version) keeps the
90/// legacy fingerprint, so its review threads stay matched.
91fn dead_code_issue(
92    finding_id: Option<&str>,
93    discriminator: &[&str],
94    input: CodeClimateIssueInput<'_>,
95) -> CodeClimateIssue {
96    let mut issue = build_codeclimate_issue(input);
97    if let Some(id) = finding_id {
98        let mut parts = Vec::with_capacity(discriminator.len() + 1);
99        parts.push(id);
100        parts.extend_from_slice(discriminator);
101        let stable = codeclimate_fingerprint_hash(&parts);
102        if stable != issue.fingerprint {
103            issue.legacy_fingerprint = Some(std::mem::replace(&mut issue.fingerprint, stable));
104        }
105    }
106    issue
107}
108
109/// The discriminator of each location of one finding, in input order.
110///
111/// A location is named by what it is, not by where it is. The only content a
112/// location carries next to the finding id is its path: the engine reports
113/// one unlisted-dependency import site per file, and every location of a
114/// duplicate export has the same export name, which the id already holds. So
115/// the discriminator is the path. Only locations with the same path (the same
116/// content) also get a position: the first by line keeps the bare path, the
117/// next one gets `~1`, and so on. A location added to or removed from another
118/// file, or after this one in the same file, never changes it.
119fn location_discriminators(locations: &[(String, u32, u32)]) -> Vec<Vec<String>> {
120    locations
121        .iter()
122        .enumerate()
123        .map(|(index, (path, line, col))| {
124            let earlier = locations
125                .iter()
126                .enumerate()
127                .filter(|(other, (other_path, other_line, other_col))| {
128                    other_path == path && (other_line, other_col, *other) < (line, col, index)
129                })
130                .count();
131            if earlier == 0 {
132                vec![path.clone()]
133            } else {
134                vec![path.clone(), format!("~{earlier}")]
135            }
136        })
137        .collect()
138}
139
140/// Push CodeClimate issues for unused dependencies with a shared structure.
141fn push_dep_cc_issues<'a, I>(
142    issues: &mut Vec<CodeClimateIssue>,
143    deps: I,
144    root: &Path,
145    rule_id: &str,
146    location_label: &str,
147    severity: Severity,
148) where
149    I: IntoIterator<
150        Item = (
151            &'a fallow_types::results::UnusedDependency,
152            &'a [ReachabilityCaveat],
153            Option<EffectiveSeverity>,
154            Option<&'a str>,
155        ),
156    >,
157{
158    for (dep, caveats, effective, finding_id) in deps {
159        let level = gate_codeclimate(effective, severity);
160        let path = cc_path(&dep.path, root);
161        let line = if dep.line > 0 { Some(dep.line) } else { None };
162        let fp = codeclimate_fingerprint_hash(&[rule_id, &dep.package_name]);
163        let workspace_context = if dep.used_in_workspaces.is_empty() {
164            String::new()
165        } else {
166            let workspaces = dep
167                .used_in_workspaces
168                .iter()
169                .map(|path| cc_path(path, root))
170                .collect::<Vec<_>>()
171                .join(", ");
172            format!("; imported in other workspaces: {workspaces}")
173        };
174        issues.push(dead_code_issue(
175            finding_id,
176            &[],
177            CodeClimateIssueInput {
178                check_name: rule_id,
179                description: &format!(
180                    "Package '{}' is in {location_label} but never imported{workspace_context}{}",
181                    dep.package_name,
182                    cc_caveat_suffix(caveats)
183                ),
184                severity: level,
185                category: "Bug Risk",
186                path: &path,
187                begin_line: line,
188                fingerprint: &fp,
189            },
190        ));
191    }
192}
193
194fn push_unused_file_issues(
195    issues: &mut Vec<CodeClimateIssue>,
196    files: &[fallow_types::output_dead_code::UnusedFileFinding],
197    root: &Path,
198    severity: Severity,
199) {
200    if files.is_empty() {
201        return;
202    }
203    for entry in files {
204        let level = finding_codeclimate(entry, severity);
205        let path = cc_path(&entry.file.path, root);
206        let fp = codeclimate_fingerprint_hash(&["fallow/unused-file", &path]);
207        issues.push(dead_code_issue(
208            entry.finding_id.as_deref(),
209            &[],
210            CodeClimateIssueInput {
211                check_name: "fallow/unused-file",
212                description: &format!(
213                    "File is not reachable from any entry point{}",
214                    cc_caveat_suffix(&entry.reachability_caveats)
215                ),
216                severity: level,
217                category: "Bug Risk",
218                path: &path,
219                begin_line: None,
220                fingerprint: &fp,
221            },
222        ));
223    }
224}
225
226/// Push CodeClimate issues for unused exports or unused types.
227///
228/// `direct_label` / `re_export_label` let the same helper produce the right
229/// prose for both `unused-export` (Export / Re-export) and `unused-type`
230/// (Type export / Type re-export) rule ids.
231struct UnusedExportIssuesInput<'a, I> {
232    issues: &'a mut Vec<CodeClimateIssue>,
233    exports: I,
234    root: &'a Path,
235    rule_id: &'a str,
236    direct_label: &'a str,
237    re_export_label: &'a str,
238    severity: Severity,
239}
240
241fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
242where
243    I: IntoIterator<
244        Item = (
245            &'a fallow_types::results::UnusedExport,
246            &'a [ReachabilityCaveat],
247            Option<EffectiveSeverity>,
248            Option<&'a str>,
249        ),
250    >,
251{
252    for (export, caveats, effective, finding_id) in input.exports {
253        let level = gate_codeclimate(effective, input.severity);
254        let path = cc_path(&export.path, input.root);
255        let kind = if export.is_re_export {
256            input.re_export_label
257        } else {
258            input.direct_label
259        };
260        let line_str = export.line.to_string();
261        let fp =
262            codeclimate_fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
263        input.issues.push(dead_code_issue(
264            finding_id,
265            &[],
266            CodeClimateIssueInput {
267                check_name: input.rule_id,
268                description: &format!(
269                    "{kind} '{}' is never imported by other modules{}",
270                    export.export_name,
271                    cc_caveat_suffix(caveats)
272                ),
273                severity: level,
274                category: "Bug Risk",
275                path: &path,
276                begin_line: Some(export.line),
277                fingerprint: &fp,
278            },
279        ));
280    }
281}
282
283fn push_private_type_leak_issues(
284    issues: &mut Vec<CodeClimateIssue>,
285    leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
286    root: &Path,
287    severity: Severity,
288) {
289    if leaks.is_empty() {
290        return;
291    }
292    for entry in leaks {
293        let level = finding_codeclimate(entry, severity);
294        let leak = &entry.leak;
295        let path = cc_path(&leak.path, root);
296        let line_str = leak.line.to_string();
297        let fp = codeclimate_fingerprint_hash(&[
298            "fallow/private-type-leak",
299            &path,
300            &line_str,
301            &leak.export_name,
302            &leak.type_name,
303        ]);
304        issues.push(dead_code_issue(
305            entry.finding_id.as_deref(),
306            &[],
307            CodeClimateIssueInput {
308                check_name: "fallow/private-type-leak",
309                description: &format!(
310                    "Export '{}' references private type '{}'",
311                    leak.export_name, leak.type_name
312                ),
313                severity: level,
314                category: "Bug Risk",
315                path: &path,
316                begin_line: Some(leak.line),
317                fingerprint: &fp,
318            },
319        ));
320    }
321}
322
323fn push_deprecated_export_issues(
324    issues: &mut Vec<CodeClimateIssue>,
325    findings: &[fallow_types::output_dead_code::DeprecatedExportInUseFinding],
326    root: &Path,
327    severity: Severity,
328) {
329    for entry in findings {
330        let level = finding_codeclimate(entry, severity);
331        let export = &entry.export;
332        let path = cc_path(&export.path, root);
333        let line_str = export.line.to_string();
334        let fp = codeclimate_fingerprint_hash(&[
335            "fallow/deprecated-export-in-use",
336            &path,
337            &line_str,
338            &export.export_name,
339        ]);
340        issues.push(dead_code_issue(
341            entry.finding_id.as_deref(),
342            &[],
343            CodeClimateIssueInput {
344                check_name: "fallow/deprecated-export-in-use",
345                description: &export.description(),
346                severity: level,
347                category: "Compatibility",
348                path: &path,
349                begin_line: Some(export.line),
350                fingerprint: &fp,
351            },
352        ));
353    }
354}
355
356fn push_type_only_dep_issues(
357    issues: &mut Vec<CodeClimateIssue>,
358    deps: &[fallow_types::output_dead_code::TypeOnlyDependencyFinding],
359    root: &Path,
360    severity: Severity,
361) {
362    if deps.is_empty() {
363        return;
364    }
365    for entry in deps {
366        let level = finding_codeclimate(entry, severity);
367        let dep = &entry.dep;
368        let path = cc_path(&dep.path, root);
369        let line = if dep.line > 0 { Some(dep.line) } else { None };
370        let fp = codeclimate_fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
371        issues.push(dead_code_issue(
372            entry.finding_id.as_deref(),
373            &[],
374            CodeClimateIssueInput {
375                check_name: "fallow/type-only-dependency",
376                description: &format!(
377                    "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
378                    dep.package_name
379                ),
380                severity: level,
381                category: "Bug Risk",
382                path: &path,
383                begin_line: line,
384                fingerprint: &fp,
385            },
386        ));
387    }
388}
389
390fn push_test_only_dep_issues(
391    issues: &mut Vec<CodeClimateIssue>,
392    deps: &[fallow_types::output_dead_code::TestOnlyDependencyFinding],
393    root: &Path,
394    severity: Severity,
395) {
396    if deps.is_empty() {
397        return;
398    }
399    for entry in deps {
400        let level = finding_codeclimate(entry, severity);
401        let dep = &entry.dep;
402        let path = cc_path(&dep.path, root);
403        let line = if dep.line > 0 { Some(dep.line) } else { None };
404        let fp = codeclimate_fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
405        issues.push(dead_code_issue(
406            entry.finding_id.as_deref(),
407            &[],
408            CodeClimateIssueInput {
409                check_name: "fallow/test-only-dependency",
410                description: &format!(
411                    "Package '{}' is only imported by test files (consider moving to devDependencies)",
412                    dep.package_name
413                ),
414                severity: level,
415                category: "Bug Risk",
416                path: &path,
417                begin_line: line,
418                fingerprint: &fp,
419            },
420        ));
421    }
422}
423
424fn push_dev_dep_in_prod_issues(
425    issues: &mut Vec<CodeClimateIssue>,
426    deps: &[fallow_types::output_dead_code::DevDependencyInProductionFinding],
427    root: &Path,
428    severity: Severity,
429) {
430    if deps.is_empty() {
431        return;
432    }
433    for entry in deps {
434        let level = finding_codeclimate(entry, severity);
435        let dep = &entry.dep;
436        let path = cc_path(&dep.path, root);
437        let line = if dep.line > 0 { Some(dep.line) } else { None };
438        let fp = codeclimate_fingerprint_hash(&[
439            "fallow/dev-dependency-in-production",
440            &dep.package_name,
441        ]);
442        issues.push(dead_code_issue(
443            entry.finding_id.as_deref(),
444            &[],
445            CodeClimateIssueInput {
446                check_name: "fallow/dev-dependency-in-production",
447                description: &format!(
448                    "devDependency '{}' is imported by production code at runtime (consider moving to dependencies)",
449                    dep.package_name
450                ),
451                severity: level,
452                category: "Bug Risk",
453                path: &path,
454                begin_line: line,
455                fingerprint: &fp,
456            },
457        ));
458    }
459}
460
461/// Push CodeClimate issues for unused enum or class members.
462///
463/// `entity_label` is `"Enum"` or `"Class"` so the rendered description reads
464/// "Enum member ..." or "Class member ..." accordingly.
465fn push_unused_member_issues<'a, I>(
466    issues: &mut Vec<CodeClimateIssue>,
467    members: I,
468    root: &Path,
469    rule_id: &str,
470    entity_label: &str,
471    severity: Severity,
472) where
473    I: IntoIterator<
474        Item = (
475            &'a fallow_types::results::UnusedMember,
476            &'a [ReachabilityCaveat],
477            Option<EffectiveSeverity>,
478            Option<&'a str>,
479        ),
480    >,
481{
482    for (member, caveats, effective, finding_id) in members {
483        let level = gate_codeclimate(effective, severity);
484        let path = cc_path(&member.path, root);
485        let line_str = member.line.to_string();
486        let fp = codeclimate_fingerprint_hash(&[
487            rule_id,
488            &path,
489            &line_str,
490            &member.parent_name,
491            &member.member_name,
492        ]);
493        issues.push(dead_code_issue(
494            finding_id,
495            &[],
496            CodeClimateIssueInput {
497                check_name: rule_id,
498                description: &format!(
499                    "{entity_label} member '{}.{}' is never referenced{}",
500                    member.parent_name,
501                    member.member_name,
502                    cc_caveat_suffix(caveats)
503                ),
504                severity: level,
505                category: "Bug Risk",
506                path: &path,
507                begin_line: Some(member.line),
508                fingerprint: &fp,
509            },
510        ));
511    }
512}
513
514fn push_unresolved_import_issues(
515    issues: &mut Vec<CodeClimateIssue>,
516    imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
517    root: &Path,
518    severity: Severity,
519) {
520    if imports.is_empty() {
521        return;
522    }
523    for entry in imports {
524        let level = finding_codeclimate(entry, severity);
525        let import = &entry.import;
526        let path = cc_path(&import.path, root);
527        let line_str = import.line.to_string();
528        let fp = codeclimate_fingerprint_hash(&[
529            "fallow/unresolved-import",
530            &path,
531            &line_str,
532            &import.specifier,
533        ]);
534        issues.push(dead_code_issue(
535            entry.finding_id.as_deref(),
536            &[],
537            CodeClimateIssueInput {
538                check_name: "fallow/unresolved-import",
539                description: &format!("Import '{}' could not be resolved", import.specifier),
540                severity: level,
541                category: "Bug Risk",
542                path: &path,
543                begin_line: Some(import.line),
544                fingerprint: &fp,
545            },
546        ));
547    }
548}
549
550fn push_unlisted_dep_issues(
551    issues: &mut Vec<CodeClimateIssue>,
552    deps: &[fallow_types::output_dead_code::UnlistedDependencyFinding],
553    root: &Path,
554    severity: Severity,
555) {
556    if deps.is_empty() {
557        return;
558    }
559    for entry in deps {
560        let level = finding_codeclimate(entry, severity);
561        let dep = &entry.dep;
562        let sites: Vec<(String, u32, u32)> = dep
563            .imported_from
564            .iter()
565            .map(|site| (cc_path(&site.path, root), site.line, site.col))
566            .collect();
567        let discriminators = location_discriminators(&sites);
568        for ((path, line, _), discriminator) in sites.iter().zip(&discriminators) {
569            let line_str = line.to_string();
570            let discriminator: Vec<&str> = discriminator.iter().map(String::as_str).collect();
571            let fp = codeclimate_fingerprint_hash(&[
572                "fallow/unlisted-dependency",
573                path,
574                &line_str,
575                &dep.package_name,
576            ]);
577            issues.push(dead_code_issue(
578                entry.finding_id.as_deref(),
579                &discriminator,
580                CodeClimateIssueInput {
581                    check_name: "fallow/unlisted-dependency",
582                    description: &format!(
583                        "Package '{}' is imported but not listed in package.json",
584                        dep.package_name
585                    ),
586                    severity: level,
587                    category: "Bug Risk",
588                    path,
589                    begin_line: Some(*line),
590                    fingerprint: &fp,
591                },
592            ));
593        }
594    }
595}
596
597fn push_duplicate_export_issues(
598    issues: &mut Vec<CodeClimateIssue>,
599    dups: &[fallow_types::output_dead_code::DuplicateExportFinding],
600    root: &Path,
601    severity: Severity,
602) {
603    if dups.is_empty() {
604        return;
605    }
606    for dup in dups {
607        let level = finding_codeclimate(dup, severity);
608        // The finding id of a duplicate export holds the set of its paths, so
609        // one more or one less location gives the finding a new id. Each
610        // issue is one location, so its fingerprint uses the part of the id
611        // that stays: the rule and the export name. The location discriminator
612        // adds the path. A saved finding without an id keeps the legacy
613        // fingerprint.
614        let location_identity = dup
615            .finding_id
616            .is_some()
617            .then(|| dead_code_finding_id("duplicate-export", &[&dup.export.export_name]));
618        let finding_id = location_identity.as_deref();
619        let dup = &dup.export;
620        let locations: Vec<(String, u32, u32)> = dup
621            .locations
622            .iter()
623            .map(|loc| (cc_path(&loc.path, root), loc.line, loc.col))
624            .collect();
625        let discriminators = location_discriminators(&locations);
626        for ((path, line, _), discriminator) in locations.iter().zip(&discriminators) {
627            let line_str = line.to_string();
628            let discriminator: Vec<&str> = discriminator.iter().map(String::as_str).collect();
629            let fp = codeclimate_fingerprint_hash(&[
630                "fallow/duplicate-export",
631                path,
632                &line_str,
633                &dup.export_name,
634            ]);
635            issues.push(dead_code_issue(
636                finding_id,
637                &discriminator,
638                CodeClimateIssueInput {
639                    check_name: "fallow/duplicate-export",
640                    description: &format!(
641                        "Export '{}' appears in multiple modules",
642                        dup.export_name
643                    ),
644                    severity: level,
645                    category: "Bug Risk",
646                    path,
647                    begin_line: Some(*line),
648                    fingerprint: &fp,
649                },
650            ));
651        }
652    }
653}
654
655fn push_circular_dep_issues(
656    issues: &mut Vec<CodeClimateIssue>,
657    cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
658    root: &Path,
659    severity: Severity,
660) {
661    if cycles.is_empty() {
662        return;
663    }
664    for entry in cycles {
665        let level = finding_codeclimate(entry, severity);
666        let cycle = &entry.cycle;
667        let Some(first) = cycle.files.first() else {
668            continue;
669        };
670        let path = cc_path(first, root);
671        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
672        let chain_str = chain.join(":");
673        let fp = codeclimate_fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
674        let line = if cycle.line > 0 {
675            Some(cycle.line)
676        } else {
677            None
678        };
679        issues.push(dead_code_issue(
680            entry.finding_id.as_deref(),
681            &[],
682            CodeClimateIssueInput {
683                check_name: "fallow/circular-dependency",
684                description: &format!(
685                    "Circular dependency{}: {}",
686                    if cycle.is_cross_package {
687                        " (cross-package)"
688                    } else {
689                        ""
690                    },
691                    chain.join(" \u{2192} ")
692                ),
693                severity: level,
694                category: "Bug Risk",
695                path: &path,
696                begin_line: line,
697                fingerprint: &fp,
698            },
699        ));
700    }
701}
702
703fn push_re_export_cycle_issues(
704    issues: &mut Vec<CodeClimateIssue>,
705    cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
706    root: &Path,
707    severity: Severity,
708) {
709    if cycles.is_empty() {
710        return;
711    }
712    for entry in cycles {
713        let level = finding_codeclimate(entry, severity);
714        let cycle = &entry.cycle;
715        let Some(first) = cycle.files.first() else {
716            continue;
717        };
718        let path = cc_path(first, root);
719        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
720        let chain_str = chain.join(":");
721        let kind_token = match cycle.kind {
722            fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
723            fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
724        };
725        let kind_tag = match cycle.kind {
726            fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
727            fallow_types::results::ReExportCycleKind::MultiNode => "",
728        };
729        let fp = codeclimate_fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
730        issues.push(dead_code_issue(
731            entry.finding_id.as_deref(),
732            &[],
733            CodeClimateIssueInput {
734                check_name: "fallow/re-export-cycle",
735                description: &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
736                severity: level,
737                category: "Bug Risk",
738                path: &path,
739                begin_line: None,
740                fingerprint: &fp,
741            },
742        ));
743    }
744}
745
746fn push_package_cycle_issues(
747    issues: &mut Vec<CodeClimateIssue>,
748    cycles: &[fallow_types::output_dead_code::PackageCycleFinding],
749    root: &Path,
750    severity: Severity,
751) {
752    for entry in cycles {
753        let level = finding_codeclimate(entry, severity);
754        let cycle = &entry.cycle;
755        let Some(anchor) = cycle.edges.first() else {
756            continue;
757        };
758        let path = cc_path(&anchor.path, root);
759        let packages = cycle.packages.join(":");
760        let fp = codeclimate_fingerprint_hash(&["fallow/package-cycle", &packages]);
761        let note = if cycle.group_truncated {
762            format!(
763                " ({})",
764                fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
765            )
766        } else {
767            String::new()
768        };
769        issues.push(build_codeclimate_issue(CodeClimateIssueInput {
770            check_name: "fallow/package-cycle",
771            description: &format!("Package cycle: {}{note}", cycle.chain(" \u{2192} ")),
772            severity: level,
773            category: "Bug Risk",
774            path: &path,
775            begin_line: (anchor.line > 0).then_some(anchor.line),
776            fingerprint: &fp,
777        }));
778    }
779}
780
781fn push_boundary_violation_issues(
782    issues: &mut Vec<CodeClimateIssue>,
783    violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
784    root: &Path,
785    severity: Severity,
786) {
787    if violations.is_empty() {
788        return;
789    }
790    for entry in violations {
791        let level = finding_codeclimate(entry, severity);
792        let v = &entry.violation;
793        let path = cc_path(&v.from_path, root);
794        let to = cc_path(&v.to_path, root);
795        let fp = codeclimate_fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
796        let line = if v.line > 0 { Some(v.line) } else { None };
797        let via = v
798            .via_path
799            .as_ref()
800            .map_or_else(String::new, |via| format!(", via {}", cc_path(via, root)));
801        issues.push(dead_code_issue(
802            entry.finding_id.as_deref(),
803            &[],
804            CodeClimateIssueInput {
805                check_name: "fallow/boundary-violation",
806                description: &format!(
807                    "Boundary violation: {} -> {} ({} -> {}{})",
808                    path, to, v.from_zone, v.to_zone, via
809                ),
810                severity: level,
811                category: "Bug Risk",
812                path: &path,
813                begin_line: line,
814                fingerprint: &fp,
815            },
816        ));
817    }
818}
819
820fn push_boundary_coverage_issues(
821    issues: &mut Vec<CodeClimateIssue>,
822    violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
823    root: &Path,
824    severity: Severity,
825) {
826    if violations.is_empty() {
827        return;
828    }
829    for entry in violations {
830        let level = finding_codeclimate(entry, severity);
831        let v = &entry.violation;
832        let path = cc_path(&v.path, root);
833        let fp = codeclimate_fingerprint_hash(&["fallow/boundary-coverage", &path]);
834        let line = if v.line > 0 { Some(v.line) } else { None };
835        issues.push(dead_code_issue(
836            entry.finding_id.as_deref(),
837            &[],
838            CodeClimateIssueInput {
839                check_name: "fallow/boundary-coverage",
840                description: &format!("Boundary coverage: {path} matches no configured zone"),
841                severity: level,
842                category: "Bug Risk",
843                path: &path,
844                begin_line: line,
845                fingerprint: &fp,
846            },
847        ));
848    }
849}
850
851fn push_boundary_call_issues(
852    issues: &mut Vec<CodeClimateIssue>,
853    violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
854    root: &Path,
855    severity: Severity,
856) {
857    if violations.is_empty() {
858        return;
859    }
860    for entry in violations {
861        let level = finding_codeclimate(entry, severity);
862        let v = &entry.violation;
863        let path = cc_path(&v.path, root);
864        let fp =
865            codeclimate_fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
866        let line = if v.line > 0 { Some(v.line) } else { None };
867        issues.push(dead_code_issue(
868            entry.finding_id.as_deref(),
869            &[],
870            CodeClimateIssueInput {
871                check_name: "fallow/boundary-call-violation",
872                description: &format!(
873                    "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
874                    v.callee, v.pattern, v.zone
875                ),
876                severity: level,
877                category: "Bug Risk",
878                path: &path,
879                begin_line: line,
880                fingerprint: &fp,
881            },
882        ));
883    }
884}
885
886fn push_policy_violation_issues(
887    issues: &mut Vec<CodeClimateIssue>,
888    violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
889    root: &Path,
890) {
891    use fallow_types::results::PolicyViolationSeverity;
892
893    for entry in violations {
894        let v = &entry.violation;
895        let path = cc_path(&v.path, root);
896        let rule = format!("{}/{}", v.pack, v.rule_id);
897        let fp =
898            codeclimate_fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
899        let line = if v.line > 0 { Some(v.line) } else { None };
900        // Severity comes from the EFFECTIVE per-finding value, not the
901        // policy-violation master, so a severity: "error" rule under a warn
902        // master maps to blocker-level just like the exit-code gate.
903        let level = severity_to_codeclimate(match v.severity {
904            PolicyViolationSeverity::Error => Severity::Error,
905            PolicyViolationSeverity::Warn => Severity::Warn,
906        });
907        let message = match &v.message {
908            Some(message) => format!(
909                "Policy violation: `{}` is banned by `{rule}`. {message}",
910                v.matched
911            ),
912            None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
913        };
914        issues.push(dead_code_issue(
915            entry.finding_id.as_deref(),
916            &[],
917            CodeClimateIssueInput {
918                check_name: "fallow/policy-violation",
919                description: &message,
920                severity: level,
921                category: "Bug Risk",
922                path: &path,
923                begin_line: line,
924                fingerprint: &fp,
925            },
926        ));
927    }
928}
929
930fn push_invalid_client_export_issues(
931    issues: &mut Vec<CodeClimateIssue>,
932    findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
933    root: &Path,
934    severity: Severity,
935) {
936    if findings.is_empty() {
937        return;
938    }
939    for entry in findings {
940        let level = finding_codeclimate(entry, severity);
941        let e = &entry.export;
942        let path = cc_path(&e.path, root);
943        let fp =
944            codeclimate_fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
945        let line = if e.line > 0 { Some(e.line) } else { None };
946        let message = format!(
947            "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
948            e.export_name, e.directive
949        );
950        issues.push(dead_code_issue(
951            entry.finding_id.as_deref(),
952            &[],
953            CodeClimateIssueInput {
954                check_name: "fallow/invalid-client-export",
955                description: &message,
956                severity: level,
957                category: "Bug Risk",
958                path: &path,
959                begin_line: line,
960                fingerprint: &fp,
961            },
962        ));
963    }
964}
965
966fn push_mixed_client_server_barrel_issues(
967    issues: &mut Vec<CodeClimateIssue>,
968    findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
969    root: &Path,
970    severity: Severity,
971) {
972    if findings.is_empty() {
973        return;
974    }
975    for entry in findings {
976        let level = finding_codeclimate(entry, severity);
977        let b = &entry.barrel;
978        let path = cc_path(&b.path, root);
979        let fp = codeclimate_fingerprint_hash(&[
980            "fallow/mixed-client-server-barrel",
981            &path,
982            &b.client_origin,
983            &b.server_origin,
984        ]);
985        let line = if b.line > 0 { Some(b.line) } else { None };
986        let message = format!(
987            "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
988            b.client_origin, b.server_origin
989        );
990        issues.push(dead_code_issue(
991            entry.finding_id.as_deref(),
992            &[],
993            CodeClimateIssueInput {
994                check_name: "fallow/mixed-client-server-barrel",
995                description: &message,
996                severity: level,
997                category: "Bug Risk",
998                path: &path,
999                begin_line: line,
1000                fingerprint: &fp,
1001            },
1002        ));
1003    }
1004}
1005
1006fn push_misplaced_directive_issues(
1007    issues: &mut Vec<CodeClimateIssue>,
1008    findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1009    root: &Path,
1010    severity: Severity,
1011) {
1012    if findings.is_empty() {
1013        return;
1014    }
1015    for entry in findings {
1016        let level = finding_codeclimate(entry, severity);
1017        let d = &entry.directive_site;
1018        let path = cc_path(&d.path, root);
1019        let fp = codeclimate_fingerprint_hash(&[
1020            "fallow/misplaced-directive",
1021            &path,
1022            &d.line.to_string(),
1023            &d.directive,
1024        ]);
1025        let line = if d.line > 0 { Some(d.line) } else { None };
1026        let message = format!(
1027            "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
1028            d.directive
1029        );
1030        issues.push(dead_code_issue(
1031            entry.finding_id.as_deref(),
1032            &[],
1033            CodeClimateIssueInput {
1034                check_name: "fallow/misplaced-directive",
1035                description: &message,
1036                severity: level,
1037                category: "Bug Risk",
1038                path: &path,
1039                begin_line: line,
1040                fingerprint: &fp,
1041            },
1042        ));
1043    }
1044}
1045
1046fn push_unprovided_inject_issues(
1047    issues: &mut Vec<CodeClimateIssue>,
1048    findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
1049    root: &Path,
1050    severity: Severity,
1051) {
1052    if findings.is_empty() {
1053        return;
1054    }
1055    for entry in findings {
1056        let level = finding_codeclimate(entry, severity);
1057        let i = &entry.inject;
1058        let path = cc_path(&i.path, root);
1059        let fp = codeclimate_fingerprint_hash(&[
1060            "fallow/unprovided-inject",
1061            &path,
1062            &i.line.to_string(),
1063            &i.key_name,
1064        ]);
1065        let line = if i.line > 0 { Some(i.line) } else { None };
1066        let message = format!(
1067            "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1068            i.key_name, i.key_name
1069        );
1070        issues.push(dead_code_issue(
1071            entry.finding_id.as_deref(),
1072            &[],
1073            CodeClimateIssueInput {
1074                check_name: "fallow/unprovided-inject",
1075                description: &message,
1076                severity: level,
1077                category: "Bug Risk",
1078                path: &path,
1079                begin_line: line,
1080                fingerprint: &fp,
1081            },
1082        ));
1083    }
1084}
1085
1086fn push_unrendered_component_issues(
1087    issues: &mut Vec<CodeClimateIssue>,
1088    findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1089    root: &Path,
1090    severity: Severity,
1091) {
1092    if findings.is_empty() {
1093        return;
1094    }
1095    for entry in findings {
1096        let level = finding_codeclimate(entry, severity);
1097        let c = &entry.component;
1098        let path = cc_path(&c.path, root);
1099        let fp = codeclimate_fingerprint_hash(&[
1100            "fallow/unrendered-component",
1101            &path,
1102            &c.line.to_string(),
1103            &c.component_name,
1104        ]);
1105        let line = if c.line > 0 { Some(c.line) } else { None };
1106        let message = format!(
1107            "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1108            c.component_name
1109        );
1110        issues.push(dead_code_issue(
1111            entry.finding_id.as_deref(),
1112            &[],
1113            CodeClimateIssueInput {
1114                check_name: "fallow/unrendered-component",
1115                description: &message,
1116                severity: level,
1117                category: "Bug Risk",
1118                path: &path,
1119                begin_line: line,
1120                fingerprint: &fp,
1121            },
1122        ));
1123    }
1124}
1125
1126fn push_absent_component_prop_issues(
1127    issues: &mut Vec<CodeClimateIssue>,
1128    findings: &[fallow_types::output_dead_code::AbsentComponentPropFinding],
1129    root: &Path,
1130    severity: Severity,
1131) {
1132    for finding in findings {
1133        let prop = &finding.prop;
1134        let path = cc_path(&prop.path, root);
1135        let fingerprint = codeclimate_fingerprint_hash(&[
1136            "fallow/absent-component-prop",
1137            &path,
1138            &prop.component_name,
1139            &prop.prop_name,
1140        ]);
1141        let description = format!(
1142            "Optional prop '{}.{}' is absent from inspected callers. {}",
1143            prop.component_name, prop.prop_name, prop.explanation
1144        );
1145        issues.push(dead_code_issue(
1146            finding.finding_id.as_deref(),
1147            &[],
1148            CodeClimateIssueInput {
1149                check_name: "fallow/absent-component-prop",
1150                description: &description,
1151                severity: finding_codeclimate(finding, severity),
1152                category: "Clarity",
1153                path: &path,
1154                begin_line: Some(prop.line),
1155                fingerprint: &fingerprint,
1156            },
1157        ));
1158    }
1159}
1160
1161fn push_unused_component_prop_issues(
1162    issues: &mut Vec<CodeClimateIssue>,
1163    findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1164    root: &Path,
1165    severity: Severity,
1166) {
1167    if findings.is_empty() {
1168        return;
1169    }
1170    for entry in findings {
1171        let level = finding_codeclimate(entry, severity);
1172        let p = &entry.prop;
1173        let path = cc_path(&p.path, root);
1174        let fp = codeclimate_fingerprint_hash(&[
1175            "fallow/unused-component-prop",
1176            &path,
1177            &p.line.to_string(),
1178            &p.prop_name,
1179        ]);
1180        let line = if p.line > 0 { Some(p.line) } else { None };
1181        let message = format!(
1182            "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1183            p.prop_name, p.component_name
1184        );
1185        issues.push(dead_code_issue(
1186            entry.finding_id.as_deref(),
1187            &[],
1188            CodeClimateIssueInput {
1189                check_name: "fallow/unused-component-prop",
1190                description: &message,
1191                severity: level,
1192                category: "Bug Risk",
1193                path: &path,
1194                begin_line: line,
1195                fingerprint: &fp,
1196            },
1197        ));
1198    }
1199}
1200
1201fn push_unused_component_emit_issues(
1202    issues: &mut Vec<CodeClimateIssue>,
1203    findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1204    root: &Path,
1205    severity: Severity,
1206) {
1207    if findings.is_empty() {
1208        return;
1209    }
1210    for entry in findings {
1211        let level = finding_codeclimate(entry, severity);
1212        let e = &entry.emit;
1213        let path = cc_path(&e.path, root);
1214        let fp = codeclimate_fingerprint_hash(&[
1215            "fallow/unused-component-emit",
1216            &path,
1217            &e.line.to_string(),
1218            &e.emit_name,
1219        ]);
1220        let line = if e.line > 0 { Some(e.line) } else { None };
1221        let message = format!(
1222            "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1223            e.emit_name, e.component_name
1224        );
1225        issues.push(dead_code_issue(
1226            entry.finding_id.as_deref(),
1227            &[],
1228            CodeClimateIssueInput {
1229                check_name: "fallow/unused-component-emit",
1230                description: &message,
1231                severity: level,
1232                category: "Bug Risk",
1233                path: &path,
1234                begin_line: line,
1235                fingerprint: &fp,
1236            },
1237        ));
1238    }
1239}
1240
1241fn push_unused_effect_layer_issues(
1242    issues: &mut Vec<CodeClimateIssue>,
1243    findings: &[fallow_types::output_dead_code::UnusedEffectLayerFinding],
1244    root: &Path,
1245    severity: Severity,
1246) {
1247    for entry in findings {
1248        let level = finding_codeclimate(entry, severity);
1249        let f = &entry.layer;
1250        let path = cc_path(&f.path, root);
1251        let fp = codeclimate_fingerprint_hash(&[
1252            "fallow/unused-effect-layer",
1253            &path,
1254            &f.line.to_string(),
1255            &f.name,
1256        ]);
1257        let line = if f.line > 0 { Some(f.line) } else { None };
1258        let message = format!(
1259            "layer `{}` is composed only into layers that nothing uses ({}); compose it into a live layer or remove it",
1260            f.name,
1261            f.dead_consumers.join(", ")
1262        );
1263        issues.push(dead_code_issue(
1264            entry.finding_id.as_deref(),
1265            &[],
1266            CodeClimateIssueInput {
1267                check_name: "fallow/unused-effect-layer",
1268                description: &message,
1269                severity: level,
1270                category: "Bug Risk",
1271                path: &path,
1272                begin_line: line,
1273                fingerprint: &fp,
1274            },
1275        ));
1276    }
1277}
1278
1279fn push_unused_tagged_error_issues(
1280    issues: &mut Vec<CodeClimateIssue>,
1281    findings: &[fallow_types::output_dead_code::UnusedTaggedErrorFinding],
1282    root: &Path,
1283    severity: Severity,
1284) {
1285    for entry in findings {
1286        let level = finding_codeclimate(entry, severity);
1287        let f = &entry.error;
1288        let path = cc_path(&f.path, root);
1289        let fp = codeclimate_fingerprint_hash(&[
1290            "fallow/unused-tagged-error",
1291            &path,
1292            &f.line.to_string(),
1293            &f.name,
1294        ]);
1295        let line = if f.line > 0 { Some(f.line) } else { None };
1296        let message = format!(
1297            "tagged error `{}` (tag `{}`) is never constructed or failed with; fail with it or remove it",
1298            f.name, f.tag
1299        );
1300        issues.push(dead_code_issue(
1301            entry.finding_id.as_deref(),
1302            &[],
1303            CodeClimateIssueInput {
1304                check_name: "fallow/unused-tagged-error",
1305                description: &message,
1306                severity: level,
1307                category: "Bug Risk",
1308                path: &path,
1309                begin_line: line,
1310                fingerprint: &fp,
1311            },
1312        ));
1313    }
1314}
1315
1316fn push_unproduced_catch_tag_issues(
1317    issues: &mut Vec<CodeClimateIssue>,
1318    findings: &[fallow_types::output_dead_code::UnproducedCatchTagFinding],
1319    root: &Path,
1320    severity: Severity,
1321) {
1322    for entry in findings {
1323        let level = finding_codeclimate(entry, severity);
1324        let f = &entry.catch_tag;
1325        let path = cc_path(&f.path, root);
1326        let fp = codeclimate_fingerprint_hash(&[
1327            "fallow/unproduced-catch-tag",
1328            &path,
1329            &f.line.to_string(),
1330            &f.tag,
1331        ]);
1332        let line = if f.line > 0 { Some(f.line) } else { None };
1333        let message = format!(
1334            "catchTag handler for `{}` never runs: no code produces {}",
1335            f.tag,
1336            f.candidate_classes.join(", ")
1337        );
1338        issues.push(dead_code_issue(
1339            entry.finding_id.as_deref(),
1340            &[],
1341            CodeClimateIssueInput {
1342                check_name: "fallow/unproduced-catch-tag",
1343                description: &message,
1344                severity: level,
1345                category: "Bug Risk",
1346                path: &path,
1347                begin_line: line,
1348                fingerprint: &fp,
1349            },
1350        ));
1351    }
1352}
1353
1354fn push_unused_svelte_event_issues(
1355    issues: &mut Vec<CodeClimateIssue>,
1356    findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1357    root: &Path,
1358    severity: Severity,
1359) {
1360    if findings.is_empty() {
1361        return;
1362    }
1363    for entry in findings {
1364        let level = finding_codeclimate(entry, severity);
1365        let e = &entry.event;
1366        let path = cc_path(&e.path, root);
1367        let fp = codeclimate_fingerprint_hash(&[
1368            "fallow/unused-svelte-event",
1369            &path,
1370            &e.line.to_string(),
1371            &e.event_name,
1372        ]);
1373        let line = if e.line > 0 { Some(e.line) } else { None };
1374        let message = format!(
1375            "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1376            e.event_name, e.component_name
1377        );
1378        issues.push(dead_code_issue(
1379            entry.finding_id.as_deref(),
1380            &[],
1381            CodeClimateIssueInput {
1382                check_name: "fallow/unused-svelte-event",
1383                description: &message,
1384                severity: level,
1385                category: "Bug Risk",
1386                path: &path,
1387                begin_line: line,
1388                fingerprint: &fp,
1389            },
1390        ));
1391    }
1392}
1393
1394fn push_unused_component_input_issues(
1395    issues: &mut Vec<CodeClimateIssue>,
1396    findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1397    root: &Path,
1398    severity: Severity,
1399) {
1400    if findings.is_empty() {
1401        return;
1402    }
1403    for entry in findings {
1404        let level = finding_codeclimate(entry, severity);
1405        let i = &entry.input;
1406        let path = cc_path(&i.path, root);
1407        let fp = codeclimate_fingerprint_hash(&[
1408            "fallow/unused-component-input",
1409            &path,
1410            &i.line.to_string(),
1411            &i.input_name,
1412        ]);
1413        let line = if i.line > 0 { Some(i.line) } else { None };
1414        let message = format!(
1415            "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1416            i.input_name, i.component_name
1417        );
1418        issues.push(dead_code_issue(
1419            entry.finding_id.as_deref(),
1420            &[],
1421            CodeClimateIssueInput {
1422                check_name: "fallow/unused-component-input",
1423                description: &message,
1424                severity: level,
1425                category: "Bug Risk",
1426                path: &path,
1427                begin_line: line,
1428                fingerprint: &fp,
1429            },
1430        ));
1431    }
1432}
1433
1434fn push_unused_component_output_issues(
1435    issues: &mut Vec<CodeClimateIssue>,
1436    findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1437    root: &Path,
1438    severity: Severity,
1439) {
1440    if findings.is_empty() {
1441        return;
1442    }
1443    for entry in findings {
1444        let level = finding_codeclimate(entry, severity);
1445        let o = &entry.output;
1446        let path = cc_path(&o.path, root);
1447        let fp = codeclimate_fingerprint_hash(&[
1448            "fallow/unused-component-output",
1449            &path,
1450            &o.line.to_string(),
1451            &o.output_name,
1452        ]);
1453        let line = if o.line > 0 { Some(o.line) } else { None };
1454        let message = format!(
1455            "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1456            o.output_name, o.component_name
1457        );
1458        issues.push(dead_code_issue(
1459            entry.finding_id.as_deref(),
1460            &[],
1461            CodeClimateIssueInput {
1462                check_name: "fallow/unused-component-output",
1463                description: &message,
1464                severity: level,
1465                category: "Bug Risk",
1466                path: &path,
1467                begin_line: line,
1468                fingerprint: &fp,
1469            },
1470        ));
1471    }
1472}
1473
1474fn push_unused_server_action_issues(
1475    issues: &mut Vec<CodeClimateIssue>,
1476    findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1477    root: &Path,
1478    severity: Severity,
1479) {
1480    if findings.is_empty() {
1481        return;
1482    }
1483    for entry in findings {
1484        let level = finding_codeclimate(entry, severity);
1485        let a = &entry.action;
1486        let path = cc_path(&a.path, root);
1487        let fp = codeclimate_fingerprint_hash(&[
1488            "fallow/unused-server-action",
1489            &path,
1490            &a.line.to_string(),
1491            &a.action_name,
1492        ]);
1493        let line = if a.line > 0 { Some(a.line) } else { None };
1494        let message = format!(
1495            "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)",
1496            a.action_name
1497        );
1498        issues.push(dead_code_issue(
1499            entry.finding_id.as_deref(),
1500            &[],
1501            CodeClimateIssueInput {
1502                check_name: "fallow/unused-server-action",
1503                description: &message,
1504                severity: level,
1505                category: "Bug Risk",
1506                path: &path,
1507                begin_line: line,
1508                fingerprint: &fp,
1509            },
1510        ));
1511    }
1512}
1513
1514fn push_unused_load_data_key_issues(
1515    issues: &mut Vec<CodeClimateIssue>,
1516    findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1517    root: &Path,
1518    severity: Severity,
1519) {
1520    if findings.is_empty() {
1521        return;
1522    }
1523    for entry in findings {
1524        let level = finding_codeclimate(entry, severity);
1525        let k = &entry.key;
1526        let path = cc_path(&k.path, root);
1527        let fp = codeclimate_fingerprint_hash(&[
1528            "fallow/unused-load-data-key",
1529            &path,
1530            &k.line.to_string(),
1531            &k.key_name,
1532        ]);
1533        let line = if k.line > 0 { Some(k.line) } else { None };
1534        let message = format!(
1535            "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",
1536            k.key_name
1537        );
1538        issues.push(dead_code_issue(
1539            entry.finding_id.as_deref(),
1540            &[],
1541            CodeClimateIssueInput {
1542                check_name: "fallow/unused-load-data-key",
1543                description: &message,
1544                severity: level,
1545                category: "Bug Risk",
1546                path: &path,
1547                begin_line: line,
1548                fingerprint: &fp,
1549            },
1550        ));
1551    }
1552}
1553
1554fn push_route_collision_issues(
1555    issues: &mut Vec<CodeClimateIssue>,
1556    findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1557    root: &Path,
1558    severity: Severity,
1559) {
1560    if findings.is_empty() {
1561        return;
1562    }
1563    for entry in findings {
1564        let level = finding_codeclimate(entry, severity);
1565        let c = &entry.collision;
1566        let path = cc_path(&c.path, root);
1567        let fp = codeclimate_fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1568        let line = if c.line > 0 { Some(c.line) } else { None };
1569        let message = format!(
1570            "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1571            c.url,
1572            c.conflicting_paths.len()
1573        );
1574        issues.push(dead_code_issue(
1575            entry.finding_id.as_deref(),
1576            &[],
1577            CodeClimateIssueInput {
1578                check_name: "fallow/route-collision",
1579                description: &message,
1580                severity: level,
1581                category: "Bug Risk",
1582                path: &path,
1583                begin_line: line,
1584                fingerprint: &fp,
1585            },
1586        ));
1587    }
1588}
1589
1590fn push_dynamic_segment_name_conflict_issues(
1591    issues: &mut Vec<CodeClimateIssue>,
1592    findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1593    root: &Path,
1594    severity: Severity,
1595) {
1596    if findings.is_empty() {
1597        return;
1598    }
1599    for entry in findings {
1600        let level = finding_codeclimate(entry, severity);
1601        let c = &entry.conflict;
1602        let path = cc_path(&c.path, root);
1603        let fp = codeclimate_fingerprint_hash(&[
1604            "fallow/dynamic-segment-name-conflict",
1605            &path,
1606            &c.position,
1607        ]);
1608        let line = if c.line > 0 { Some(c.line) } else { None };
1609        let message = format!(
1610            "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1611            c.position,
1612            c.conflicting_segments.join(", ")
1613        );
1614        issues.push(dead_code_issue(
1615            entry.finding_id.as_deref(),
1616            &[],
1617            CodeClimateIssueInput {
1618                check_name: "fallow/dynamic-segment-name-conflict",
1619                description: &message,
1620                severity: level,
1621                category: "Bug Risk",
1622                path: &path,
1623                begin_line: line,
1624                fingerprint: &fp,
1625            },
1626        ));
1627    }
1628}
1629
1630fn push_stale_suppression_issues(
1631    issues: &mut Vec<CodeClimateIssue>,
1632    suppressions: &[fallow_types::results::StaleSuppression],
1633    root: &Path,
1634    rules: &RulesConfig,
1635) {
1636    if suppressions.is_empty() {
1637        return;
1638    }
1639    for s in suppressions {
1640        let severity = if s.missing_reason {
1641            rules.require_suppression_reason
1642        } else {
1643            rules.stale_suppressions
1644        };
1645        let level = finding_codeclimate(s, severity);
1646        let path = cc_path(&s.path, root);
1647        let line_str = s.line.to_string();
1648        let check_name = if s.missing_reason {
1649            "fallow/missing-suppression-reason"
1650        } else {
1651            "fallow/stale-suppression"
1652        };
1653        let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str]);
1654        issues.push(dead_code_issue(
1655            s.finding_id.as_deref(),
1656            &[],
1657            CodeClimateIssueInput {
1658                check_name,
1659                description: &s.display_message(),
1660                severity: level,
1661                category: "Bug Risk",
1662                path: &path,
1663                begin_line: Some(s.line),
1664                fingerprint: &fp,
1665            },
1666        ));
1667    }
1668}
1669
1670fn push_unused_catalog_entry_issues(
1671    issues: &mut Vec<CodeClimateIssue>,
1672    entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1673    root: &Path,
1674    severity: Severity,
1675) {
1676    if entries.is_empty() {
1677        return;
1678    }
1679    for entry in entries {
1680        let level = finding_codeclimate(entry, severity);
1681        let finding_id = entry.finding_id.as_deref();
1682        let entry = &entry.entry;
1683        let path = cc_path(&entry.path, root);
1684        let line_str = entry.line.to_string();
1685        let fp = codeclimate_fingerprint_hash(&[
1686            "fallow/unused-catalog-entry",
1687            &path,
1688            &line_str,
1689            &entry.catalog_name,
1690            &entry.entry_name,
1691        ]);
1692        let description = if entry.catalog_name == "default" {
1693            format!(
1694                "Catalog entry '{}' is not referenced by any workspace package",
1695                entry.entry_name
1696            )
1697        } else {
1698            format!(
1699                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1700                entry.entry_name, entry.catalog_name
1701            )
1702        };
1703        issues.push(dead_code_issue(
1704            finding_id,
1705            &[],
1706            CodeClimateIssueInput {
1707                check_name: "fallow/unused-catalog-entry",
1708                description: &description,
1709                severity: level,
1710                category: "Bug Risk",
1711                path: &path,
1712                begin_line: Some(entry.line),
1713                fingerprint: &fp,
1714            },
1715        ));
1716    }
1717}
1718
1719fn push_unresolved_catalog_reference_issues(
1720    issues: &mut Vec<CodeClimateIssue>,
1721    findings: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1722    root: &Path,
1723    severity: Severity,
1724) {
1725    if findings.is_empty() {
1726        return;
1727    }
1728    for finding in findings {
1729        let level = finding_codeclimate(finding, severity);
1730        let finding_id = finding.finding_id.as_deref();
1731        let finding = &finding.reference;
1732        let path = cc_path(&finding.path, root);
1733        let line_str = finding.line.to_string();
1734        let fp = codeclimate_fingerprint_hash(&[
1735            "fallow/unresolved-catalog-reference",
1736            &path,
1737            &line_str,
1738            &finding.catalog_name,
1739            &finding.entry_name,
1740        ]);
1741        let catalog_phrase = if finding.catalog_name == "default" {
1742            "the default catalog".to_string()
1743        } else {
1744            format!("catalog '{}'", finding.catalog_name)
1745        };
1746        let mut description = format!(
1747            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1748            finding.entry_name,
1749            if finding.catalog_name == "default" {
1750                ""
1751            } else {
1752                finding.catalog_name.as_str()
1753            },
1754            catalog_phrase,
1755        );
1756        if !finding.available_in_catalogs.is_empty() {
1757            use std::fmt::Write as _;
1758            let _ = write!(
1759                description,
1760                " (available in: {})",
1761                finding.available_in_catalogs.join(", ")
1762            );
1763        }
1764        issues.push(dead_code_issue(
1765            finding_id,
1766            &[],
1767            CodeClimateIssueInput {
1768                check_name: "fallow/unresolved-catalog-reference",
1769                description: &description,
1770                severity: level,
1771                category: "Bug Risk",
1772                path: &path,
1773                begin_line: Some(finding.line),
1774                fingerprint: &fp,
1775            },
1776        ));
1777    }
1778}
1779
1780fn push_empty_catalog_group_issues(
1781    issues: &mut Vec<CodeClimateIssue>,
1782    groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1783    root: &Path,
1784    severity: Severity,
1785) {
1786    if groups.is_empty() {
1787        return;
1788    }
1789    for group in groups {
1790        let level = finding_codeclimate(group, severity);
1791        let finding_id = group.finding_id.as_deref();
1792        let group = &group.group;
1793        let path = cc_path(&group.path, root);
1794        let line_str = group.line.to_string();
1795        let fp = codeclimate_fingerprint_hash(&[
1796            "fallow/empty-catalog-group",
1797            &path,
1798            &line_str,
1799            &group.catalog_name,
1800        ]);
1801        issues.push(dead_code_issue(
1802            finding_id,
1803            &[],
1804            CodeClimateIssueInput {
1805                check_name: "fallow/empty-catalog-group",
1806                description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1807                severity: level,
1808                category: "Bug Risk",
1809                path: &path,
1810                begin_line: Some(group.line),
1811                fingerprint: &fp,
1812            },
1813        ));
1814    }
1815}
1816
1817fn push_unused_dependency_override_issues(
1818    issues: &mut Vec<CodeClimateIssue>,
1819    findings: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1820    root: &Path,
1821    severity: Severity,
1822) {
1823    if findings.is_empty() {
1824        return;
1825    }
1826    for finding in findings {
1827        let level = finding_codeclimate(finding, severity);
1828        let finding_id = finding.finding_id.as_deref();
1829        let finding = &finding.entry;
1830        let path = cc_path(&finding.path, root);
1831        let line_str = finding.line.to_string();
1832        let fp = codeclimate_fingerprint_hash(&[
1833            "fallow/unused-dependency-override",
1834            &path,
1835            &line_str,
1836            finding.source.as_label(),
1837            &finding.raw_key,
1838        ]);
1839        let mut description = format!(
1840            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1841            finding.raw_key, finding.version_range, finding.target_package,
1842        );
1843        if let Some(hint) = &finding.hint {
1844            use std::fmt::Write as _;
1845            let _ = write!(description, " ({hint})");
1846        }
1847        issues.push(dead_code_issue(
1848            finding_id,
1849            &[],
1850            CodeClimateIssueInput {
1851                check_name: "fallow/unused-dependency-override",
1852                description: &description,
1853                severity: level,
1854                category: "Bug Risk",
1855                path: &path,
1856                begin_line: Some(finding.line),
1857                fingerprint: &fp,
1858            },
1859        ));
1860    }
1861}
1862
1863fn push_misconfigured_dependency_override_issues(
1864    issues: &mut Vec<CodeClimateIssue>,
1865    findings: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1866    root: &Path,
1867    severity: Severity,
1868) {
1869    if findings.is_empty() {
1870        return;
1871    }
1872    for finding in findings {
1873        let level = finding_codeclimate(finding, severity);
1874        let finding_id = finding.finding_id.as_deref();
1875        let finding = &finding.entry;
1876        let path = cc_path(&finding.path, root);
1877        let line_str = finding.line.to_string();
1878        let fp = codeclimate_fingerprint_hash(&[
1879            "fallow/misconfigured-dependency-override",
1880            &path,
1881            &line_str,
1882            finding.source.as_label(),
1883            &finding.raw_key,
1884        ]);
1885        let description = format!(
1886            "Override `{}` -> `{}` is malformed: {}",
1887            finding.raw_key,
1888            finding.raw_value,
1889            finding.reason.describe(),
1890        );
1891        issues.push(dead_code_issue(
1892            finding_id,
1893            &[],
1894            CodeClimateIssueInput {
1895                check_name: "fallow/misconfigured-dependency-override",
1896                description: &description,
1897                severity: level,
1898                category: "Bug Risk",
1899                path: &path,
1900                begin_line: Some(finding.line),
1901                fingerprint: &fp,
1902            },
1903        ));
1904    }
1905}
1906
1907/// Build CodeClimate issues from dead-code analysis results.
1908///
1909/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1910/// shape convert via [`fallow_output::codeclimate_issues_to_value`]. The schema
1911/// drift gate locks the per-issue shape against
1912/// [`fallow_output::CodeClimateOutput`].
1913#[must_use]
1914pub fn build_codeclimate(
1915    results: &AnalysisResults,
1916    root: &Path,
1917    rules: &RulesConfig,
1918) -> Vec<CodeClimateIssue> {
1919    CodeClimateBuilder {
1920        issues: Vec::new(),
1921        results,
1922        root,
1923        rules,
1924    }
1925    .build()
1926}
1927
1928struct CodeClimateBuilder<'a> {
1929    issues: Vec<CodeClimateIssue>,
1930    results: &'a AnalysisResults,
1931    root: &'a Path,
1932    rules: &'a RulesConfig,
1933}
1934
1935impl CodeClimateBuilder<'_> {
1936    fn build(mut self) -> Vec<CodeClimateIssue> {
1937        self.push_file_and_export_issues();
1938        self.push_private_type_leak_issues();
1939        push_deprecated_export_issues(
1940            &mut self.issues,
1941            &self.results.deprecated_exports_in_use,
1942            self.root,
1943            self.rules.deprecated_exports_in_use,
1944        );
1945        self.push_package_dependency_issues();
1946        self.push_type_test_dependency_issues();
1947        self.push_member_issues();
1948        self.push_import_and_duplicate_issues();
1949        self.push_graph_issues();
1950        self.push_boundary_issues();
1951        self.push_suppression_and_catalog_issues();
1952        self.push_override_issues();
1953        self.push_prop_drilling_issues();
1954        self.issues
1955    }
1956
1957    fn push_prop_drilling_issues(&mut self) {
1958        for finding in &self.results.prop_drilling_chains {
1959            if finding.effective_severity != Some(EffectiveSeverity::Error) {
1960                continue;
1961            }
1962            let Some(source) = finding.chain.hops.first() else {
1963                continue;
1964            };
1965            let path = cc_path(&source.file, self.root);
1966            let fingerprint = codeclimate_fingerprint_hash(&[
1967                "fallow/prop-drilling",
1968                &path,
1969                &source.component,
1970                &finding.chain.prop,
1971            ]);
1972            self.issues.push(dead_code_issue(
1973                finding.finding_id.as_deref(),
1974                &[],
1975                CodeClimateIssueInput {
1976                    check_name: "fallow/prop-drilling",
1977                    description: &format!("Prop '{}' is forwarded unchanged through {} components. Review composition, colocation or context, or suppress the source prop declaration.", finding.chain.prop, finding.chain.depth),
1978                    severity: CodeClimateSeverity::Major,
1979                    category: "Complexity",
1980                    path: &path,
1981                    begin_line: Some(source.line),
1982                    fingerprint: &fingerprint,
1983                },
1984            ));
1985        }
1986    }
1987
1988    fn push_file_and_export_issues(&mut self) {
1989        push_unused_file_issues(
1990            &mut self.issues,
1991            &self.results.unused_files,
1992            self.root,
1993            self.rules.unused_files,
1994        );
1995        push_unused_export_issues(UnusedExportIssuesInput {
1996            issues: &mut self.issues,
1997            exports: self.results.unused_exports.iter().map(|e| {
1998                (
1999                    &e.export,
2000                    e.reachability_caveats.as_slice(),
2001                    e.effective_severity,
2002                    e.finding_id.as_deref(),
2003                )
2004            }),
2005            root: self.root,
2006            rule_id: "fallow/unused-export",
2007            direct_label: "Export",
2008            re_export_label: "Re-export",
2009            severity: self.rules.unused_exports,
2010        });
2011        push_unused_export_issues(UnusedExportIssuesInput {
2012            issues: &mut self.issues,
2013            exports: self.results.unused_types.iter().map(|e| {
2014                (
2015                    &e.export,
2016                    e.reachability_caveats.as_slice(),
2017                    e.effective_severity,
2018                    e.finding_id.as_deref(),
2019                )
2020            }),
2021            root: self.root,
2022            rule_id: "fallow/unused-type",
2023            direct_label: "Type export",
2024            re_export_label: "Type re-export",
2025            severity: self.rules.unused_types,
2026        });
2027    }
2028
2029    fn push_private_type_leak_issues(&mut self) {
2030        push_private_type_leak_issues(
2031            &mut self.issues,
2032            &self.results.private_type_leaks,
2033            self.root,
2034            self.rules.private_type_leaks,
2035        );
2036    }
2037
2038    fn push_package_dependency_issues(&mut self) {
2039        push_dep_cc_issues(
2040            &mut self.issues,
2041            self.results.unused_dependencies.iter().map(|f| {
2042                (
2043                    &f.dep,
2044                    f.reachability_caveats.as_slice(),
2045                    f.effective_severity,
2046                    f.finding_id.as_deref(),
2047                )
2048            }),
2049            self.root,
2050            "fallow/unused-dependency",
2051            "dependencies",
2052            self.rules.unused_dependencies,
2053        );
2054        push_dep_cc_issues(
2055            &mut self.issues,
2056            self.results.unused_dev_dependencies.iter().map(|f| {
2057                (
2058                    &f.dep,
2059                    f.reachability_caveats.as_slice(),
2060                    f.effective_severity,
2061                    f.finding_id.as_deref(),
2062                )
2063            }),
2064            self.root,
2065            "fallow/unused-dev-dependency",
2066            "devDependencies",
2067            self.rules.unused_dev_dependencies,
2068        );
2069        push_dep_cc_issues(
2070            &mut self.issues,
2071            self.results.unused_optional_dependencies.iter().map(|f| {
2072                (
2073                    &f.dep,
2074                    f.reachability_caveats.as_slice(),
2075                    f.effective_severity,
2076                    f.finding_id.as_deref(),
2077                )
2078            }),
2079            self.root,
2080            "fallow/unused-optional-dependency",
2081            "optionalDependencies",
2082            self.rules.unused_optional_dependencies,
2083        );
2084    }
2085
2086    fn push_type_test_dependency_issues(&mut self) {
2087        push_type_only_dep_issues(
2088            &mut self.issues,
2089            &self.results.type_only_dependencies,
2090            self.root,
2091            self.rules.type_only_dependencies,
2092        );
2093        push_test_only_dep_issues(
2094            &mut self.issues,
2095            &self.results.test_only_dependencies,
2096            self.root,
2097            self.rules.test_only_dependencies,
2098        );
2099        push_dev_dep_in_prod_issues(
2100            &mut self.issues,
2101            &self.results.dev_dependencies_in_production,
2102            self.root,
2103            self.rules.dev_dependencies_in_production,
2104        );
2105    }
2106
2107    fn push_member_issues(&mut self) {
2108        push_unused_member_issues(
2109            &mut self.issues,
2110            self.results.unused_enum_members.iter().map(|m| {
2111                (
2112                    &m.member,
2113                    m.reachability_caveats.as_slice(),
2114                    m.effective_severity,
2115                    m.finding_id.as_deref(),
2116                )
2117            }),
2118            self.root,
2119            "fallow/unused-enum-member",
2120            "Enum",
2121            self.rules.unused_enum_members,
2122        );
2123        push_unused_member_issues(
2124            &mut self.issues,
2125            self.results.unused_class_members.iter().map(|m| {
2126                (
2127                    &m.member,
2128                    m.reachability_caveats.as_slice(),
2129                    m.effective_severity,
2130                    m.finding_id.as_deref(),
2131                )
2132            }),
2133            self.root,
2134            "fallow/unused-class-member",
2135            "Class",
2136            self.rules.unused_class_members,
2137        );
2138        push_unused_member_issues(
2139            &mut self.issues,
2140            self.results.unused_store_members.iter().map(|m| {
2141                (
2142                    &m.member,
2143                    m.reachability_caveats.as_slice(),
2144                    m.effective_severity,
2145                    m.finding_id.as_deref(),
2146                )
2147            }),
2148            self.root,
2149            "fallow/unused-store-member",
2150            "Store",
2151            self.rules.unused_store_members,
2152        );
2153    }
2154
2155    fn push_import_and_duplicate_issues(&mut self) {
2156        push_unresolved_import_issues(
2157            &mut self.issues,
2158            &self.results.unresolved_imports,
2159            self.root,
2160            self.rules.unresolved_imports,
2161        );
2162        push_unlisted_dep_issues(
2163            &mut self.issues,
2164            &self.results.unlisted_dependencies,
2165            self.root,
2166            self.rules.unlisted_dependencies,
2167        );
2168        push_duplicate_export_issues(
2169            &mut self.issues,
2170            &self.results.duplicate_exports,
2171            self.root,
2172            self.rules.duplicate_exports,
2173        );
2174    }
2175
2176    fn push_graph_issues(&mut self) {
2177        push_circular_dep_issues(
2178            &mut self.issues,
2179            &self.results.circular_dependencies,
2180            self.root,
2181            self.rules.circular_dependencies,
2182        );
2183        push_re_export_cycle_issues(
2184            &mut self.issues,
2185            &self.results.re_export_cycles,
2186            self.root,
2187            self.rules.re_export_cycle,
2188        );
2189        push_package_cycle_issues(
2190            &mut self.issues,
2191            &self.results.package_cycles,
2192            self.root,
2193            self.rules.package_cycle,
2194        );
2195    }
2196
2197    fn push_boundary_issues(&mut self) {
2198        self.push_architecture_boundary_issues();
2199        self.push_client_server_boundary_issues();
2200        self.push_component_boundary_issues();
2201        self.push_framework_route_issues();
2202    }
2203
2204    fn push_architecture_boundary_issues(&mut self) {
2205        push_boundary_violation_issues(
2206            &mut self.issues,
2207            &self.results.boundary_violations,
2208            self.root,
2209            self.rules.boundary_violation,
2210        );
2211        push_boundary_coverage_issues(
2212            &mut self.issues,
2213            &self.results.boundary_coverage_violations,
2214            self.root,
2215            self.rules.boundary_violation,
2216        );
2217        push_boundary_call_issues(
2218            &mut self.issues,
2219            &self.results.boundary_call_violations,
2220            self.root,
2221            self.rules.boundary_violation,
2222        );
2223        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
2224    }
2225
2226    fn push_client_server_boundary_issues(&mut self) {
2227        push_invalid_client_export_issues(
2228            &mut self.issues,
2229            &self.results.invalid_client_exports,
2230            self.root,
2231            self.rules.invalid_client_export,
2232        );
2233        push_mixed_client_server_barrel_issues(
2234            &mut self.issues,
2235            &self.results.mixed_client_server_barrels,
2236            self.root,
2237            self.rules.mixed_client_server_barrel,
2238        );
2239        push_misplaced_directive_issues(
2240            &mut self.issues,
2241            &self.results.misplaced_directives,
2242            self.root,
2243            self.rules.misplaced_directive,
2244        );
2245    }
2246
2247    fn push_component_boundary_issues(&mut self) {
2248        push_unprovided_inject_issues(
2249            &mut self.issues,
2250            &self.results.unprovided_injects,
2251            self.root,
2252            self.rules.unprovided_injects,
2253        );
2254        push_unrendered_component_issues(
2255            &mut self.issues,
2256            &self.results.unrendered_components,
2257            self.root,
2258            self.rules.unrendered_components,
2259        );
2260        push_absent_component_prop_issues(
2261            &mut self.issues,
2262            &self.results.absent_component_props,
2263            self.root,
2264            self.rules.absent_component_props,
2265        );
2266        push_unused_component_prop_issues(
2267            &mut self.issues,
2268            &self.results.unused_component_props,
2269            self.root,
2270            self.rules.unused_component_props,
2271        );
2272        push_unused_component_emit_issues(
2273            &mut self.issues,
2274            &self.results.unused_component_emits,
2275            self.root,
2276            self.rules.unused_component_emits,
2277        );
2278        push_unused_component_input_issues(
2279            &mut self.issues,
2280            &self.results.unused_component_inputs,
2281            self.root,
2282            self.rules.unused_component_inputs,
2283        );
2284        push_unused_component_output_issues(
2285            &mut self.issues,
2286            &self.results.unused_component_outputs,
2287            self.root,
2288            self.rules.unused_component_outputs,
2289        );
2290        push_unused_svelte_event_issues(
2291            &mut self.issues,
2292            &self.results.unused_svelte_events,
2293            self.root,
2294            self.rules.unused_svelte_events,
2295        );
2296        push_unused_effect_layer_issues(
2297            &mut self.issues,
2298            &self.results.unused_effect_layers,
2299            self.root,
2300            self.rules.unused_effect_layers,
2301        );
2302        push_unused_tagged_error_issues(
2303            &mut self.issues,
2304            &self.results.unused_tagged_errors,
2305            self.root,
2306            self.rules.unused_tagged_errors,
2307        );
2308        push_unproduced_catch_tag_issues(
2309            &mut self.issues,
2310            &self.results.unproduced_catch_tags,
2311            self.root,
2312            self.rules.unproduced_catch_tags,
2313        );
2314    }
2315
2316    fn push_framework_route_issues(&mut self) {
2317        push_unused_server_action_issues(
2318            &mut self.issues,
2319            &self.results.unused_server_actions,
2320            self.root,
2321            self.rules.unused_server_actions,
2322        );
2323        push_unused_load_data_key_issues(
2324            &mut self.issues,
2325            &self.results.unused_load_data_keys,
2326            self.root,
2327            self.rules.unused_load_data_keys,
2328        );
2329        push_route_collision_issues(
2330            &mut self.issues,
2331            &self.results.route_collisions,
2332            self.root,
2333            self.rules.route_collision,
2334        );
2335        push_dynamic_segment_name_conflict_issues(
2336            &mut self.issues,
2337            &self.results.dynamic_segment_name_conflicts,
2338            self.root,
2339            self.rules.dynamic_segment_name_conflict,
2340        );
2341    }
2342
2343    fn push_suppression_and_catalog_issues(&mut self) {
2344        push_stale_suppression_issues(
2345            &mut self.issues,
2346            &self.results.stale_suppressions,
2347            self.root,
2348            self.rules,
2349        );
2350        push_unused_catalog_entry_issues(
2351            &mut self.issues,
2352            &self.results.unused_catalog_entries,
2353            self.root,
2354            self.rules.unused_catalog_entries,
2355        );
2356        push_empty_catalog_group_issues(
2357            &mut self.issues,
2358            &self.results.empty_catalog_groups,
2359            self.root,
2360            self.rules.empty_catalog_groups,
2361        );
2362        push_unresolved_catalog_reference_issues(
2363            &mut self.issues,
2364            &self.results.unresolved_catalog_references,
2365            self.root,
2366            self.rules.unresolved_catalog_references,
2367        );
2368    }
2369
2370    fn push_override_issues(&mut self) {
2371        push_unused_dependency_override_issues(
2372            &mut self.issues,
2373            &self.results.unused_dependency_overrides,
2374            self.root,
2375            self.rules.unused_dependency_overrides,
2376        );
2377        push_misconfigured_dependency_override_issues(
2378            &mut self.issues,
2379            &self.results.misconfigured_dependency_overrides,
2380            self.root,
2381            self.rules.misconfigured_dependency_overrides,
2382        );
2383    }
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388    use std::collections::BTreeSet;
2389
2390    use fallow_output::issue_output_contracts;
2391
2392    fn codeclimate_check_name_literals() -> BTreeSet<String> {
2393        let source = include_str!("dead_code_codeclimate.rs")
2394            .split("#[cfg(test)]")
2395            .next()
2396            .expect("source before tests");
2397        let mut literals = BTreeSet::new();
2398        let mut rest = source;
2399        while let Some(start) = rest.find("\"fallow/") {
2400            let after_quote = &rest[start + 1..];
2401            let Some(end) = after_quote.find('"') else {
2402                break;
2403            };
2404            literals.insert(after_quote[..end].to_owned());
2405            rest = &after_quote[end + 1..];
2406        }
2407        literals
2408    }
2409
2410    #[test]
2411    fn codeclimate_check_names_match_issue_contracts() {
2412        let from_emitter = codeclimate_check_name_literals();
2413        let from_contracts = issue_output_contracts()
2414            .flat_map(|contract| contract.codeclimate_check_names)
2415            .collect::<BTreeSet<_>>();
2416
2417        assert_eq!(from_emitter, from_contracts);
2418    }
2419
2420    mod caveats {
2421        use std::path::{Path, PathBuf};
2422
2423        use fallow_config::RulesConfig;
2424        use fallow_types::extract::MemberKind;
2425        use fallow_types::output_dead_code::{
2426            ReachabilityCaveat, UnusedClassMemberFinding, UnusedDependencyFinding,
2427            UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
2428            UnusedStoreMemberFinding,
2429        };
2430        use fallow_types::results::{
2431            AnalysisResults, DependencyLocation, UnusedDependency, UnusedExport, UnusedFile,
2432            UnusedMember,
2433        };
2434
2435        use crate::dead_code_codeclimate::build_codeclimate;
2436
2437        /// One finding of each caveated kind, caveated or not.
2438        fn results_with(root: &Path, caveated: bool) -> AnalysisResults {
2439            let caveats = if caveated {
2440                vec![ReachabilityCaveat::IncompleteImportGraph]
2441            } else {
2442                Vec::new()
2443            };
2444            let mut results = AnalysisResults::default();
2445
2446            let mut file = UnusedFileFinding::with_actions(UnusedFile {
2447                path: root.join("src/lib.ts"),
2448            });
2449            file.reachability_caveats.clone_from(&caveats);
2450            results.unused_files.push(file);
2451
2452            let mut export = UnusedExportFinding::with_actions(UnusedExport {
2453                path: root.join("src/lib.ts"),
2454                export_name: "needed".to_owned(),
2455                is_type_only: false,
2456                line: 3,
2457                col: 0,
2458                span_start: 0,
2459                is_re_export: false,
2460                deprecated: false,
2461                deprecated_reason: None,
2462            });
2463            export.reachability_caveats.clone_from(&caveats);
2464            results.unused_exports.push(export);
2465
2466            let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
2467                package_name: "left-pad".to_owned(),
2468                location: DependencyLocation::Dependencies,
2469                path: root.join("package.json"),
2470                line: 5,
2471                used_in_workspaces: Vec::new(),
2472                declared_and_imported_in: Vec::new(),
2473            });
2474            dep.reachability_caveats.clone_from(&caveats);
2475            results.unused_dependencies.push(dep);
2476
2477            let member = |parent: &str, name: &str, kind| UnusedMember {
2478                path: root.join("src/lib.ts"),
2479                parent_name: parent.to_owned(),
2480                member_name: name.to_owned(),
2481                kind,
2482                line: 7,
2483                col: 2,
2484            };
2485
2486            let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2487                "Mode",
2488                "Legacy",
2489                MemberKind::EnumMember,
2490            ));
2491            enum_member.reachability_caveats.clone_from(&caveats);
2492            results.unused_enum_members.push(enum_member);
2493
2494            let mut class_member = UnusedClassMemberFinding::with_actions(member(
2495                "Widget",
2496                "render",
2497                MemberKind::ClassMethod,
2498            ));
2499            class_member.reachability_caveats.clone_from(&caveats);
2500            results.unused_class_members.push(class_member);
2501
2502            let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2503                "useCart",
2504                "subtotal",
2505                MemberKind::StoreMember,
2506            ));
2507            store_member.reachability_caveats.clone_from(&caveats);
2508            results.unused_store_members.push(store_member);
2509
2510            results
2511        }
2512
2513        /// `description` is the field GitLab renders inline on the MR diff, and
2514        /// the field `CiIssue` carries into the PR-comment and review-comment
2515        /// bodies that offer the mutation. A verdict resting on a file the run
2516        /// never read has to say so there.
2517        #[test]
2518        fn descriptions_name_the_caveat() {
2519            let root = PathBuf::from("/project");
2520
2521            let issues =
2522                build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2523
2524            let descriptions: Vec<&str> = issues
2525                .iter()
2526                .map(|issue| issue.description.as_str())
2527                .collect();
2528            assert!(
2529                descriptions
2530                    .iter()
2531                    .all(|description| description.ends_with(" (caveat: incomplete import graph)")),
2532                "every caveated finding hedges its description: {descriptions:?}"
2533            );
2534            assert!(
2535                descriptions.contains(
2536                    &"File is not reachable from any entry point (caveat: incomplete import graph)"
2537                ),
2538                "{descriptions:?}"
2539            );
2540        }
2541
2542        /// A clean run must stay byte-identical, so an integrator diffing
2543        /// reports across versions sees no churn from a mechanism that did not
2544        /// fire.
2545        #[test]
2546        fn a_clean_run_carries_no_caveat_text() {
2547            let root = PathBuf::from("/project");
2548
2549            let issues =
2550                build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2551
2552            assert!(
2553                issues
2554                    .iter()
2555                    .all(|issue| !issue.description.contains("caveat")),
2556                "{:?}",
2557                issues
2558                    .iter()
2559                    .map(|issue| issue.description.as_str())
2560                    .collect::<Vec<_>>()
2561            );
2562        }
2563
2564        /// The fingerprint is GitLab's and the review layer's comment identity.
2565        /// It is computed from rule id plus location, never from the message,
2566        /// so gaining a caveat must not reopen a resolved comment thread.
2567        #[test]
2568        fn the_caveat_does_not_move_the_fingerprint() {
2569            let root = PathBuf::from("/project");
2570
2571            let clean =
2572                build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2573            let caveated =
2574                build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2575
2576            let fingerprints = |issues: &[fallow_output::CodeClimateIssue]| {
2577                issues
2578                    .iter()
2579                    .map(|issue| issue.fingerprint.clone())
2580                    .collect::<Vec<_>>()
2581            };
2582            assert_eq!(fingerprints(&clean), fingerprints(&caveated));
2583            assert_ne!(
2584                clean[0].description, caveated[0].description,
2585                "the guard is only meaningful while the description actually changed"
2586            );
2587        }
2588    }
2589
2590    mod stable_fingerprints {
2591        use std::path::{Path, PathBuf};
2592
2593        use fallow_config::RulesConfig;
2594        use fallow_output::{CodeClimateIssue, codeclimate_fingerprint_hash};
2595        use fallow_types::identity::stamp_dead_code_finding_ids;
2596        use fallow_types::output_dead_code::{
2597            DuplicateExportFinding, UnlistedDependencyFinding, UnusedDependencyFinding,
2598            UnusedExportFinding,
2599        };
2600        use fallow_types::results::{
2601            AnalysisResults, DependencyLocation, DuplicateExport, DuplicateLocation, ImportSite,
2602            UnlistedDependency, UnusedDependency, UnusedExport,
2603        };
2604
2605        use crate::dead_code_codeclimate::build_codeclimate;
2606
2607        fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
2608            UnusedExportFinding::with_actions(UnusedExport {
2609                path: root.join("src/lib.ts"),
2610                export_name: name.to_owned(),
2611                is_type_only: false,
2612                line,
2613                col: 0,
2614                span_start: 0,
2615                is_re_export: false,
2616                deprecated: false,
2617                deprecated_reason: None,
2618            })
2619        }
2620
2621        fn dependency(manifest: PathBuf, line: u32) -> UnusedDependencyFinding {
2622            UnusedDependencyFinding::with_actions(UnusedDependency {
2623                package_name: "left-pad".to_owned(),
2624                location: DependencyLocation::Dependencies,
2625                path: manifest,
2626                line,
2627                used_in_workspaces: Vec::new(),
2628                declared_and_imported_in: Vec::new(),
2629            })
2630        }
2631
2632        /// Findings of the kinds whose old fingerprint held a line, with
2633        /// every line moved down by `shift`.
2634        fn results(root: &Path, shift: u32) -> AnalysisResults {
2635            let mut results = AnalysisResults::default();
2636            results
2637                .unused_exports
2638                .push(export(root, "first", 3 + shift));
2639            results
2640                .unused_exports
2641                .push(export(root, "second", 9 + shift));
2642            results
2643                .unused_dependencies
2644                .push(dependency(root.join("package.json"), 5 + shift));
2645            results
2646                .unlisted_dependencies
2647                .push(UnlistedDependencyFinding::with_actions(
2648                    UnlistedDependency {
2649                        package_name: "chalk".to_owned(),
2650                        imported_from: vec![
2651                            ImportSite {
2652                                path: root.join("src/cli.ts"),
2653                                line: 2 + shift,
2654                                col: 0,
2655                            },
2656                            ImportSite {
2657                                path: root.join("src/cli.ts"),
2658                                line: 8 + shift,
2659                                col: 0,
2660                            },
2661                        ],
2662                    },
2663                ));
2664            results
2665                .duplicate_exports
2666                .push(DuplicateExportFinding::with_actions(DuplicateExport {
2667                    export_name: "Config".to_owned(),
2668                    locations: vec![
2669                        DuplicateLocation {
2670                            path: root.join("src/a.ts"),
2671                            line: 4 + shift,
2672                            col: 0,
2673                        },
2674                        DuplicateLocation {
2675                            path: root.join("src/b.ts"),
2676                            line: 6 + shift,
2677                            col: 0,
2678                        },
2679                    ],
2680                }));
2681            stamp_dead_code_finding_ids(&mut results, root);
2682            results
2683        }
2684
2685        fn fingerprints(issues: &[CodeClimateIssue]) -> Vec<String> {
2686            issues
2687                .iter()
2688                .map(|issue| issue.fingerprint.clone())
2689                .collect()
2690        }
2691
2692        /// A line shift above a finding keeps its fingerprint, so GitLab and
2693        /// the review layer keep the same issue and the same thread.
2694        #[test]
2695        fn a_line_shift_keeps_every_dead_code_fingerprint() {
2696            let root = PathBuf::from("/project");
2697            let rules = RulesConfig::default();
2698
2699            let before = build_codeclimate(&results(&root, 0), &root, &rules);
2700            let after = build_codeclimate(&results(&root, 40), &root, &rules);
2701
2702            assert_eq!(before.len(), 7);
2703            assert_eq!(fingerprints(&before), fingerprints(&after));
2704            assert_ne!(
2705                before[0].location.lines.begin, after[0].location.lines.begin,
2706                "the guard is only meaningful while the lines actually moved"
2707            );
2708            let mut unique = fingerprints(&before);
2709            unique.sort();
2710            unique.dedup();
2711            assert_eq!(
2712                unique.len(),
2713                before.len(),
2714                "every issue keeps its own fingerprint"
2715            );
2716            assert_eq!(
2717                before[0].legacy_fingerprint.as_deref(),
2718                Some(
2719                    codeclimate_fingerprint_hash(&[
2720                        "fallow/unused-export",
2721                        "src/lib.ts",
2722                        "3",
2723                        "first"
2724                    ])
2725                    .as_str()
2726                ),
2727                "the line-based value stays available to match older review threads"
2728            );
2729        }
2730
2731        fn site(root: &Path, file: &str, line: u32) -> ImportSite {
2732            ImportSite {
2733                path: root.join(file),
2734                line,
2735                col: 0,
2736            }
2737        }
2738
2739        fn location(root: &Path, file: &str, line: u32) -> DuplicateLocation {
2740            DuplicateLocation {
2741                path: root.join(file),
2742                line,
2743                col: 0,
2744            }
2745        }
2746
2747        /// Fingerprint per location path for one unlisted dependency and one
2748        /// duplicate export with the given locations.
2749        fn per_location(
2750            root: &Path,
2751            sites: Vec<ImportSite>,
2752            locations: Vec<DuplicateLocation>,
2753        ) -> Vec<(String, u32, String)> {
2754            let mut results = AnalysisResults::default();
2755            results
2756                .unlisted_dependencies
2757                .push(UnlistedDependencyFinding::with_actions(
2758                    UnlistedDependency {
2759                        package_name: "chalk".to_owned(),
2760                        imported_from: sites,
2761                    },
2762                ));
2763            results
2764                .duplicate_exports
2765                .push(DuplicateExportFinding::with_actions(DuplicateExport {
2766                    export_name: "Config".to_owned(),
2767                    locations,
2768                }));
2769            stamp_dead_code_finding_ids(&mut results, root);
2770            build_codeclimate(&results, root, &RulesConfig::default())
2771                .into_iter()
2772                .map(|issue| {
2773                    (
2774                        format!("{} {}", issue.check_name, issue.location.path),
2775                        issue.location.lines.begin,
2776                        issue.fingerprint,
2777                    )
2778                })
2779                .collect()
2780        }
2781
2782        fn fingerprint_at(issues: &[(String, u32, String)], path: &str, line: u32) -> String {
2783            issues
2784                .iter()
2785                .find(|(p, l, _)| p == path && *l == line)
2786                .map_or_else(
2787                    || panic!("no issue at {path}:{line}: {issues:?}"),
2788                    |(_, _, fingerprint)| fingerprint.clone(),
2789                )
2790        }
2791
2792        /// A location is named by its content, not by its position among the
2793        /// other locations. Adding or removing a sibling location, in another
2794        /// file or later in the same file, keeps the fingerprints of the
2795        /// others.
2796        #[test]
2797        fn a_sibling_location_does_not_move_the_other_fingerprints() {
2798            let root = PathBuf::from("/project");
2799            let base = per_location(
2800                &root,
2801                vec![site(&root, "src/b.ts", 4), site(&root, "src/c.ts", 7)],
2802                vec![
2803                    location(&root, "src/b.ts", 4),
2804                    location(&root, "src/c.ts", 7),
2805                ],
2806            );
2807            let added = per_location(
2808                &root,
2809                vec![
2810                    site(&root, "src/a.ts", 1),
2811                    site(&root, "src/b.ts", 4),
2812                    site(&root, "src/b.ts", 9),
2813                    site(&root, "src/c.ts", 7),
2814                ],
2815                vec![
2816                    location(&root, "src/a.ts", 1),
2817                    location(&root, "src/b.ts", 4),
2818                    location(&root, "src/b.ts", 9),
2819                    location(&root, "src/c.ts", 7),
2820                ],
2821            );
2822            let removed = per_location(
2823                &root,
2824                vec![site(&root, "src/c.ts", 7)],
2825                vec![
2826                    location(&root, "src/c.ts", 7),
2827                    location(&root, "src/d.ts", 2),
2828                ],
2829            );
2830
2831            for rule in ["fallow/unlisted-dependency", "fallow/duplicate-export"] {
2832                let pick = |issues: &[(String, u32, String)]| {
2833                    issues
2834                        .iter()
2835                        .filter_map(|(key, line, fingerprint)| {
2836                            key.strip_prefix(&format!("{rule} "))
2837                                .map(|path| (path.to_owned(), *line, fingerprint.clone()))
2838                        })
2839                        .collect::<Vec<_>>()
2840                };
2841                let (base, added, removed) = (pick(&base), pick(&added), pick(&removed));
2842                assert_eq!(
2843                    fingerprint_at(&base, "src/b.ts", 4),
2844                    fingerprint_at(&added, "src/b.ts", 4)
2845                );
2846                assert_eq!(
2847                    fingerprint_at(&base, "src/c.ts", 7),
2848                    fingerprint_at(&added, "src/c.ts", 7)
2849                );
2850                assert_eq!(
2851                    fingerprint_at(&base, "src/c.ts", 7),
2852                    fingerprint_at(&removed, "src/c.ts", 7)
2853                );
2854                assert_ne!(
2855                    fingerprint_at(&added, "src/b.ts", 4),
2856                    fingerprint_at(&added, "src/b.ts", 9),
2857                    "two locations with the same content still get two fingerprints"
2858                );
2859            }
2860        }
2861
2862        /// The same package unused in two workspaces is two findings. The old
2863        /// fingerprint held only rule and package name, so they collided.
2864        #[test]
2865        fn the_same_dependency_in_two_workspaces_gets_two_fingerprints() {
2866            let root = PathBuf::from("/project");
2867            let mut results = AnalysisResults::default();
2868            results
2869                .unused_dependencies
2870                .push(dependency(root.join("packages/a/package.json"), 5));
2871            results
2872                .unused_dependencies
2873                .push(dependency(root.join("packages/b/package.json"), 5));
2874            stamp_dead_code_finding_ids(&mut results, &root);
2875
2876            let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2877
2878            assert_eq!(issues.len(), 2);
2879            assert_ne!(issues[0].fingerprint, issues[1].fingerprint);
2880        }
2881
2882        /// A saved report from before finding ids has no ids. It keeps the old
2883        /// line-based fingerprint, so its threads stay matched.
2884        #[test]
2885        fn a_finding_without_an_id_keeps_the_legacy_fingerprint() {
2886            let root = PathBuf::from("/project");
2887            let mut results = AnalysisResults::default();
2888            results.unused_exports.push(export(&root, "first", 3));
2889
2890            let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2891
2892            assert_eq!(
2893                issues[0].fingerprint,
2894                codeclimate_fingerprint_hash(&["fallow/unused-export", "src/lib.ts", "3", "first"])
2895            );
2896        }
2897    }
2898}