Skip to main content

fallow_api/
dead_code_codeclimate.rs

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