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_unused_component_prop_issues(
1127    issues: &mut Vec<CodeClimateIssue>,
1128    findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1129    root: &Path,
1130    severity: Severity,
1131) {
1132    if findings.is_empty() {
1133        return;
1134    }
1135    for entry in findings {
1136        let level = finding_codeclimate(entry, severity);
1137        let p = &entry.prop;
1138        let path = cc_path(&p.path, root);
1139        let fp = codeclimate_fingerprint_hash(&[
1140            "fallow/unused-component-prop",
1141            &path,
1142            &p.line.to_string(),
1143            &p.prop_name,
1144        ]);
1145        let line = if p.line > 0 { Some(p.line) } else { None };
1146        let message = format!(
1147            "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1148            p.prop_name, p.component_name
1149        );
1150        issues.push(dead_code_issue(
1151            entry.finding_id.as_deref(),
1152            &[],
1153            CodeClimateIssueInput {
1154                check_name: "fallow/unused-component-prop",
1155                description: &message,
1156                severity: level,
1157                category: "Bug Risk",
1158                path: &path,
1159                begin_line: line,
1160                fingerprint: &fp,
1161            },
1162        ));
1163    }
1164}
1165
1166fn push_unused_component_emit_issues(
1167    issues: &mut Vec<CodeClimateIssue>,
1168    findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1169    root: &Path,
1170    severity: Severity,
1171) {
1172    if findings.is_empty() {
1173        return;
1174    }
1175    for entry in findings {
1176        let level = finding_codeclimate(entry, severity);
1177        let e = &entry.emit;
1178        let path = cc_path(&e.path, root);
1179        let fp = codeclimate_fingerprint_hash(&[
1180            "fallow/unused-component-emit",
1181            &path,
1182            &e.line.to_string(),
1183            &e.emit_name,
1184        ]);
1185        let line = if e.line > 0 { Some(e.line) } else { None };
1186        let message = format!(
1187            "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1188            e.emit_name, e.component_name
1189        );
1190        issues.push(dead_code_issue(
1191            entry.finding_id.as_deref(),
1192            &[],
1193            CodeClimateIssueInput {
1194                check_name: "fallow/unused-component-emit",
1195                description: &message,
1196                severity: level,
1197                category: "Bug Risk",
1198                path: &path,
1199                begin_line: line,
1200                fingerprint: &fp,
1201            },
1202        ));
1203    }
1204}
1205
1206fn push_unused_svelte_event_issues(
1207    issues: &mut Vec<CodeClimateIssue>,
1208    findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1209    root: &Path,
1210    severity: Severity,
1211) {
1212    if findings.is_empty() {
1213        return;
1214    }
1215    for entry in findings {
1216        let level = finding_codeclimate(entry, severity);
1217        let e = &entry.event;
1218        let path = cc_path(&e.path, root);
1219        let fp = codeclimate_fingerprint_hash(&[
1220            "fallow/unused-svelte-event",
1221            &path,
1222            &e.line.to_string(),
1223            &e.event_name,
1224        ]);
1225        let line = if e.line > 0 { Some(e.line) } else { None };
1226        let message = format!(
1227            "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1228            e.event_name, e.component_name
1229        );
1230        issues.push(dead_code_issue(
1231            entry.finding_id.as_deref(),
1232            &[],
1233            CodeClimateIssueInput {
1234                check_name: "fallow/unused-svelte-event",
1235                description: &message,
1236                severity: level,
1237                category: "Bug Risk",
1238                path: &path,
1239                begin_line: line,
1240                fingerprint: &fp,
1241            },
1242        ));
1243    }
1244}
1245
1246fn push_unused_component_input_issues(
1247    issues: &mut Vec<CodeClimateIssue>,
1248    findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1249    root: &Path,
1250    severity: Severity,
1251) {
1252    if findings.is_empty() {
1253        return;
1254    }
1255    for entry in findings {
1256        let level = finding_codeclimate(entry, severity);
1257        let i = &entry.input;
1258        let path = cc_path(&i.path, root);
1259        let fp = codeclimate_fingerprint_hash(&[
1260            "fallow/unused-component-input",
1261            &path,
1262            &i.line.to_string(),
1263            &i.input_name,
1264        ]);
1265        let line = if i.line > 0 { Some(i.line) } else { None };
1266        let message = format!(
1267            "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1268            i.input_name, i.component_name
1269        );
1270        issues.push(dead_code_issue(
1271            entry.finding_id.as_deref(),
1272            &[],
1273            CodeClimateIssueInput {
1274                check_name: "fallow/unused-component-input",
1275                description: &message,
1276                severity: level,
1277                category: "Bug Risk",
1278                path: &path,
1279                begin_line: line,
1280                fingerprint: &fp,
1281            },
1282        ));
1283    }
1284}
1285
1286fn push_unused_component_output_issues(
1287    issues: &mut Vec<CodeClimateIssue>,
1288    findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1289    root: &Path,
1290    severity: Severity,
1291) {
1292    if findings.is_empty() {
1293        return;
1294    }
1295    for entry in findings {
1296        let level = finding_codeclimate(entry, severity);
1297        let o = &entry.output;
1298        let path = cc_path(&o.path, root);
1299        let fp = codeclimate_fingerprint_hash(&[
1300            "fallow/unused-component-output",
1301            &path,
1302            &o.line.to_string(),
1303            &o.output_name,
1304        ]);
1305        let line = if o.line > 0 { Some(o.line) } else { None };
1306        let message = format!(
1307            "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1308            o.output_name, o.component_name
1309        );
1310        issues.push(dead_code_issue(
1311            entry.finding_id.as_deref(),
1312            &[],
1313            CodeClimateIssueInput {
1314                check_name: "fallow/unused-component-output",
1315                description: &message,
1316                severity: level,
1317                category: "Bug Risk",
1318                path: &path,
1319                begin_line: line,
1320                fingerprint: &fp,
1321            },
1322        ));
1323    }
1324}
1325
1326fn push_unused_server_action_issues(
1327    issues: &mut Vec<CodeClimateIssue>,
1328    findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1329    root: &Path,
1330    severity: Severity,
1331) {
1332    if findings.is_empty() {
1333        return;
1334    }
1335    for entry in findings {
1336        let level = finding_codeclimate(entry, severity);
1337        let a = &entry.action;
1338        let path = cc_path(&a.path, root);
1339        let fp = codeclimate_fingerprint_hash(&[
1340            "fallow/unused-server-action",
1341            &path,
1342            &a.line.to_string(),
1343            &a.action_name,
1344        ]);
1345        let line = if a.line > 0 { Some(a.line) } else { None };
1346        let message = format!(
1347            "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)",
1348            a.action_name
1349        );
1350        issues.push(dead_code_issue(
1351            entry.finding_id.as_deref(),
1352            &[],
1353            CodeClimateIssueInput {
1354                check_name: "fallow/unused-server-action",
1355                description: &message,
1356                severity: level,
1357                category: "Bug Risk",
1358                path: &path,
1359                begin_line: line,
1360                fingerprint: &fp,
1361            },
1362        ));
1363    }
1364}
1365
1366fn push_unused_load_data_key_issues(
1367    issues: &mut Vec<CodeClimateIssue>,
1368    findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1369    root: &Path,
1370    severity: Severity,
1371) {
1372    if findings.is_empty() {
1373        return;
1374    }
1375    for entry in findings {
1376        let level = finding_codeclimate(entry, severity);
1377        let k = &entry.key;
1378        let path = cc_path(&k.path, root);
1379        let fp = codeclimate_fingerprint_hash(&[
1380            "fallow/unused-load-data-key",
1381            &path,
1382            &k.line.to_string(),
1383            &k.key_name,
1384        ]);
1385        let line = if k.line > 0 { Some(k.line) } else { None };
1386        let message = format!(
1387            "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",
1388            k.key_name
1389        );
1390        issues.push(dead_code_issue(
1391            entry.finding_id.as_deref(),
1392            &[],
1393            CodeClimateIssueInput {
1394                check_name: "fallow/unused-load-data-key",
1395                description: &message,
1396                severity: level,
1397                category: "Bug Risk",
1398                path: &path,
1399                begin_line: line,
1400                fingerprint: &fp,
1401            },
1402        ));
1403    }
1404}
1405
1406fn push_route_collision_issues(
1407    issues: &mut Vec<CodeClimateIssue>,
1408    findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1409    root: &Path,
1410    severity: Severity,
1411) {
1412    if findings.is_empty() {
1413        return;
1414    }
1415    for entry in findings {
1416        let level = finding_codeclimate(entry, severity);
1417        let c = &entry.collision;
1418        let path = cc_path(&c.path, root);
1419        let fp = codeclimate_fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1420        let line = if c.line > 0 { Some(c.line) } else { None };
1421        let message = format!(
1422            "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1423            c.url,
1424            c.conflicting_paths.len()
1425        );
1426        issues.push(dead_code_issue(
1427            entry.finding_id.as_deref(),
1428            &[],
1429            CodeClimateIssueInput {
1430                check_name: "fallow/route-collision",
1431                description: &message,
1432                severity: level,
1433                category: "Bug Risk",
1434                path: &path,
1435                begin_line: line,
1436                fingerprint: &fp,
1437            },
1438        ));
1439    }
1440}
1441
1442fn push_dynamic_segment_name_conflict_issues(
1443    issues: &mut Vec<CodeClimateIssue>,
1444    findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1445    root: &Path,
1446    severity: Severity,
1447) {
1448    if findings.is_empty() {
1449        return;
1450    }
1451    for entry in findings {
1452        let level = finding_codeclimate(entry, severity);
1453        let c = &entry.conflict;
1454        let path = cc_path(&c.path, root);
1455        let fp = codeclimate_fingerprint_hash(&[
1456            "fallow/dynamic-segment-name-conflict",
1457            &path,
1458            &c.position,
1459        ]);
1460        let line = if c.line > 0 { Some(c.line) } else { None };
1461        let message = format!(
1462            "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1463            c.position,
1464            c.conflicting_segments.join(", ")
1465        );
1466        issues.push(dead_code_issue(
1467            entry.finding_id.as_deref(),
1468            &[],
1469            CodeClimateIssueInput {
1470                check_name: "fallow/dynamic-segment-name-conflict",
1471                description: &message,
1472                severity: level,
1473                category: "Bug Risk",
1474                path: &path,
1475                begin_line: line,
1476                fingerprint: &fp,
1477            },
1478        ));
1479    }
1480}
1481
1482fn push_stale_suppression_issues(
1483    issues: &mut Vec<CodeClimateIssue>,
1484    suppressions: &[fallow_types::results::StaleSuppression],
1485    root: &Path,
1486    rules: &RulesConfig,
1487) {
1488    if suppressions.is_empty() {
1489        return;
1490    }
1491    for s in suppressions {
1492        let severity = if s.missing_reason {
1493            rules.require_suppression_reason
1494        } else {
1495            rules.stale_suppressions
1496        };
1497        let level = finding_codeclimate(s, severity);
1498        let path = cc_path(&s.path, root);
1499        let line_str = s.line.to_string();
1500        let check_name = if s.missing_reason {
1501            "fallow/missing-suppression-reason"
1502        } else {
1503            "fallow/stale-suppression"
1504        };
1505        let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str]);
1506        issues.push(dead_code_issue(
1507            s.finding_id.as_deref(),
1508            &[],
1509            CodeClimateIssueInput {
1510                check_name,
1511                description: &s.display_message(),
1512                severity: level,
1513                category: "Bug Risk",
1514                path: &path,
1515                begin_line: Some(s.line),
1516                fingerprint: &fp,
1517            },
1518        ));
1519    }
1520}
1521
1522fn push_unused_catalog_entry_issues(
1523    issues: &mut Vec<CodeClimateIssue>,
1524    entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1525    root: &Path,
1526    severity: Severity,
1527) {
1528    if entries.is_empty() {
1529        return;
1530    }
1531    for entry in entries {
1532        let level = finding_codeclimate(entry, severity);
1533        let finding_id = entry.finding_id.as_deref();
1534        let entry = &entry.entry;
1535        let path = cc_path(&entry.path, root);
1536        let line_str = entry.line.to_string();
1537        let fp = codeclimate_fingerprint_hash(&[
1538            "fallow/unused-catalog-entry",
1539            &path,
1540            &line_str,
1541            &entry.catalog_name,
1542            &entry.entry_name,
1543        ]);
1544        let description = if entry.catalog_name == "default" {
1545            format!(
1546                "Catalog entry '{}' is not referenced by any workspace package",
1547                entry.entry_name
1548            )
1549        } else {
1550            format!(
1551                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1552                entry.entry_name, entry.catalog_name
1553            )
1554        };
1555        issues.push(dead_code_issue(
1556            finding_id,
1557            &[],
1558            CodeClimateIssueInput {
1559                check_name: "fallow/unused-catalog-entry",
1560                description: &description,
1561                severity: level,
1562                category: "Bug Risk",
1563                path: &path,
1564                begin_line: Some(entry.line),
1565                fingerprint: &fp,
1566            },
1567        ));
1568    }
1569}
1570
1571fn push_unresolved_catalog_reference_issues(
1572    issues: &mut Vec<CodeClimateIssue>,
1573    findings: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1574    root: &Path,
1575    severity: Severity,
1576) {
1577    if findings.is_empty() {
1578        return;
1579    }
1580    for finding in findings {
1581        let level = finding_codeclimate(finding, severity);
1582        let finding_id = finding.finding_id.as_deref();
1583        let finding = &finding.reference;
1584        let path = cc_path(&finding.path, root);
1585        let line_str = finding.line.to_string();
1586        let fp = codeclimate_fingerprint_hash(&[
1587            "fallow/unresolved-catalog-reference",
1588            &path,
1589            &line_str,
1590            &finding.catalog_name,
1591            &finding.entry_name,
1592        ]);
1593        let catalog_phrase = if finding.catalog_name == "default" {
1594            "the default catalog".to_string()
1595        } else {
1596            format!("catalog '{}'", finding.catalog_name)
1597        };
1598        let mut description = format!(
1599            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1600            finding.entry_name,
1601            if finding.catalog_name == "default" {
1602                ""
1603            } else {
1604                finding.catalog_name.as_str()
1605            },
1606            catalog_phrase,
1607        );
1608        if !finding.available_in_catalogs.is_empty() {
1609            use std::fmt::Write as _;
1610            let _ = write!(
1611                description,
1612                " (available in: {})",
1613                finding.available_in_catalogs.join(", ")
1614            );
1615        }
1616        issues.push(dead_code_issue(
1617            finding_id,
1618            &[],
1619            CodeClimateIssueInput {
1620                check_name: "fallow/unresolved-catalog-reference",
1621                description: &description,
1622                severity: level,
1623                category: "Bug Risk",
1624                path: &path,
1625                begin_line: Some(finding.line),
1626                fingerprint: &fp,
1627            },
1628        ));
1629    }
1630}
1631
1632fn push_empty_catalog_group_issues(
1633    issues: &mut Vec<CodeClimateIssue>,
1634    groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1635    root: &Path,
1636    severity: Severity,
1637) {
1638    if groups.is_empty() {
1639        return;
1640    }
1641    for group in groups {
1642        let level = finding_codeclimate(group, severity);
1643        let finding_id = group.finding_id.as_deref();
1644        let group = &group.group;
1645        let path = cc_path(&group.path, root);
1646        let line_str = group.line.to_string();
1647        let fp = codeclimate_fingerprint_hash(&[
1648            "fallow/empty-catalog-group",
1649            &path,
1650            &line_str,
1651            &group.catalog_name,
1652        ]);
1653        issues.push(dead_code_issue(
1654            finding_id,
1655            &[],
1656            CodeClimateIssueInput {
1657                check_name: "fallow/empty-catalog-group",
1658                description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1659                severity: level,
1660                category: "Bug Risk",
1661                path: &path,
1662                begin_line: Some(group.line),
1663                fingerprint: &fp,
1664            },
1665        ));
1666    }
1667}
1668
1669fn push_unused_dependency_override_issues(
1670    issues: &mut Vec<CodeClimateIssue>,
1671    findings: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1672    root: &Path,
1673    severity: Severity,
1674) {
1675    if findings.is_empty() {
1676        return;
1677    }
1678    for finding in findings {
1679        let level = finding_codeclimate(finding, severity);
1680        let finding_id = finding.finding_id.as_deref();
1681        let finding = &finding.entry;
1682        let path = cc_path(&finding.path, root);
1683        let line_str = finding.line.to_string();
1684        let fp = codeclimate_fingerprint_hash(&[
1685            "fallow/unused-dependency-override",
1686            &path,
1687            &line_str,
1688            finding.source.as_label(),
1689            &finding.raw_key,
1690        ]);
1691        let mut description = format!(
1692            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1693            finding.raw_key, finding.version_range, finding.target_package,
1694        );
1695        if let Some(hint) = &finding.hint {
1696            use std::fmt::Write as _;
1697            let _ = write!(description, " ({hint})");
1698        }
1699        issues.push(dead_code_issue(
1700            finding_id,
1701            &[],
1702            CodeClimateIssueInput {
1703                check_name: "fallow/unused-dependency-override",
1704                description: &description,
1705                severity: level,
1706                category: "Bug Risk",
1707                path: &path,
1708                begin_line: Some(finding.line),
1709                fingerprint: &fp,
1710            },
1711        ));
1712    }
1713}
1714
1715fn push_misconfigured_dependency_override_issues(
1716    issues: &mut Vec<CodeClimateIssue>,
1717    findings: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1718    root: &Path,
1719    severity: Severity,
1720) {
1721    if findings.is_empty() {
1722        return;
1723    }
1724    for finding in findings {
1725        let level = finding_codeclimate(finding, severity);
1726        let finding_id = finding.finding_id.as_deref();
1727        let finding = &finding.entry;
1728        let path = cc_path(&finding.path, root);
1729        let line_str = finding.line.to_string();
1730        let fp = codeclimate_fingerprint_hash(&[
1731            "fallow/misconfigured-dependency-override",
1732            &path,
1733            &line_str,
1734            finding.source.as_label(),
1735            &finding.raw_key,
1736        ]);
1737        let description = format!(
1738            "Override `{}` -> `{}` is malformed: {}",
1739            finding.raw_key,
1740            finding.raw_value,
1741            finding.reason.describe(),
1742        );
1743        issues.push(dead_code_issue(
1744            finding_id,
1745            &[],
1746            CodeClimateIssueInput {
1747                check_name: "fallow/misconfigured-dependency-override",
1748                description: &description,
1749                severity: level,
1750                category: "Bug Risk",
1751                path: &path,
1752                begin_line: Some(finding.line),
1753                fingerprint: &fp,
1754            },
1755        ));
1756    }
1757}
1758
1759/// Build CodeClimate issues from dead-code analysis results.
1760///
1761/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1762/// shape convert via [`fallow_output::codeclimate_issues_to_value`]. The schema
1763/// drift gate locks the per-issue shape against
1764/// [`fallow_output::CodeClimateOutput`].
1765#[must_use]
1766pub fn build_codeclimate(
1767    results: &AnalysisResults,
1768    root: &Path,
1769    rules: &RulesConfig,
1770) -> Vec<CodeClimateIssue> {
1771    CodeClimateBuilder {
1772        issues: Vec::new(),
1773        results,
1774        root,
1775        rules,
1776    }
1777    .build()
1778}
1779
1780struct CodeClimateBuilder<'a> {
1781    issues: Vec<CodeClimateIssue>,
1782    results: &'a AnalysisResults,
1783    root: &'a Path,
1784    rules: &'a RulesConfig,
1785}
1786
1787impl CodeClimateBuilder<'_> {
1788    fn build(mut self) -> Vec<CodeClimateIssue> {
1789        self.push_file_and_export_issues();
1790        self.push_private_type_leak_issues();
1791        push_deprecated_export_issues(
1792            &mut self.issues,
1793            &self.results.deprecated_exports_in_use,
1794            self.root,
1795            self.rules.deprecated_exports_in_use,
1796        );
1797        self.push_package_dependency_issues();
1798        self.push_type_test_dependency_issues();
1799        self.push_member_issues();
1800        self.push_import_and_duplicate_issues();
1801        self.push_graph_issues();
1802        self.push_boundary_issues();
1803        self.push_suppression_and_catalog_issues();
1804        self.push_override_issues();
1805        self.issues
1806    }
1807
1808    fn push_file_and_export_issues(&mut self) {
1809        push_unused_file_issues(
1810            &mut self.issues,
1811            &self.results.unused_files,
1812            self.root,
1813            self.rules.unused_files,
1814        );
1815        push_unused_export_issues(UnusedExportIssuesInput {
1816            issues: &mut self.issues,
1817            exports: self.results.unused_exports.iter().map(|e| {
1818                (
1819                    &e.export,
1820                    e.reachability_caveats.as_slice(),
1821                    e.effective_severity,
1822                    e.finding_id.as_deref(),
1823                )
1824            }),
1825            root: self.root,
1826            rule_id: "fallow/unused-export",
1827            direct_label: "Export",
1828            re_export_label: "Re-export",
1829            severity: self.rules.unused_exports,
1830        });
1831        push_unused_export_issues(UnusedExportIssuesInput {
1832            issues: &mut self.issues,
1833            exports: self.results.unused_types.iter().map(|e| {
1834                (
1835                    &e.export,
1836                    e.reachability_caveats.as_slice(),
1837                    e.effective_severity,
1838                    e.finding_id.as_deref(),
1839                )
1840            }),
1841            root: self.root,
1842            rule_id: "fallow/unused-type",
1843            direct_label: "Type export",
1844            re_export_label: "Type re-export",
1845            severity: self.rules.unused_types,
1846        });
1847    }
1848
1849    fn push_private_type_leak_issues(&mut self) {
1850        push_private_type_leak_issues(
1851            &mut self.issues,
1852            &self.results.private_type_leaks,
1853            self.root,
1854            self.rules.private_type_leaks,
1855        );
1856    }
1857
1858    fn push_package_dependency_issues(&mut self) {
1859        push_dep_cc_issues(
1860            &mut self.issues,
1861            self.results.unused_dependencies.iter().map(|f| {
1862                (
1863                    &f.dep,
1864                    f.reachability_caveats.as_slice(),
1865                    f.effective_severity,
1866                    f.finding_id.as_deref(),
1867                )
1868            }),
1869            self.root,
1870            "fallow/unused-dependency",
1871            "dependencies",
1872            self.rules.unused_dependencies,
1873        );
1874        push_dep_cc_issues(
1875            &mut self.issues,
1876            self.results.unused_dev_dependencies.iter().map(|f| {
1877                (
1878                    &f.dep,
1879                    f.reachability_caveats.as_slice(),
1880                    f.effective_severity,
1881                    f.finding_id.as_deref(),
1882                )
1883            }),
1884            self.root,
1885            "fallow/unused-dev-dependency",
1886            "devDependencies",
1887            self.rules.unused_dev_dependencies,
1888        );
1889        push_dep_cc_issues(
1890            &mut self.issues,
1891            self.results.unused_optional_dependencies.iter().map(|f| {
1892                (
1893                    &f.dep,
1894                    f.reachability_caveats.as_slice(),
1895                    f.effective_severity,
1896                    f.finding_id.as_deref(),
1897                )
1898            }),
1899            self.root,
1900            "fallow/unused-optional-dependency",
1901            "optionalDependencies",
1902            self.rules.unused_optional_dependencies,
1903        );
1904    }
1905
1906    fn push_type_test_dependency_issues(&mut self) {
1907        push_type_only_dep_issues(
1908            &mut self.issues,
1909            &self.results.type_only_dependencies,
1910            self.root,
1911            self.rules.type_only_dependencies,
1912        );
1913        push_test_only_dep_issues(
1914            &mut self.issues,
1915            &self.results.test_only_dependencies,
1916            self.root,
1917            self.rules.test_only_dependencies,
1918        );
1919        push_dev_dep_in_prod_issues(
1920            &mut self.issues,
1921            &self.results.dev_dependencies_in_production,
1922            self.root,
1923            self.rules.dev_dependencies_in_production,
1924        );
1925    }
1926
1927    fn push_member_issues(&mut self) {
1928        push_unused_member_issues(
1929            &mut self.issues,
1930            self.results.unused_enum_members.iter().map(|m| {
1931                (
1932                    &m.member,
1933                    m.reachability_caveats.as_slice(),
1934                    m.effective_severity,
1935                    m.finding_id.as_deref(),
1936                )
1937            }),
1938            self.root,
1939            "fallow/unused-enum-member",
1940            "Enum",
1941            self.rules.unused_enum_members,
1942        );
1943        push_unused_member_issues(
1944            &mut self.issues,
1945            self.results.unused_class_members.iter().map(|m| {
1946                (
1947                    &m.member,
1948                    m.reachability_caveats.as_slice(),
1949                    m.effective_severity,
1950                    m.finding_id.as_deref(),
1951                )
1952            }),
1953            self.root,
1954            "fallow/unused-class-member",
1955            "Class",
1956            self.rules.unused_class_members,
1957        );
1958        push_unused_member_issues(
1959            &mut self.issues,
1960            self.results.unused_store_members.iter().map(|m| {
1961                (
1962                    &m.member,
1963                    m.reachability_caveats.as_slice(),
1964                    m.effective_severity,
1965                    m.finding_id.as_deref(),
1966                )
1967            }),
1968            self.root,
1969            "fallow/unused-store-member",
1970            "Store",
1971            self.rules.unused_store_members,
1972        );
1973    }
1974
1975    fn push_import_and_duplicate_issues(&mut self) {
1976        push_unresolved_import_issues(
1977            &mut self.issues,
1978            &self.results.unresolved_imports,
1979            self.root,
1980            self.rules.unresolved_imports,
1981        );
1982        push_unlisted_dep_issues(
1983            &mut self.issues,
1984            &self.results.unlisted_dependencies,
1985            self.root,
1986            self.rules.unlisted_dependencies,
1987        );
1988        push_duplicate_export_issues(
1989            &mut self.issues,
1990            &self.results.duplicate_exports,
1991            self.root,
1992            self.rules.duplicate_exports,
1993        );
1994    }
1995
1996    fn push_graph_issues(&mut self) {
1997        push_circular_dep_issues(
1998            &mut self.issues,
1999            &self.results.circular_dependencies,
2000            self.root,
2001            self.rules.circular_dependencies,
2002        );
2003        push_re_export_cycle_issues(
2004            &mut self.issues,
2005            &self.results.re_export_cycles,
2006            self.root,
2007            self.rules.re_export_cycle,
2008        );
2009        push_package_cycle_issues(
2010            &mut self.issues,
2011            &self.results.package_cycles,
2012            self.root,
2013            self.rules.package_cycle,
2014        );
2015    }
2016
2017    fn push_boundary_issues(&mut self) {
2018        self.push_architecture_boundary_issues();
2019        self.push_client_server_boundary_issues();
2020        self.push_component_boundary_issues();
2021        self.push_framework_route_issues();
2022    }
2023
2024    fn push_architecture_boundary_issues(&mut self) {
2025        push_boundary_violation_issues(
2026            &mut self.issues,
2027            &self.results.boundary_violations,
2028            self.root,
2029            self.rules.boundary_violation,
2030        );
2031        push_boundary_coverage_issues(
2032            &mut self.issues,
2033            &self.results.boundary_coverage_violations,
2034            self.root,
2035            self.rules.boundary_violation,
2036        );
2037        push_boundary_call_issues(
2038            &mut self.issues,
2039            &self.results.boundary_call_violations,
2040            self.root,
2041            self.rules.boundary_violation,
2042        );
2043        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
2044    }
2045
2046    fn push_client_server_boundary_issues(&mut self) {
2047        push_invalid_client_export_issues(
2048            &mut self.issues,
2049            &self.results.invalid_client_exports,
2050            self.root,
2051            self.rules.invalid_client_export,
2052        );
2053        push_mixed_client_server_barrel_issues(
2054            &mut self.issues,
2055            &self.results.mixed_client_server_barrels,
2056            self.root,
2057            self.rules.mixed_client_server_barrel,
2058        );
2059        push_misplaced_directive_issues(
2060            &mut self.issues,
2061            &self.results.misplaced_directives,
2062            self.root,
2063            self.rules.misplaced_directive,
2064        );
2065    }
2066
2067    fn push_component_boundary_issues(&mut self) {
2068        push_unprovided_inject_issues(
2069            &mut self.issues,
2070            &self.results.unprovided_injects,
2071            self.root,
2072            self.rules.unprovided_injects,
2073        );
2074        push_unrendered_component_issues(
2075            &mut self.issues,
2076            &self.results.unrendered_components,
2077            self.root,
2078            self.rules.unrendered_components,
2079        );
2080        push_unused_component_prop_issues(
2081            &mut self.issues,
2082            &self.results.unused_component_props,
2083            self.root,
2084            self.rules.unused_component_props,
2085        );
2086        push_unused_component_emit_issues(
2087            &mut self.issues,
2088            &self.results.unused_component_emits,
2089            self.root,
2090            self.rules.unused_component_emits,
2091        );
2092        push_unused_component_input_issues(
2093            &mut self.issues,
2094            &self.results.unused_component_inputs,
2095            self.root,
2096            self.rules.unused_component_inputs,
2097        );
2098        push_unused_component_output_issues(
2099            &mut self.issues,
2100            &self.results.unused_component_outputs,
2101            self.root,
2102            self.rules.unused_component_outputs,
2103        );
2104        push_unused_svelte_event_issues(
2105            &mut self.issues,
2106            &self.results.unused_svelte_events,
2107            self.root,
2108            self.rules.unused_svelte_events,
2109        );
2110    }
2111
2112    fn push_framework_route_issues(&mut self) {
2113        push_unused_server_action_issues(
2114            &mut self.issues,
2115            &self.results.unused_server_actions,
2116            self.root,
2117            self.rules.unused_server_actions,
2118        );
2119        push_unused_load_data_key_issues(
2120            &mut self.issues,
2121            &self.results.unused_load_data_keys,
2122            self.root,
2123            self.rules.unused_load_data_keys,
2124        );
2125        push_route_collision_issues(
2126            &mut self.issues,
2127            &self.results.route_collisions,
2128            self.root,
2129            self.rules.route_collision,
2130        );
2131        push_dynamic_segment_name_conflict_issues(
2132            &mut self.issues,
2133            &self.results.dynamic_segment_name_conflicts,
2134            self.root,
2135            self.rules.dynamic_segment_name_conflict,
2136        );
2137    }
2138
2139    fn push_suppression_and_catalog_issues(&mut self) {
2140        push_stale_suppression_issues(
2141            &mut self.issues,
2142            &self.results.stale_suppressions,
2143            self.root,
2144            self.rules,
2145        );
2146        push_unused_catalog_entry_issues(
2147            &mut self.issues,
2148            &self.results.unused_catalog_entries,
2149            self.root,
2150            self.rules.unused_catalog_entries,
2151        );
2152        push_empty_catalog_group_issues(
2153            &mut self.issues,
2154            &self.results.empty_catalog_groups,
2155            self.root,
2156            self.rules.empty_catalog_groups,
2157        );
2158        push_unresolved_catalog_reference_issues(
2159            &mut self.issues,
2160            &self.results.unresolved_catalog_references,
2161            self.root,
2162            self.rules.unresolved_catalog_references,
2163        );
2164    }
2165
2166    fn push_override_issues(&mut self) {
2167        push_unused_dependency_override_issues(
2168            &mut self.issues,
2169            &self.results.unused_dependency_overrides,
2170            self.root,
2171            self.rules.unused_dependency_overrides,
2172        );
2173        push_misconfigured_dependency_override_issues(
2174            &mut self.issues,
2175            &self.results.misconfigured_dependency_overrides,
2176            self.root,
2177            self.rules.misconfigured_dependency_overrides,
2178        );
2179    }
2180}
2181
2182#[cfg(test)]
2183mod tests {
2184    use std::collections::BTreeSet;
2185
2186    use fallow_output::issue_output_contracts;
2187
2188    fn codeclimate_check_name_literals() -> BTreeSet<String> {
2189        let source = include_str!("dead_code_codeclimate.rs")
2190            .split("#[cfg(test)]")
2191            .next()
2192            .expect("source before tests");
2193        let mut literals = BTreeSet::new();
2194        let mut rest = source;
2195        while let Some(start) = rest.find("\"fallow/") {
2196            let after_quote = &rest[start + 1..];
2197            let Some(end) = after_quote.find('"') else {
2198                break;
2199            };
2200            literals.insert(after_quote[..end].to_owned());
2201            rest = &after_quote[end + 1..];
2202        }
2203        literals
2204    }
2205
2206    #[test]
2207    fn codeclimate_check_names_match_issue_contracts() {
2208        let from_emitter = codeclimate_check_name_literals();
2209        let from_contracts = issue_output_contracts()
2210            .flat_map(|contract| contract.codeclimate_check_names)
2211            .collect::<BTreeSet<_>>();
2212
2213        assert_eq!(from_emitter, from_contracts);
2214    }
2215
2216    mod caveats {
2217        use std::path::{Path, PathBuf};
2218
2219        use fallow_config::RulesConfig;
2220        use fallow_types::extract::MemberKind;
2221        use fallow_types::output_dead_code::{
2222            ReachabilityCaveat, UnusedClassMemberFinding, UnusedDependencyFinding,
2223            UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
2224            UnusedStoreMemberFinding,
2225        };
2226        use fallow_types::results::{
2227            AnalysisResults, DependencyLocation, UnusedDependency, UnusedExport, UnusedFile,
2228            UnusedMember,
2229        };
2230
2231        use crate::dead_code_codeclimate::build_codeclimate;
2232
2233        /// One finding of each caveated kind, caveated or not.
2234        fn results_with(root: &Path, caveated: bool) -> AnalysisResults {
2235            let caveats = if caveated {
2236                vec![ReachabilityCaveat::IncompleteImportGraph]
2237            } else {
2238                Vec::new()
2239            };
2240            let mut results = AnalysisResults::default();
2241
2242            let mut file = UnusedFileFinding::with_actions(UnusedFile {
2243                path: root.join("src/lib.ts"),
2244            });
2245            file.reachability_caveats.clone_from(&caveats);
2246            results.unused_files.push(file);
2247
2248            let mut export = UnusedExportFinding::with_actions(UnusedExport {
2249                path: root.join("src/lib.ts"),
2250                export_name: "needed".to_owned(),
2251                is_type_only: false,
2252                line: 3,
2253                col: 0,
2254                span_start: 0,
2255                is_re_export: false,
2256                deprecated: false,
2257                deprecated_reason: None,
2258            });
2259            export.reachability_caveats.clone_from(&caveats);
2260            results.unused_exports.push(export);
2261
2262            let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
2263                package_name: "left-pad".to_owned(),
2264                location: DependencyLocation::Dependencies,
2265                path: root.join("package.json"),
2266                line: 5,
2267                used_in_workspaces: Vec::new(),
2268            });
2269            dep.reachability_caveats.clone_from(&caveats);
2270            results.unused_dependencies.push(dep);
2271
2272            let member = |parent: &str, name: &str, kind| UnusedMember {
2273                path: root.join("src/lib.ts"),
2274                parent_name: parent.to_owned(),
2275                member_name: name.to_owned(),
2276                kind,
2277                line: 7,
2278                col: 2,
2279            };
2280
2281            let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2282                "Mode",
2283                "Legacy",
2284                MemberKind::EnumMember,
2285            ));
2286            enum_member.reachability_caveats.clone_from(&caveats);
2287            results.unused_enum_members.push(enum_member);
2288
2289            let mut class_member = UnusedClassMemberFinding::with_actions(member(
2290                "Widget",
2291                "render",
2292                MemberKind::ClassMethod,
2293            ));
2294            class_member.reachability_caveats.clone_from(&caveats);
2295            results.unused_class_members.push(class_member);
2296
2297            let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2298                "useCart",
2299                "subtotal",
2300                MemberKind::StoreMember,
2301            ));
2302            store_member.reachability_caveats.clone_from(&caveats);
2303            results.unused_store_members.push(store_member);
2304
2305            results
2306        }
2307
2308        /// `description` is the field GitLab renders inline on the MR diff, and
2309        /// the field `CiIssue` carries into the PR-comment and review-comment
2310        /// bodies that offer the mutation. A verdict resting on a file the run
2311        /// never read has to say so there.
2312        #[test]
2313        fn descriptions_name_the_caveat() {
2314            let root = PathBuf::from("/project");
2315
2316            let issues =
2317                build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2318
2319            let descriptions: Vec<&str> = issues
2320                .iter()
2321                .map(|issue| issue.description.as_str())
2322                .collect();
2323            assert!(
2324                descriptions
2325                    .iter()
2326                    .all(|description| description.ends_with(" (caveat: incomplete import graph)")),
2327                "every caveated finding hedges its description: {descriptions:?}"
2328            );
2329            assert!(
2330                descriptions.contains(
2331                    &"File is not reachable from any entry point (caveat: incomplete import graph)"
2332                ),
2333                "{descriptions:?}"
2334            );
2335        }
2336
2337        /// A clean run must stay byte-identical, so an integrator diffing
2338        /// reports across versions sees no churn from a mechanism that did not
2339        /// fire.
2340        #[test]
2341        fn a_clean_run_carries_no_caveat_text() {
2342            let root = PathBuf::from("/project");
2343
2344            let issues =
2345                build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2346
2347            assert!(
2348                issues
2349                    .iter()
2350                    .all(|issue| !issue.description.contains("caveat")),
2351                "{:?}",
2352                issues
2353                    .iter()
2354                    .map(|issue| issue.description.as_str())
2355                    .collect::<Vec<_>>()
2356            );
2357        }
2358
2359        /// The fingerprint is GitLab's and the review layer's comment identity.
2360        /// It is computed from rule id plus location, never from the message,
2361        /// so gaining a caveat must not reopen a resolved comment thread.
2362        #[test]
2363        fn the_caveat_does_not_move_the_fingerprint() {
2364            let root = PathBuf::from("/project");
2365
2366            let clean =
2367                build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2368            let caveated =
2369                build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2370
2371            let fingerprints = |issues: &[fallow_output::CodeClimateIssue]| {
2372                issues
2373                    .iter()
2374                    .map(|issue| issue.fingerprint.clone())
2375                    .collect::<Vec<_>>()
2376            };
2377            assert_eq!(fingerprints(&clean), fingerprints(&caveated));
2378            assert_ne!(
2379                clean[0].description, caveated[0].description,
2380                "the guard is only meaningful while the description actually changed"
2381            );
2382        }
2383    }
2384
2385    mod stable_fingerprints {
2386        use std::path::{Path, PathBuf};
2387
2388        use fallow_config::RulesConfig;
2389        use fallow_output::{CodeClimateIssue, codeclimate_fingerprint_hash};
2390        use fallow_types::identity::stamp_dead_code_finding_ids;
2391        use fallow_types::output_dead_code::{
2392            DuplicateExportFinding, UnlistedDependencyFinding, UnusedDependencyFinding,
2393            UnusedExportFinding,
2394        };
2395        use fallow_types::results::{
2396            AnalysisResults, DependencyLocation, DuplicateExport, DuplicateLocation, ImportSite,
2397            UnlistedDependency, UnusedDependency, UnusedExport,
2398        };
2399
2400        use crate::dead_code_codeclimate::build_codeclimate;
2401
2402        fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
2403            UnusedExportFinding::with_actions(UnusedExport {
2404                path: root.join("src/lib.ts"),
2405                export_name: name.to_owned(),
2406                is_type_only: false,
2407                line,
2408                col: 0,
2409                span_start: 0,
2410                is_re_export: false,
2411                deprecated: false,
2412                deprecated_reason: None,
2413            })
2414        }
2415
2416        fn dependency(manifest: PathBuf, line: u32) -> UnusedDependencyFinding {
2417            UnusedDependencyFinding::with_actions(UnusedDependency {
2418                package_name: "left-pad".to_owned(),
2419                location: DependencyLocation::Dependencies,
2420                path: manifest,
2421                line,
2422                used_in_workspaces: Vec::new(),
2423            })
2424        }
2425
2426        /// Findings of the kinds whose old fingerprint held a line, with
2427        /// every line moved down by `shift`.
2428        fn results(root: &Path, shift: u32) -> AnalysisResults {
2429            let mut results = AnalysisResults::default();
2430            results
2431                .unused_exports
2432                .push(export(root, "first", 3 + shift));
2433            results
2434                .unused_exports
2435                .push(export(root, "second", 9 + shift));
2436            results
2437                .unused_dependencies
2438                .push(dependency(root.join("package.json"), 5 + shift));
2439            results
2440                .unlisted_dependencies
2441                .push(UnlistedDependencyFinding::with_actions(
2442                    UnlistedDependency {
2443                        package_name: "chalk".to_owned(),
2444                        imported_from: vec![
2445                            ImportSite {
2446                                path: root.join("src/cli.ts"),
2447                                line: 2 + shift,
2448                                col: 0,
2449                            },
2450                            ImportSite {
2451                                path: root.join("src/cli.ts"),
2452                                line: 8 + shift,
2453                                col: 0,
2454                            },
2455                        ],
2456                    },
2457                ));
2458            results
2459                .duplicate_exports
2460                .push(DuplicateExportFinding::with_actions(DuplicateExport {
2461                    export_name: "Config".to_owned(),
2462                    locations: vec![
2463                        DuplicateLocation {
2464                            path: root.join("src/a.ts"),
2465                            line: 4 + shift,
2466                            col: 0,
2467                        },
2468                        DuplicateLocation {
2469                            path: root.join("src/b.ts"),
2470                            line: 6 + shift,
2471                            col: 0,
2472                        },
2473                    ],
2474                }));
2475            stamp_dead_code_finding_ids(&mut results, root);
2476            results
2477        }
2478
2479        fn fingerprints(issues: &[CodeClimateIssue]) -> Vec<String> {
2480            issues
2481                .iter()
2482                .map(|issue| issue.fingerprint.clone())
2483                .collect()
2484        }
2485
2486        /// A line shift above a finding keeps its fingerprint, so GitLab and
2487        /// the review layer keep the same issue and the same thread.
2488        #[test]
2489        fn a_line_shift_keeps_every_dead_code_fingerprint() {
2490            let root = PathBuf::from("/project");
2491            let rules = RulesConfig::default();
2492
2493            let before = build_codeclimate(&results(&root, 0), &root, &rules);
2494            let after = build_codeclimate(&results(&root, 40), &root, &rules);
2495
2496            assert_eq!(before.len(), 7);
2497            assert_eq!(fingerprints(&before), fingerprints(&after));
2498            assert_ne!(
2499                before[0].location.lines.begin, after[0].location.lines.begin,
2500                "the guard is only meaningful while the lines actually moved"
2501            );
2502            let mut unique = fingerprints(&before);
2503            unique.sort();
2504            unique.dedup();
2505            assert_eq!(
2506                unique.len(),
2507                before.len(),
2508                "every issue keeps its own fingerprint"
2509            );
2510            assert_eq!(
2511                before[0].legacy_fingerprint.as_deref(),
2512                Some(
2513                    codeclimate_fingerprint_hash(&[
2514                        "fallow/unused-export",
2515                        "src/lib.ts",
2516                        "3",
2517                        "first"
2518                    ])
2519                    .as_str()
2520                ),
2521                "the line-based value stays available to match older review threads"
2522            );
2523        }
2524
2525        fn site(root: &Path, file: &str, line: u32) -> ImportSite {
2526            ImportSite {
2527                path: root.join(file),
2528                line,
2529                col: 0,
2530            }
2531        }
2532
2533        fn location(root: &Path, file: &str, line: u32) -> DuplicateLocation {
2534            DuplicateLocation {
2535                path: root.join(file),
2536                line,
2537                col: 0,
2538            }
2539        }
2540
2541        /// Fingerprint per location path for one unlisted dependency and one
2542        /// duplicate export with the given locations.
2543        fn per_location(
2544            root: &Path,
2545            sites: Vec<ImportSite>,
2546            locations: Vec<DuplicateLocation>,
2547        ) -> Vec<(String, u32, String)> {
2548            let mut results = AnalysisResults::default();
2549            results
2550                .unlisted_dependencies
2551                .push(UnlistedDependencyFinding::with_actions(
2552                    UnlistedDependency {
2553                        package_name: "chalk".to_owned(),
2554                        imported_from: sites,
2555                    },
2556                ));
2557            results
2558                .duplicate_exports
2559                .push(DuplicateExportFinding::with_actions(DuplicateExport {
2560                    export_name: "Config".to_owned(),
2561                    locations,
2562                }));
2563            stamp_dead_code_finding_ids(&mut results, root);
2564            build_codeclimate(&results, root, &RulesConfig::default())
2565                .into_iter()
2566                .map(|issue| {
2567                    (
2568                        format!("{} {}", issue.check_name, issue.location.path),
2569                        issue.location.lines.begin,
2570                        issue.fingerprint,
2571                    )
2572                })
2573                .collect()
2574        }
2575
2576        fn fingerprint_at(issues: &[(String, u32, String)], path: &str, line: u32) -> String {
2577            issues
2578                .iter()
2579                .find(|(p, l, _)| p == path && *l == line)
2580                .map_or_else(
2581                    || panic!("no issue at {path}:{line}: {issues:?}"),
2582                    |(_, _, fingerprint)| fingerprint.clone(),
2583                )
2584        }
2585
2586        /// A location is named by its content, not by its position among the
2587        /// other locations. Adding or removing a sibling location, in another
2588        /// file or later in the same file, keeps the fingerprints of the
2589        /// others.
2590        #[test]
2591        fn a_sibling_location_does_not_move_the_other_fingerprints() {
2592            let root = PathBuf::from("/project");
2593            let base = per_location(
2594                &root,
2595                vec![site(&root, "src/b.ts", 4), site(&root, "src/c.ts", 7)],
2596                vec![
2597                    location(&root, "src/b.ts", 4),
2598                    location(&root, "src/c.ts", 7),
2599                ],
2600            );
2601            let added = per_location(
2602                &root,
2603                vec![
2604                    site(&root, "src/a.ts", 1),
2605                    site(&root, "src/b.ts", 4),
2606                    site(&root, "src/b.ts", 9),
2607                    site(&root, "src/c.ts", 7),
2608                ],
2609                vec![
2610                    location(&root, "src/a.ts", 1),
2611                    location(&root, "src/b.ts", 4),
2612                    location(&root, "src/b.ts", 9),
2613                    location(&root, "src/c.ts", 7),
2614                ],
2615            );
2616            let removed = per_location(
2617                &root,
2618                vec![site(&root, "src/c.ts", 7)],
2619                vec![
2620                    location(&root, "src/c.ts", 7),
2621                    location(&root, "src/d.ts", 2),
2622                ],
2623            );
2624
2625            for rule in ["fallow/unlisted-dependency", "fallow/duplicate-export"] {
2626                let pick = |issues: &[(String, u32, String)]| {
2627                    issues
2628                        .iter()
2629                        .filter_map(|(key, line, fingerprint)| {
2630                            key.strip_prefix(&format!("{rule} "))
2631                                .map(|path| (path.to_owned(), *line, fingerprint.clone()))
2632                        })
2633                        .collect::<Vec<_>>()
2634                };
2635                let (base, added, removed) = (pick(&base), pick(&added), pick(&removed));
2636                assert_eq!(
2637                    fingerprint_at(&base, "src/b.ts", 4),
2638                    fingerprint_at(&added, "src/b.ts", 4)
2639                );
2640                assert_eq!(
2641                    fingerprint_at(&base, "src/c.ts", 7),
2642                    fingerprint_at(&added, "src/c.ts", 7)
2643                );
2644                assert_eq!(
2645                    fingerprint_at(&base, "src/c.ts", 7),
2646                    fingerprint_at(&removed, "src/c.ts", 7)
2647                );
2648                assert_ne!(
2649                    fingerprint_at(&added, "src/b.ts", 4),
2650                    fingerprint_at(&added, "src/b.ts", 9),
2651                    "two locations with the same content still get two fingerprints"
2652                );
2653            }
2654        }
2655
2656        /// The same package unused in two workspaces is two findings. The old
2657        /// fingerprint held only rule and package name, so they collided.
2658        #[test]
2659        fn the_same_dependency_in_two_workspaces_gets_two_fingerprints() {
2660            let root = PathBuf::from("/project");
2661            let mut results = AnalysisResults::default();
2662            results
2663                .unused_dependencies
2664                .push(dependency(root.join("packages/a/package.json"), 5));
2665            results
2666                .unused_dependencies
2667                .push(dependency(root.join("packages/b/package.json"), 5));
2668            stamp_dead_code_finding_ids(&mut results, &root);
2669
2670            let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2671
2672            assert_eq!(issues.len(), 2);
2673            assert_ne!(issues[0].fingerprint, issues[1].fingerprint);
2674        }
2675
2676        /// A saved report from before finding ids has no ids. It keeps the old
2677        /// line-based fingerprint, so its threads stay matched.
2678        #[test]
2679        fn a_finding_without_an_id_keeps_the_legacy_fingerprint() {
2680            let root = PathBuf::from("/project");
2681            let mut results = AnalysisResults::default();
2682            results.unused_exports.push(export(&root, "first", 3));
2683
2684            let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2685
2686            assert_eq!(
2687                issues[0].fingerprint,
2688                codeclimate_fingerprint_hash(&["fallow/unused-export", "src/lib.ts", "3", "first"])
2689            );
2690        }
2691    }
2692}