Skip to main content

fallow_api/
dead_code_codeclimate.rs

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