Skip to main content

fallow_cli/
audit_keys.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4
5use rustc_hash::FxHashSet;
6
7pub fn relative_key_path(path: &Path, root: &Path) -> String {
8    let simple_path = dunce::simplified(path);
9    let simple_root = dunce::simplified(root);
10    simple_path
11        .strip_prefix(simple_root)
12        .unwrap_or(simple_path)
13        .to_string_lossy()
14        .replace('\\', "/")
15}
16
17fn dependency_location_key(location: &fallow_core::results::DependencyLocation) -> &'static str {
18    match location {
19        fallow_core::results::DependencyLocation::Dependencies => "unused-dependency",
20        fallow_core::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
21        fallow_core::results::DependencyLocation::OptionalDependencies => {
22            "unused-optional-dependency"
23        }
24    }
25}
26
27fn unused_dependency_key(item: &fallow_core::results::UnusedDependency, root: &Path) -> String {
28    format!(
29        "{}:{}:{}",
30        dependency_location_key(&item.location),
31        relative_key_path(&item.path, root),
32        item.package_name
33    )
34}
35
36fn invalid_client_export_key(
37    item: &fallow_core::results::InvalidClientExport,
38    root: &Path,
39) -> String {
40    format!(
41        "invalid-client-export:{}:{}",
42        relative_key_path(&item.path, root),
43        item.export_name
44    )
45}
46
47fn mixed_client_server_barrel_key(
48    item: &fallow_core::results::MixedClientServerBarrel,
49    root: &Path,
50) -> String {
51    format!(
52        "mixed-client-server-barrel:{}:{}:{}",
53        relative_key_path(&item.path, root),
54        item.client_origin,
55        item.server_origin
56    )
57}
58
59fn misplaced_directive_key(item: &fallow_core::results::MisplacedDirective, root: &Path) -> String {
60    format!(
61        "misplaced-directive:{}:{}:{}",
62        relative_key_path(&item.path, root),
63        item.line,
64        item.directive
65    )
66}
67
68fn unprovided_inject_key(item: &fallow_core::results::UnprovidedInject, root: &Path) -> String {
69    format!(
70        "unprovided-inject:{}:{}",
71        relative_key_path(&item.path, root),
72        item.key_name
73    )
74}
75
76fn unrendered_component_key(
77    item: &fallow_core::results::UnrenderedComponent,
78    root: &Path,
79) -> String {
80    format!(
81        "unrendered-component:{}:{}",
82        relative_key_path(&item.path, root),
83        item.component_name
84    )
85}
86
87fn unused_component_prop_key(
88    item: &fallow_core::results::UnusedComponentProp,
89    root: &Path,
90) -> String {
91    format!(
92        "unused-component-prop:{}:{}",
93        relative_key_path(&item.path, root),
94        item.prop_name
95    )
96}
97
98fn unused_component_emit_key(
99    item: &fallow_core::results::UnusedComponentEmit,
100    root: &Path,
101) -> String {
102    format!(
103        "unused-component-emit:{}:{}",
104        relative_key_path(&item.path, root),
105        item.emit_name
106    )
107}
108
109fn unused_component_input_key(
110    item: &fallow_core::results::UnusedComponentInput,
111    root: &Path,
112) -> String {
113    format!(
114        "unused-component-input:{}:{}",
115        relative_key_path(&item.path, root),
116        item.input_name
117    )
118}
119
120fn unused_component_output_key(
121    item: &fallow_core::results::UnusedComponentOutput,
122    root: &Path,
123) -> String {
124    format!(
125        "unused-component-output:{}:{}",
126        relative_key_path(&item.path, root),
127        item.output_name
128    )
129}
130
131fn unused_svelte_event_key(item: &fallow_core::results::UnusedSvelteEvent, root: &Path) -> String {
132    format!(
133        "unused-svelte-event:{}:{}",
134        relative_key_path(&item.path, root),
135        item.event_name
136    )
137}
138
139fn unused_server_action_key(
140    item: &fallow_core::results::UnusedServerAction,
141    root: &Path,
142) -> String {
143    format!(
144        "unused-server-action:{}:{}",
145        relative_key_path(&item.path, root),
146        item.action_name
147    )
148}
149
150fn unused_load_data_key_key(item: &fallow_core::results::UnusedLoadDataKey, root: &Path) -> String {
151    format!(
152        "unused-load-data-key:{}:{}",
153        relative_key_path(&item.path, root),
154        item.key_name
155    )
156}
157
158fn route_collision_key(item: &fallow_core::results::RouteCollision, root: &Path) -> String {
159    format!(
160        "route-collision:{}:{}",
161        relative_key_path(&item.path, root),
162        item.url
163    )
164}
165
166fn dynamic_segment_name_conflict_key(
167    item: &fallow_core::results::DynamicSegmentNameConflict,
168    root: &Path,
169) -> String {
170    format!(
171        "dynamic-segment-name-conflict:{}:{}",
172        relative_key_path(&item.path, root),
173        item.position
174    )
175}
176
177fn unlisted_dependency_key(item: &fallow_core::results::UnlistedDependency, root: &Path) -> String {
178    let mut sites = item
179        .imported_from
180        .iter()
181        .map(|site| {
182            format!(
183                "{}:{}:{}",
184                relative_key_path(&site.path, root),
185                site.line,
186                site.col
187            )
188        })
189        .collect::<Vec<_>>();
190    sites.sort();
191    sites.dedup();
192    format!(
193        "unlisted-dependency:{}:{}",
194        item.package_name,
195        sites.join("|")
196    )
197}
198
199fn unused_member_key(
200    rule_id: &str,
201    item: &fallow_core::results::UnusedMember,
202    root: &Path,
203) -> String {
204    format!(
205        "{}:{}:{}:{}",
206        rule_id,
207        relative_key_path(&item.path, root),
208        item.parent_name,
209        item.member_name
210    )
211}
212
213fn unused_catalog_entry_key(
214    item: &fallow_core::results::UnusedCatalogEntry,
215    root: &Path,
216) -> String {
217    format!(
218        "unused-catalog-entry:{}:{}:{}:{}",
219        relative_key_path(&item.path, root),
220        item.line,
221        item.catalog_name,
222        item.entry_name
223    )
224}
225
226fn empty_catalog_group_key(item: &fallow_core::results::EmptyCatalogGroup, root: &Path) -> String {
227    format!(
228        "empty-catalog-group:{}:{}:{}",
229        relative_key_path(&item.path, root),
230        item.line,
231        item.catalog_name
232    )
233}
234
235fn sorted_relative_path_keys<'a>(
236    paths: impl Iterator<Item = &'a Path>,
237    root: &Path,
238) -> Vec<String> {
239    let mut keys = paths
240        .map(|path| relative_key_path(path, root))
241        .collect::<Vec<_>>();
242    keys.sort();
243    keys
244}
245
246fn duplicate_export_key(
247    item: &fallow_core::results::DuplicateExportFinding,
248    root: &Path,
249) -> String {
250    let mut locations = sorted_relative_path_keys(
251        item.export.locations.iter().map(|loc| loc.path.as_path()),
252        root,
253    );
254    locations.dedup();
255    format!(
256        "duplicate-export:{}:{}",
257        item.export.export_name,
258        locations.join("|")
259    )
260}
261
262fn circular_dependency_key(
263    item: &fallow_core::results::CircularDependencyFinding,
264    root: &Path,
265) -> String {
266    let files = sorted_relative_path_keys(
267        item.cycle.files.iter().map(std::path::PathBuf::as_path),
268        root,
269    );
270    format!("circular-dependency:{}", files.join("|"))
271}
272
273fn re_export_cycle_key(item: &fallow_core::results::ReExportCycleFinding, root: &Path) -> String {
274    let kind = match item.cycle.kind {
275        fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
276        fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
277    };
278    let files = sorted_relative_path_keys(
279        item.cycle.files.iter().map(std::path::PathBuf::as_path),
280        root,
281    );
282    format!("re-export-cycle:{kind}:{}", files.join("|"))
283}
284
285fn boundary_violation_key(
286    item: &fallow_core::results::BoundaryViolationFinding,
287    root: &Path,
288) -> String {
289    format!(
290        "boundary-violation:{}:{}:{}",
291        relative_key_path(&item.violation.from_path, root),
292        relative_key_path(&item.violation.to_path, root),
293        item.violation.import_specifier
294    )
295}
296
297fn boundary_coverage_key(
298    item: &fallow_core::results::BoundaryCoverageViolationFinding,
299    root: &Path,
300) -> String {
301    format!(
302        "boundary-coverage:{}",
303        relative_key_path(&item.violation.path, root)
304    )
305}
306
307fn boundary_call_key(
308    item: &fallow_core::results::BoundaryCallViolationFinding,
309    root: &Path,
310) -> String {
311    format!(
312        "boundary-call:{}:{}",
313        relative_key_path(&item.violation.path, root),
314        item.violation.callee
315    )
316}
317
318fn policy_violation_key(
319    item: &fallow_core::results::PolicyViolationFinding,
320    root: &Path,
321) -> String {
322    format!(
323        "policy-violation:{}:{}/{}:{}",
324        relative_key_path(&item.violation.path, root),
325        item.violation.pack,
326        item.violation.rule_id,
327        item.violation.matched
328    )
329}
330
331fn stale_suppression_key(item: &fallow_core::results::StaleSuppression, root: &Path) -> String {
332    let rule_id = if item.missing_reason {
333        "missing-suppression-reason"
334    } else {
335        "stale-suppression"
336    };
337    format!(
338        "{rule_id}:{}:{}",
339        relative_key_path(&item.path, root),
340        item.description()
341    )
342}
343
344fn unresolved_catalog_reference_key(
345    item: &fallow_core::results::UnresolvedCatalogReferenceFinding,
346    root: &Path,
347) -> String {
348    format!(
349        "unresolved-catalog-reference:{}:{}:{}:{}",
350        relative_key_path(&item.reference.path, root),
351        item.reference.line,
352        item.reference.catalog_name,
353        item.reference.entry_name
354    )
355}
356
357fn unused_dependency_override_key(
358    item: &fallow_core::results::UnusedDependencyOverrideFinding,
359    root: &Path,
360) -> String {
361    format!(
362        "unused-dependency-override:{}:{}:{}",
363        relative_key_path(&item.entry.path, root),
364        item.entry.line,
365        item.entry.raw_key
366    )
367}
368
369fn misconfigured_dependency_override_key(
370    item: &fallow_core::results::MisconfiguredDependencyOverrideFinding,
371    root: &Path,
372) -> String {
373    format!(
374        "misconfigured-dependency-override:{}:{}:{}",
375        relative_key_path(&item.entry.path, root),
376        item.entry.line,
377        item.entry.raw_key
378    )
379}
380
381/// Build the set of audit attribution keys for all dead-code findings in
382/// `results`.
383///
384/// Each key is a stable string that uniquely identifies one finding across
385/// runs (e.g. `unused-file:src/dead.ts`, `unused-export:src/a.ts:Foo`).
386/// `retain_introduced_dead_code` and `annotate_dead_code_json` use the same
387/// key format to diff the current run against a base snapshot.
388///
389/// This destructure is deliberately exhaustive: adding a field to
390/// `AnalysisResults` must fail compilation here so the author decides
391/// explicitly whether the new finding type needs an audit key (add a loop)
392/// or has no key representation today (bind with underscore and document why).
393///
394/// Sibling exhaustive sites: `fallow_core::changed_files::filter_results_by_changed_files`,
395/// The six dependency-related finding slices, bundled so the dependency
396/// dispatcher takes one parameter instead of six.
397#[derive(Clone, Copy)]
398#[allow(
399    clippy::struct_field_names,
400    reason = "field names mirror the AnalysisResults field names so the destructure stays shorthand"
401)]
402struct DependencyFindingSlices<'a> {
403    unused_dependencies: &'a [fallow_core::results::UnusedDependencyFinding],
404    unused_dev_dependencies: &'a [fallow_core::results::UnusedDevDependencyFinding],
405    unused_optional_dependencies: &'a [fallow_core::results::UnusedOptionalDependencyFinding],
406    unlisted_dependencies: &'a [fallow_core::results::UnlistedDependencyFinding],
407    type_only_dependencies: &'a [fallow_core::results::TypeOnlyDependencyFinding],
408    test_only_dependencies: &'a [fallow_core::results::TestOnlyDependencyFinding],
409}
410
411/// The six framework-specific finding slices, bundled so the framework
412/// dispatcher takes one parameter instead of six.
413#[derive(Clone, Copy)]
414struct FrameworkFindingSlices<'a> {
415    unprovided_injects: &'a [fallow_core::results::UnprovidedInjectFinding],
416    unrendered_components: &'a [fallow_core::results::UnrenderedComponentFinding],
417    unused_server_actions: &'a [fallow_core::results::UnusedServerActionFinding],
418    unused_load_data_keys: &'a [fallow_core::results::UnusedLoadDataKeyFinding],
419    route_collisions: &'a [fallow_core::results::RouteCollisionFinding],
420    dynamic_segment_name_conflicts: &'a [fallow_core::results::DynamicSegmentNameConflictFinding],
421}
422
423/// `dead_code_keys`, `retain_introduced_dead_code`.
424/// Non-exhaustive siblings the compiler will NOT flag (wire manually when a
425/// finding type is added): `annotate_dead_code_json` (same key formats, this
426/// file) and the per-collection severity branches in
427/// `crates/cli/src/check/rules.rs` (`apply_rules`, `has_error_severity_issues`).
428/// TypeScript mirror: `editors/vscode/scripts/codegen-types.mjs` (`BARE_DEAD_CODE_ALIASES`).
429pub(super) fn dead_code_keys(
430    results: &fallow_core::results::AnalysisResults,
431    root: &Path,
432) -> FxHashSet<String> {
433    let mut collector = DeadCodeKeyCollector::new(root);
434    collector.add_all_findings(results);
435    collector.into_keys()
436}
437
438impl DeadCodeKeyCollector<'_> {
439    #[expect(
440        clippy::too_many_lines,
441        reason = "flat field-by-field destructure of the large AnalysisResults struct (with per-field provenance comments) plus straight-line dispatch; length tracks the field count, not branching"
442    )]
443    fn add_all_findings(&mut self, results: &fallow_core::results::AnalysisResults) {
444        let fallow_core::results::AnalysisResults {
445            unused_files,
446            unused_exports,
447            unused_types,
448            private_type_leaks,
449            unused_dependencies,
450            unused_dev_dependencies,
451            unused_optional_dependencies,
452            unused_enum_members,
453            unused_class_members,
454            unused_store_members,
455            unresolved_imports,
456            unlisted_dependencies,
457            duplicate_exports,
458            type_only_dependencies,
459            test_only_dependencies,
460            circular_dependencies,
461            re_export_cycles,
462            boundary_violations,
463            boundary_coverage_violations,
464            boundary_call_violations,
465            policy_violations,
466            stale_suppressions,
467            unused_catalog_entries,
468            empty_catalog_groups,
469            unresolved_catalog_references,
470            unused_dependency_overrides,
471            misconfigured_dependency_overrides,
472            invalid_client_exports,
473            mixed_client_server_barrels,
474            misplaced_directives,
475            unprovided_injects,
476            unrendered_components,
477            unused_component_props,
478            unused_component_emits,
479            unused_component_inputs,
480            unused_component_outputs,
481            unused_svelte_events,
482            unused_server_actions,
483            unused_load_data_keys,
484            unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
485            route_collisions,
486            dynamic_segment_name_conflicts,
487            // Non-finding fields: counts and metadata, not attributable to a key.
488            suppression_count: _suppression_count,
489            active_suppressions: _active_suppressions,
490            feature_flags: _feature_flags,
491            // Security findings are emitted via `fallow security`, not the audit
492            // dead-code gate; they have no dead-code key representation today.
493            security_findings: _security_findings,
494            security_unresolved_edge_files: _security_unresolved_edge_files,
495            security_unresolved_callee_sites: _security_unresolved_callee_sites,
496            security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
497            // Prop-drilling is a dormant multi-file health signal (rule defaults to
498            // `off`); like security findings it has no dead-code attribution key.
499            prop_drilling_chains: _prop_drilling_chains,
500            // Thin wrappers are a dormant health signal (rule defaults to `off`); a
501            // candidate-for-inlining record, not a dead-code attribution key.
502            thin_wrappers: _thin_wrappers,
503            // Duplicate prop shapes are a dormant multi-file health signal (rule
504            // defaults to `off`); a missing-abstraction record, not a dead-code
505            // attribution key.
506            duplicate_prop_shapes: _duplicate_prop_shapes,
507            // Export usages and entry-point summary are metadata, not issue
508            // collections; no key needed.
509            export_usages: _export_usages,
510            entry_point_summary: _entry_point_summary,
511            // Render fan-in is a whole-project descriptive metric, not an issue
512            // collection; no attribution key needed.
513            render_fan_in: _render_fan_in,
514            // Per-component React intel is a descriptive ambient-editor carrier,
515            // not an issue collection; no attribution key needed.
516            react_component_intel: _react_component_intel,
517        } = results;
518
519        self.add_core_findings(
520            unused_files,
521            unused_exports,
522            unused_types,
523            private_type_leaks,
524        );
525        self.add_client_directive_findings(
526            invalid_client_exports,
527            mixed_client_server_barrels,
528            misplaced_directives,
529        );
530        self.add_dependency_findings(&DependencyFindingSlices {
531            unused_dependencies,
532            unused_dev_dependencies,
533            unused_optional_dependencies,
534            unlisted_dependencies,
535            type_only_dependencies,
536            test_only_dependencies,
537        });
538        self.add_dependency_override_findings(
539            unused_dependency_overrides,
540            misconfigured_dependency_overrides,
541        );
542        self.add_member_findings(
543            unused_enum_members,
544            unused_class_members,
545            unused_store_members,
546        );
547        self.add_component_contract_findings(
548            unused_component_props,
549            unused_component_emits,
550            unused_component_inputs,
551            unused_component_outputs,
552            unused_svelte_events,
553        );
554        self.add_graph_findings(
555            unresolved_imports,
556            duplicate_exports,
557            circular_dependencies,
558            re_export_cycles,
559        );
560        self.add_boundary_findings(
561            boundary_violations,
562            boundary_coverage_violations,
563            boundary_call_violations,
564            policy_violations,
565            stale_suppressions,
566        );
567        self.add_catalog_findings(
568            unresolved_catalog_references,
569            unused_catalog_entries,
570            empty_catalog_groups,
571        );
572        self.add_framework_findings(&FrameworkFindingSlices {
573            unprovided_injects,
574            unrendered_components,
575            unused_server_actions,
576            unused_load_data_keys,
577            route_collisions,
578            dynamic_segment_name_conflicts,
579        });
580    }
581}
582
583struct DeadCodeKeyCollector<'a> {
584    root: &'a Path,
585    keys: FxHashSet<String>,
586}
587
588impl<'a> DeadCodeKeyCollector<'a> {
589    fn new(root: &'a Path) -> Self {
590        Self {
591            root,
592            keys: FxHashSet::default(),
593        }
594    }
595
596    fn into_keys(self) -> FxHashSet<String> {
597        self.keys
598    }
599
600    fn insert(&mut self, key: String) {
601        self.keys.insert(key);
602    }
603
604    fn add_core_findings(
605        &mut self,
606        unused_files: &[fallow_core::results::UnusedFileFinding],
607        unused_exports: &[fallow_core::results::UnusedExportFinding],
608        unused_types: &[fallow_core::results::UnusedTypeFinding],
609        private_type_leaks: &[fallow_core::results::PrivateTypeLeakFinding],
610    ) {
611        self.add_unused_files(unused_files);
612        self.add_unused_exports(unused_exports);
613        self.add_unused_types(unused_types);
614        self.add_private_type_leaks(private_type_leaks);
615    }
616
617    fn add_client_directive_findings(
618        &mut self,
619        invalid_client_exports: &[fallow_core::results::InvalidClientExportFinding],
620        mixed_client_server_barrels: &[fallow_core::results::MixedClientServerBarrelFinding],
621        misplaced_directives: &[fallow_core::results::MisplacedDirectiveFinding],
622    ) {
623        self.add_invalid_client_exports(invalid_client_exports);
624        self.add_mixed_client_server_barrels(mixed_client_server_barrels);
625        self.add_misplaced_directives(misplaced_directives);
626    }
627
628    fn add_dependency_findings(&mut self, deps: &DependencyFindingSlices<'_>) {
629        let DependencyFindingSlices {
630            unused_dependencies,
631            unused_dev_dependencies,
632            unused_optional_dependencies,
633            unlisted_dependencies,
634            type_only_dependencies,
635            test_only_dependencies,
636        } = *deps;
637        self.add_unused_dependencies(unused_dependencies);
638        self.add_unused_dev_dependencies(unused_dev_dependencies);
639        self.add_unused_optional_dependencies(unused_optional_dependencies);
640        self.add_unlisted_dependencies(unlisted_dependencies);
641        self.add_type_only_dependencies(type_only_dependencies);
642        self.add_test_only_dependencies(test_only_dependencies);
643    }
644
645    fn add_dependency_override_findings(
646        &mut self,
647        unused_dependency_overrides: &[fallow_core::results::UnusedDependencyOverrideFinding],
648        misconfigured_dependency_overrides: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
649    ) {
650        self.add_unused_dependency_overrides(unused_dependency_overrides);
651        self.add_misconfigured_dependency_overrides(misconfigured_dependency_overrides);
652    }
653
654    fn add_member_findings(
655        &mut self,
656        unused_enum_members: &[fallow_core::results::UnusedEnumMemberFinding],
657        unused_class_members: &[fallow_core::results::UnusedClassMemberFinding],
658        unused_store_members: &[fallow_core::results::UnusedStoreMemberFinding],
659    ) {
660        self.add_unused_enum_members(unused_enum_members);
661        self.add_unused_class_members(unused_class_members);
662        self.add_unused_store_members(unused_store_members);
663    }
664
665    fn add_component_contract_findings(
666        &mut self,
667        unused_component_props: &[fallow_core::results::UnusedComponentPropFinding],
668        unused_component_emits: &[fallow_core::results::UnusedComponentEmitFinding],
669        unused_component_inputs: &[fallow_core::results::UnusedComponentInputFinding],
670        unused_component_outputs: &[fallow_core::results::UnusedComponentOutputFinding],
671        unused_svelte_events: &[fallow_core::results::UnusedSvelteEventFinding],
672    ) {
673        self.add_unused_component_props(unused_component_props);
674        self.add_unused_component_emits(unused_component_emits);
675        self.add_unused_component_inputs(unused_component_inputs);
676        self.add_unused_component_outputs(unused_component_outputs);
677        self.add_unused_svelte_events(unused_svelte_events);
678    }
679
680    fn add_graph_findings(
681        &mut self,
682        unresolved_imports: &[fallow_core::results::UnresolvedImportFinding],
683        duplicate_exports: &[fallow_core::results::DuplicateExportFinding],
684        circular_dependencies: &[fallow_core::results::CircularDependencyFinding],
685        re_export_cycles: &[fallow_core::results::ReExportCycleFinding],
686    ) {
687        self.add_unresolved_imports(unresolved_imports);
688        self.add_duplicate_exports(duplicate_exports);
689        self.add_circular_dependencies(circular_dependencies);
690        self.add_re_export_cycles(re_export_cycles);
691    }
692
693    fn add_boundary_findings(
694        &mut self,
695        boundary_violations: &[fallow_core::results::BoundaryViolationFinding],
696        boundary_coverage_violations: &[fallow_core::results::BoundaryCoverageViolationFinding],
697        boundary_call_violations: &[fallow_core::results::BoundaryCallViolationFinding],
698        policy_violations: &[fallow_core::results::PolicyViolationFinding],
699        stale_suppressions: &[fallow_core::results::StaleSuppression],
700    ) {
701        self.add_boundary_violations(boundary_violations);
702        self.add_boundary_coverage_violations(boundary_coverage_violations);
703        self.add_boundary_call_violations(boundary_call_violations);
704        self.add_policy_violations(policy_violations);
705        self.add_stale_suppressions(stale_suppressions);
706    }
707
708    fn add_catalog_findings(
709        &mut self,
710        unresolved_catalog_references: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
711        unused_catalog_entries: &[fallow_core::results::UnusedCatalogEntryFinding],
712        empty_catalog_groups: &[fallow_core::results::EmptyCatalogGroupFinding],
713    ) {
714        self.add_unresolved_catalog_references(unresolved_catalog_references);
715        self.add_unused_catalog_entries(unused_catalog_entries);
716        self.add_empty_catalog_groups(empty_catalog_groups);
717    }
718
719    fn add_framework_findings(&mut self, framework: &FrameworkFindingSlices<'_>) {
720        let FrameworkFindingSlices {
721            unprovided_injects,
722            unrendered_components,
723            unused_server_actions,
724            unused_load_data_keys,
725            route_collisions,
726            dynamic_segment_name_conflicts,
727        } = *framework;
728        self.add_unprovided_injects(unprovided_injects);
729        self.add_unrendered_components(unrendered_components);
730        self.add_unused_server_actions(unused_server_actions);
731        self.add_unused_load_data_keys(unused_load_data_keys);
732        self.add_route_collisions(route_collisions);
733        self.add_dynamic_segment_name_conflicts(dynamic_segment_name_conflicts);
734    }
735
736    fn add_unused_files(&mut self, items: &[fallow_core::results::UnusedFileFinding]) {
737        for item in items {
738            self.insert(format!(
739                "unused-file:{}",
740                relative_key_path(&item.file.path, self.root)
741            ));
742        }
743    }
744
745    fn add_unused_exports(&mut self, items: &[fallow_core::results::UnusedExportFinding]) {
746        for item in items {
747            self.insert(format!(
748                "unused-export:{}:{}",
749                relative_key_path(&item.export.path, self.root),
750                item.export.export_name
751            ));
752        }
753    }
754
755    fn add_unused_types(&mut self, items: &[fallow_core::results::UnusedTypeFinding]) {
756        for item in items {
757            self.insert(format!(
758                "unused-type:{}:{}",
759                relative_key_path(&item.export.path, self.root),
760                item.export.export_name
761            ));
762        }
763    }
764
765    fn add_private_type_leaks(&mut self, items: &[fallow_core::results::PrivateTypeLeakFinding]) {
766        for item in items {
767            self.insert(format!(
768                "private-type-leak:{}:{}:{}",
769                relative_key_path(&item.leak.path, self.root),
770                item.leak.export_name,
771                item.leak.type_name
772            ));
773        }
774    }
775
776    fn add_invalid_client_exports(
777        &mut self,
778        items: &[fallow_core::results::InvalidClientExportFinding],
779    ) {
780        for item in items {
781            self.insert(invalid_client_export_key(&item.export, self.root));
782        }
783    }
784
785    fn add_mixed_client_server_barrels(
786        &mut self,
787        items: &[fallow_core::results::MixedClientServerBarrelFinding],
788    ) {
789        for item in items {
790            self.insert(mixed_client_server_barrel_key(&item.barrel, self.root));
791        }
792    }
793
794    fn add_misplaced_directives(
795        &mut self,
796        items: &[fallow_core::results::MisplacedDirectiveFinding],
797    ) {
798        for item in items {
799            self.insert(misplaced_directive_key(&item.directive_site, self.root));
800        }
801    }
802
803    fn add_unprovided_injects(&mut self, items: &[fallow_core::results::UnprovidedInjectFinding]) {
804        for item in items {
805            self.insert(unprovided_inject_key(&item.inject, self.root));
806        }
807    }
808
809    fn add_unrendered_components(
810        &mut self,
811        items: &[fallow_core::results::UnrenderedComponentFinding],
812    ) {
813        for item in items {
814            self.insert(unrendered_component_key(&item.component, self.root));
815        }
816    }
817
818    fn add_unused_component_props(
819        &mut self,
820        items: &[fallow_core::results::UnusedComponentPropFinding],
821    ) {
822        for item in items {
823            self.insert(unused_component_prop_key(&item.prop, self.root));
824        }
825    }
826
827    fn add_unused_component_emits(
828        &mut self,
829        items: &[fallow_core::results::UnusedComponentEmitFinding],
830    ) {
831        for item in items {
832            self.insert(unused_component_emit_key(&item.emit, self.root));
833        }
834    }
835
836    fn add_unused_component_inputs(
837        &mut self,
838        items: &[fallow_core::results::UnusedComponentInputFinding],
839    ) {
840        for item in items {
841            self.insert(unused_component_input_key(&item.input, self.root));
842        }
843    }
844
845    fn add_unused_component_outputs(
846        &mut self,
847        items: &[fallow_core::results::UnusedComponentOutputFinding],
848    ) {
849        for item in items {
850            self.insert(unused_component_output_key(&item.output, self.root));
851        }
852    }
853
854    fn add_unused_svelte_events(
855        &mut self,
856        items: &[fallow_core::results::UnusedSvelteEventFinding],
857    ) {
858        for item in items {
859            self.insert(unused_svelte_event_key(&item.event, self.root));
860        }
861    }
862
863    fn add_unused_server_actions(
864        &mut self,
865        items: &[fallow_core::results::UnusedServerActionFinding],
866    ) {
867        for item in items {
868            self.insert(unused_server_action_key(&item.action, self.root));
869        }
870    }
871
872    fn add_unused_load_data_keys(
873        &mut self,
874        items: &[fallow_core::results::UnusedLoadDataKeyFinding],
875    ) {
876        for item in items {
877            self.insert(unused_load_data_key_key(&item.key, self.root));
878        }
879    }
880
881    fn add_route_collisions(&mut self, items: &[fallow_core::results::RouteCollisionFinding]) {
882        for item in items {
883            self.insert(route_collision_key(&item.collision, self.root));
884        }
885    }
886
887    fn add_dynamic_segment_name_conflicts(
888        &mut self,
889        items: &[fallow_core::results::DynamicSegmentNameConflictFinding],
890    ) {
891        for item in items {
892            self.insert(dynamic_segment_name_conflict_key(&item.conflict, self.root));
893        }
894    }
895
896    fn add_unused_dependencies(&mut self, items: &[fallow_core::results::UnusedDependencyFinding]) {
897        for item in items {
898            self.insert(unused_dependency_key(&item.dep, self.root));
899        }
900    }
901
902    fn add_unused_dev_dependencies(
903        &mut self,
904        items: &[fallow_core::results::UnusedDevDependencyFinding],
905    ) {
906        for item in items {
907            self.insert(unused_dependency_key(&item.dep, self.root));
908        }
909    }
910
911    fn add_unused_optional_dependencies(
912        &mut self,
913        items: &[fallow_core::results::UnusedOptionalDependencyFinding],
914    ) {
915        for item in items {
916            self.insert(unused_dependency_key(&item.dep, self.root));
917        }
918    }
919
920    fn add_unused_enum_members(&mut self, items: &[fallow_core::results::UnusedEnumMemberFinding]) {
921        for item in items {
922            self.insert(unused_member_key(
923                "unused-enum-member",
924                &item.member,
925                self.root,
926            ));
927        }
928    }
929
930    fn add_unused_class_members(
931        &mut self,
932        items: &[fallow_core::results::UnusedClassMemberFinding],
933    ) {
934        for item in items {
935            self.insert(unused_member_key(
936                "unused-class-member",
937                &item.member,
938                self.root,
939            ));
940        }
941    }
942
943    fn add_unused_store_members(
944        &mut self,
945        items: &[fallow_core::results::UnusedStoreMemberFinding],
946    ) {
947        for item in items {
948            self.insert(unused_member_key(
949                "unused-store-member",
950                &item.member,
951                self.root,
952            ));
953        }
954    }
955
956    fn add_unresolved_imports(&mut self, items: &[fallow_core::results::UnresolvedImportFinding]) {
957        for item in items {
958            self.insert(format!(
959                "unresolved-import:{}:{}",
960                relative_key_path(&item.import.path, self.root),
961                item.import.specifier
962            ));
963        }
964    }
965
966    fn add_unlisted_dependencies(
967        &mut self,
968        items: &[fallow_core::results::UnlistedDependencyFinding],
969    ) {
970        for item in items {
971            self.insert(unlisted_dependency_key(&item.dep, self.root));
972        }
973    }
974
975    fn add_duplicate_exports(&mut self, items: &[fallow_core::results::DuplicateExportFinding]) {
976        for item in items {
977            self.insert(duplicate_export_key(item, self.root));
978        }
979    }
980
981    fn add_type_only_dependencies(
982        &mut self,
983        items: &[fallow_core::results::TypeOnlyDependencyFinding],
984    ) {
985        for item in items {
986            self.insert(format!(
987                "type-only-dependency:{}:{}",
988                relative_key_path(&item.dep.path, self.root),
989                item.dep.package_name
990            ));
991        }
992    }
993
994    fn add_test_only_dependencies(
995        &mut self,
996        items: &[fallow_core::results::TestOnlyDependencyFinding],
997    ) {
998        for item in items {
999            self.insert(format!(
1000                "test-only-dependency:{}:{}",
1001                relative_key_path(&item.dep.path, self.root),
1002                item.dep.package_name
1003            ));
1004        }
1005    }
1006
1007    fn add_circular_dependencies(
1008        &mut self,
1009        items: &[fallow_core::results::CircularDependencyFinding],
1010    ) {
1011        for item in items {
1012            self.insert(circular_dependency_key(item, self.root));
1013        }
1014    }
1015
1016    fn add_re_export_cycles(&mut self, items: &[fallow_core::results::ReExportCycleFinding]) {
1017        for item in items {
1018            self.insert(re_export_cycle_key(item, self.root));
1019        }
1020    }
1021
1022    fn add_boundary_violations(
1023        &mut self,
1024        items: &[fallow_core::results::BoundaryViolationFinding],
1025    ) {
1026        for item in items {
1027            self.insert(boundary_violation_key(item, self.root));
1028        }
1029    }
1030
1031    fn add_boundary_coverage_violations(
1032        &mut self,
1033        items: &[fallow_core::results::BoundaryCoverageViolationFinding],
1034    ) {
1035        for item in items {
1036            self.insert(boundary_coverage_key(item, self.root));
1037        }
1038    }
1039
1040    fn add_boundary_call_violations(
1041        &mut self,
1042        items: &[fallow_core::results::BoundaryCallViolationFinding],
1043    ) {
1044        for item in items {
1045            self.insert(boundary_call_key(item, self.root));
1046        }
1047    }
1048
1049    fn add_policy_violations(&mut self, items: &[fallow_core::results::PolicyViolationFinding]) {
1050        for item in items {
1051            self.insert(policy_violation_key(item, self.root));
1052        }
1053    }
1054
1055    fn add_stale_suppressions(&mut self, items: &[fallow_core::results::StaleSuppression]) {
1056        for item in items {
1057            self.insert(stale_suppression_key(item, self.root));
1058        }
1059    }
1060
1061    fn add_unresolved_catalog_references(
1062        &mut self,
1063        items: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1064    ) {
1065        for item in items {
1066            self.insert(unresolved_catalog_reference_key(item, self.root));
1067        }
1068    }
1069
1070    fn add_unused_catalog_entries(
1071        &mut self,
1072        items: &[fallow_core::results::UnusedCatalogEntryFinding],
1073    ) {
1074        for item in items {
1075            self.insert(unused_catalog_entry_key(&item.entry, self.root));
1076        }
1077    }
1078
1079    fn add_empty_catalog_groups(
1080        &mut self,
1081        items: &[fallow_core::results::EmptyCatalogGroupFinding],
1082    ) {
1083        for item in items {
1084            self.insert(empty_catalog_group_key(&item.group, self.root));
1085        }
1086    }
1087
1088    fn add_unused_dependency_overrides(
1089        &mut self,
1090        items: &[fallow_core::results::UnusedDependencyOverrideFinding],
1091    ) {
1092        for item in items {
1093            self.insert(unused_dependency_override_key(item, self.root));
1094        }
1095    }
1096
1097    fn add_misconfigured_dependency_overrides(
1098        &mut self,
1099        items: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1100    ) {
1101        for item in items {
1102            self.insert(misconfigured_dependency_override_key(item, self.root));
1103        }
1104    }
1105}
1106
1107/// Retain only findings whose audit key was NOT present in `base` (i.e. was
1108/// introduced on the current branch).
1109///
1110/// When `base` is `None` (no baseline), all findings are kept.
1111///
1112/// This destructure is deliberately exhaustive: adding a field to
1113/// `AnalysisResults` must fail compilation here so the author decides
1114/// explicitly whether the new finding type needs an introduced-retain (add a
1115/// retain block) or has no key representation today (bind with underscore and
1116/// document why).
1117///
1118/// Sibling exhaustive sites: `fallow_core::changed_files::filter_results_by_changed_files`,
1119/// `dead_code_keys`, `retain_introduced_dead_code`.
1120/// Non-exhaustive siblings the compiler will NOT flag (wire manually when a
1121/// finding type is added): `annotate_dead_code_json` (same key formats, this
1122/// file) and the per-collection severity branches in
1123/// `crates/cli/src/check/rules.rs` (`apply_rules`, `has_error_severity_issues`).
1124/// TypeScript mirror: `editors/vscode/scripts/codegen-types.mjs` (`BARE_DEAD_CODE_ALIASES`).
1125pub(super) fn retain_introduced_dead_code(
1126    results: &mut fallow_core::results::AnalysisResults,
1127    root: &Path,
1128    base: Option<&FxHashSet<String>>,
1129) {
1130    let Some(base) = base else {
1131        return;
1132    };
1133
1134    // Compute the introduced set before taking any mutable borrows. Note the
1135    // order differs from the pre-destructure code, which narrowed
1136    // unused_files/exports/types first and computed keys from the narrowed
1137    // results. Computing from the un-narrowed results is equivalent: those
1138    // retains keep exactly the items whose key is NOT in `base`, and the
1139    // `!base.contains(key)` filter below removes the same base-member keys
1140    // from the full key set, so `introduced` is identical either way.
1141    let introduced = introduced_dead_code_keys(results, root, base);
1142    classify_introduced_dead_code_fields(results);
1143
1144    // The three "fast path" retains use a direct base-lookup rather than the
1145    // introduced set; both predicates are equivalent for these collections
1146    // (see the `introduced` comment above), so this preserves the original
1147    // behavior.
1148    retain_introduced_fast_paths(
1149        &mut results.unused_files,
1150        &mut results.unused_exports,
1151        &mut results.unused_types,
1152        root,
1153        base,
1154    );
1155    retain_introduced_core_findings(results, root, &introduced);
1156    retain_introduced_dependency_and_graph_findings(results, root, &introduced);
1157    retain_introduced_workspace_findings(results, root, &introduced);
1158    retain_introduced_framework_findings(results, root, &introduced);
1159}
1160
1161fn introduced_dead_code_keys(
1162    results: &fallow_core::results::AnalysisResults,
1163    root: &Path,
1164    base: &FxHashSet<String>,
1165) -> FxHashSet<String> {
1166    dead_code_keys(results, root)
1167        .into_iter()
1168        .filter(|key| !base.contains(key))
1169        .collect()
1170}
1171
1172fn classify_introduced_dead_code_fields(results: &fallow_core::results::AnalysisResults) {
1173    let fallow_core::results::AnalysisResults {
1174        unused_files: _unused_files,
1175        unused_exports: _unused_exports,
1176        unused_types: _unused_types,
1177        private_type_leaks: _private_type_leaks,
1178        unused_dependencies: _unused_dependencies,
1179        unused_dev_dependencies: _unused_dev_dependencies,
1180        unused_optional_dependencies: _unused_optional_dependencies,
1181        unused_enum_members: _unused_enum_members,
1182        unused_class_members: _unused_class_members,
1183        unused_store_members: _unused_store_members,
1184        unresolved_imports: _unresolved_imports,
1185        unlisted_dependencies: _unlisted_dependencies,
1186        duplicate_exports: _duplicate_exports,
1187        type_only_dependencies: _type_only_dependencies,
1188        test_only_dependencies: _test_only_dependencies,
1189        circular_dependencies: _circular_dependencies,
1190        re_export_cycles: _re_export_cycles,
1191        boundary_violations: _boundary_violations,
1192        boundary_coverage_violations: _boundary_coverage_violations,
1193        boundary_call_violations: _boundary_call_violations,
1194        policy_violations: _policy_violations,
1195        stale_suppressions: _stale_suppressions,
1196        unused_catalog_entries: _unused_catalog_entries,
1197        empty_catalog_groups: _empty_catalog_groups,
1198        unresolved_catalog_references: _unresolved_catalog_references,
1199        unused_dependency_overrides: _unused_dependency_overrides,
1200        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
1201        invalid_client_exports: _invalid_client_exports,
1202        mixed_client_server_barrels: _mixed_client_server_barrels,
1203        misplaced_directives: _misplaced_directives,
1204        unprovided_injects: _unprovided_injects,
1205        unrendered_components: _unrendered_components,
1206        unused_component_props: _unused_component_props,
1207        unused_component_emits: _unused_component_emits,
1208        unused_component_inputs: _unused_component_inputs,
1209        unused_component_outputs: _unused_component_outputs,
1210        unused_svelte_events: _unused_svelte_events,
1211        unused_server_actions: _unused_server_actions,
1212        unused_load_data_keys: _unused_load_data_keys,
1213        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
1214        route_collisions: _route_collisions,
1215        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
1216        // Non-finding fields: counts and metadata, not subject to base-keyed
1217        // filtering.
1218        suppression_count: _suppression_count,
1219        active_suppressions: _active_suppressions,
1220        feature_flags: _feature_flags,
1221        // Security findings are emitted via `fallow security`, not the audit
1222        // dead-code gate; they have no key representation and are not filtered
1223        // here.
1224        security_findings: _security_findings,
1225        security_unresolved_edge_files: _security_unresolved_edge_files,
1226        security_unresolved_callee_sites: _security_unresolved_callee_sites,
1227        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
1228        // Prop-drilling is a dormant multi-file health signal (rule defaults to
1229        // `off`); it carries no dead-code key and is not base-filtered here.
1230        prop_drilling_chains: _prop_drilling_chains,
1231        // Thin wrappers are a dormant health signal (rule defaults to `off`);
1232        // no dead-code key and not base-filtered here.
1233        thin_wrappers: _thin_wrappers,
1234        // Duplicate prop shapes are a dormant multi-file health signal (rule
1235        // defaults to `off`); no dead-code key and not base-filtered here.
1236        duplicate_prop_shapes: _duplicate_prop_shapes,
1237        // Export usages and entry-point summary are metadata, not issue
1238        // collections; no key needed.
1239        export_usages: _export_usages,
1240        entry_point_summary: _entry_point_summary,
1241        // Render fan-in is a whole-project descriptive metric, not an issue
1242        // collection; no key needed.
1243        render_fan_in: _render_fan_in,
1244        // Per-component React intel is a descriptive ambient-editor carrier, not
1245        // an issue collection; no key needed.
1246        react_component_intel: _react_component_intel,
1247    } = results;
1248}
1249
1250fn retain_introduced_fast_paths(
1251    unused_files: &mut Vec<fallow_core::results::UnusedFileFinding>,
1252    unused_exports: &mut Vec<fallow_core::results::UnusedExportFinding>,
1253    unused_types: &mut Vec<fallow_core::results::UnusedTypeFinding>,
1254    root: &Path,
1255    base: &FxHashSet<String>,
1256) {
1257    unused_files.retain(|item| {
1258        !base.contains(&format!(
1259            "unused-file:{}",
1260            relative_key_path(&item.file.path, root)
1261        ))
1262    });
1263    unused_exports.retain(|item| {
1264        !base.contains(&format!(
1265            "unused-export:{}:{}",
1266            relative_key_path(&item.export.path, root),
1267            item.export.export_name
1268        ))
1269    });
1270    unused_types.retain(|item| {
1271        !base.contains(&format!(
1272            "unused-type:{}:{}",
1273            relative_key_path(&item.export.path, root),
1274            item.export.export_name
1275        ))
1276    });
1277}
1278
1279fn keep_introduced(introduced: &FxHashSet<String>, key: impl AsRef<str>) -> bool {
1280    introduced.contains(key.as_ref())
1281}
1282
1283fn retain_introduced_core_findings(
1284    results: &mut fallow_core::results::AnalysisResults,
1285    root: &Path,
1286    introduced: &FxHashSet<String>,
1287) {
1288    results.private_type_leaks.retain(|item| {
1289        keep_introduced(
1290            introduced,
1291            format!(
1292                "private-type-leak:{}:{}:{}",
1293                relative_key_path(&item.leak.path, root),
1294                item.leak.export_name,
1295                item.leak.type_name
1296            ),
1297        )
1298    });
1299    results.unused_enum_members.retain(|item| {
1300        keep_introduced(
1301            introduced,
1302            unused_member_key("unused-enum-member", &item.member, root),
1303        )
1304    });
1305    results.unused_class_members.retain(|item| {
1306        keep_introduced(
1307            introduced,
1308            unused_member_key("unused-class-member", &item.member, root),
1309        )
1310    });
1311    results.unused_store_members.retain(|item| {
1312        keep_introduced(
1313            introduced,
1314            unused_member_key("unused-store-member", &item.member, root),
1315        )
1316    });
1317    results.unresolved_imports.retain(|item| {
1318        keep_introduced(
1319            introduced,
1320            format!(
1321                "unresolved-import:{}:{}",
1322                relative_key_path(&item.import.path, root),
1323                item.import.specifier
1324            ),
1325        )
1326    });
1327}
1328
1329fn retain_introduced_dependency_and_graph_findings(
1330    results: &mut fallow_core::results::AnalysisResults,
1331    root: &Path,
1332    introduced: &FxHashSet<String>,
1333) {
1334    results
1335        .unused_dependencies
1336        .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
1337    results
1338        .unused_dev_dependencies
1339        .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
1340    results
1341        .unused_optional_dependencies
1342        .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
1343    results
1344        .unlisted_dependencies
1345        .retain(|item| keep_introduced(introduced, unlisted_dependency_key(&item.dep, root)));
1346    results
1347        .duplicate_exports
1348        .retain(|item| keep_introduced(introduced, duplicate_export_key(item, root)));
1349    results.type_only_dependencies.retain(|item| {
1350        keep_introduced(
1351            introduced,
1352            format!(
1353                "type-only-dependency:{}:{}",
1354                relative_key_path(&item.dep.path, root),
1355                item.dep.package_name
1356            ),
1357        )
1358    });
1359    results.test_only_dependencies.retain(|item| {
1360        keep_introduced(
1361            introduced,
1362            format!(
1363                "test-only-dependency:{}:{}",
1364                relative_key_path(&item.dep.path, root),
1365                item.dep.package_name
1366            ),
1367        )
1368    });
1369    results
1370        .circular_dependencies
1371        .retain(|item| keep_introduced(introduced, circular_dependency_key(item, root)));
1372    results
1373        .re_export_cycles
1374        .retain(|item| keep_introduced(introduced, re_export_cycle_key(item, root)));
1375    results
1376        .boundary_violations
1377        .retain(|item| keep_introduced(introduced, boundary_violation_key(item, root)));
1378    results
1379        .boundary_coverage_violations
1380        .retain(|item| keep_introduced(introduced, boundary_coverage_key(item, root)));
1381    results
1382        .boundary_call_violations
1383        .retain(|item| keep_introduced(introduced, boundary_call_key(item, root)));
1384    results
1385        .policy_violations
1386        .retain(|item| keep_introduced(introduced, policy_violation_key(item, root)));
1387    results
1388        .stale_suppressions
1389        .retain(|item| keep_introduced(introduced, stale_suppression_key(item, root)));
1390}
1391
1392fn retain_introduced_workspace_findings(
1393    results: &mut fallow_core::results::AnalysisResults,
1394    root: &Path,
1395    introduced: &FxHashSet<String>,
1396) {
1397    results
1398        .unresolved_catalog_references
1399        .retain(|item| keep_introduced(introduced, unresolved_catalog_reference_key(item, root)));
1400    results
1401        .unused_catalog_entries
1402        .retain(|item| keep_introduced(introduced, unused_catalog_entry_key(&item.entry, root)));
1403    results
1404        .empty_catalog_groups
1405        .retain(|item| keep_introduced(introduced, empty_catalog_group_key(&item.group, root)));
1406    results
1407        .unused_dependency_overrides
1408        .retain(|item| keep_introduced(introduced, unused_dependency_override_key(item, root)));
1409    results.misconfigured_dependency_overrides.retain(|item| {
1410        keep_introduced(
1411            introduced,
1412            misconfigured_dependency_override_key(item, root),
1413        )
1414    });
1415}
1416
1417fn retain_introduced_framework_findings(
1418    results: &mut fallow_core::results::AnalysisResults,
1419    root: &Path,
1420    introduced: &FxHashSet<String>,
1421) {
1422    results
1423        .invalid_client_exports
1424        .retain(|item| keep_introduced(introduced, invalid_client_export_key(&item.export, root)));
1425    results.mixed_client_server_barrels.retain(|item| {
1426        keep_introduced(
1427            introduced,
1428            mixed_client_server_barrel_key(&item.barrel, root),
1429        )
1430    });
1431    results.misplaced_directives.retain(|item| {
1432        keep_introduced(
1433            introduced,
1434            misplaced_directive_key(&item.directive_site, root),
1435        )
1436    });
1437    results
1438        .unprovided_injects
1439        .retain(|item| keep_introduced(introduced, unprovided_inject_key(&item.inject, root)));
1440    results.unrendered_components.retain(|item| {
1441        keep_introduced(introduced, unrendered_component_key(&item.component, root))
1442    });
1443    results
1444        .unused_component_props
1445        .retain(|item| keep_introduced(introduced, unused_component_prop_key(&item.prop, root)));
1446    results
1447        .unused_component_emits
1448        .retain(|item| keep_introduced(introduced, unused_component_emit_key(&item.emit, root)));
1449    results
1450        .unused_component_inputs
1451        .retain(|item| keep_introduced(introduced, unused_component_input_key(&item.input, root)));
1452    results.unused_component_outputs.retain(|item| {
1453        keep_introduced(introduced, unused_component_output_key(&item.output, root))
1454    });
1455    results
1456        .unused_svelte_events
1457        .retain(|item| keep_introduced(introduced, unused_svelte_event_key(&item.event, root)));
1458    results
1459        .unused_server_actions
1460        .retain(|item| keep_introduced(introduced, unused_server_action_key(&item.action, root)));
1461    results
1462        .unused_load_data_keys
1463        .retain(|item| keep_introduced(introduced, unused_load_data_key_key(&item.key, root)));
1464    results
1465        .route_collisions
1466        .retain(|item| keep_introduced(introduced, route_collision_key(&item.collision, root)));
1467    results.dynamic_segment_name_conflicts.retain(|item| {
1468        keep_introduced(
1469            introduced,
1470            dynamic_segment_name_conflict_key(&item.conflict, root),
1471        )
1472    });
1473}
1474
1475fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
1476    !base.contains(key)
1477}
1478
1479fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
1480where
1481    I: IntoIterator<Item = bool>,
1482{
1483    let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
1484        return;
1485    };
1486    for (item, introduced) in items.iter_mut().zip(introduced) {
1487        if let serde_json::Value::Object(map) = item {
1488            map.insert("introduced".to_string(), serde_json::json!(introduced));
1489        }
1490    }
1491}
1492
1493pub(super) fn annotate_dead_code_json(
1494    json: &mut serde_json::Value,
1495    results: &fallow_core::results::AnalysisResults,
1496    root: &Path,
1497    base: &FxHashSet<String>,
1498) {
1499    let mut annotator = DeadCodeJsonAnnotator {
1500        json,
1501        results,
1502        root,
1503        base,
1504    };
1505    annotator.annotate_file_symbols();
1506    annotator.annotate_dependencies();
1507    annotator.annotate_members();
1508    annotator.annotate_imports_and_exports();
1509    annotator.annotate_graph();
1510    annotator.annotate_catalog();
1511}
1512
1513struct DeadCodeJsonAnnotator<'a> {
1514    json: &'a mut serde_json::Value,
1515    results: &'a fallow_core::results::AnalysisResults,
1516    root: &'a Path,
1517    base: &'a FxHashSet<String>,
1518}
1519
1520impl DeadCodeJsonAnnotator<'_> {
1521    fn annotate_file_symbols(&mut self) {
1522        annotate_issue_array(
1523            self.json,
1524            "unused_files",
1525            self.results.unused_files.iter().map(|item| {
1526                issue_was_introduced(
1527                    &format!(
1528                        "unused-file:{}",
1529                        relative_key_path(&item.file.path, self.root)
1530                    ),
1531                    self.base,
1532                )
1533            }),
1534        );
1535        annotate_issue_array(
1536            self.json,
1537            "unused_exports",
1538            self.results.unused_exports.iter().map(|item| {
1539                issue_was_introduced(
1540                    &format!(
1541                        "unused-export:{}:{}",
1542                        relative_key_path(&item.export.path, self.root),
1543                        item.export.export_name
1544                    ),
1545                    self.base,
1546                )
1547            }),
1548        );
1549        annotate_issue_array(
1550            self.json,
1551            "unused_types",
1552            self.results.unused_types.iter().map(|item| {
1553                issue_was_introduced(
1554                    &format!(
1555                        "unused-type:{}:{}",
1556                        relative_key_path(&item.export.path, self.root),
1557                        item.export.export_name
1558                    ),
1559                    self.base,
1560                )
1561            }),
1562        );
1563        annotate_issue_array(
1564            self.json,
1565            "private_type_leaks",
1566            self.results.private_type_leaks.iter().map(|item| {
1567                issue_was_introduced(
1568                    &format!(
1569                        "private-type-leak:{}:{}:{}",
1570                        relative_key_path(&item.leak.path, self.root),
1571                        item.leak.export_name,
1572                        item.leak.type_name
1573                    ),
1574                    self.base,
1575                )
1576            }),
1577        );
1578    }
1579
1580    fn annotate_dependencies(&mut self) {
1581        annotate_dependency_json(self.json, self.results, self.root, self.base);
1582        annotate_issue_array(
1583            self.json,
1584            "type_only_dependencies",
1585            self.results.type_only_dependencies.iter().map(|item| {
1586                issue_was_introduced(
1587                    &format!(
1588                        "type-only-dependency:{}:{}",
1589                        relative_key_path(&item.dep.path, self.root),
1590                        item.dep.package_name
1591                    ),
1592                    self.base,
1593                )
1594            }),
1595        );
1596        annotate_issue_array(
1597            self.json,
1598            "test_only_dependencies",
1599            self.results.test_only_dependencies.iter().map(|item| {
1600                issue_was_introduced(
1601                    &format!(
1602                        "test-only-dependency:{}:{}",
1603                        relative_key_path(&item.dep.path, self.root),
1604                        item.dep.package_name
1605                    ),
1606                    self.base,
1607                )
1608            }),
1609        );
1610    }
1611
1612    fn annotate_members(&mut self) {
1613        annotate_member_json(self.json, self.results, self.root, self.base);
1614    }
1615
1616    fn annotate_imports_and_exports(&mut self) {
1617        self.annotate_import_dependency_keys();
1618        self.annotate_framework_keys();
1619        self.annotate_component_keys();
1620        self.annotate_route_keys();
1621    }
1622
1623    fn annotate_import_dependency_keys(&mut self) {
1624        annotate_issue_array(
1625            self.json,
1626            "unresolved_imports",
1627            self.results.unresolved_imports.iter().map(|item| {
1628                issue_was_introduced(
1629                    &format!(
1630                        "unresolved-import:{}:{}",
1631                        relative_key_path(&item.import.path, self.root),
1632                        item.import.specifier
1633                    ),
1634                    self.base,
1635                )
1636            }),
1637        );
1638        annotate_issue_array(
1639            self.json,
1640            "unlisted_dependencies",
1641            self.results.unlisted_dependencies.iter().map(|item| {
1642                issue_was_introduced(&unlisted_dependency_key(&item.dep, self.root), self.base)
1643            }),
1644        );
1645        annotate_issue_array(
1646            self.json,
1647            "duplicate_exports",
1648            self.results.duplicate_exports.iter().map(|item| {
1649                let mut locations: Vec<String> = item
1650                    .export
1651                    .locations
1652                    .iter()
1653                    .map(|loc| relative_key_path(&loc.path, self.root))
1654                    .collect();
1655                locations.sort();
1656                locations.dedup();
1657                issue_was_introduced(
1658                    &format!(
1659                        "duplicate-export:{}:{}",
1660                        item.export.export_name,
1661                        locations.join("|")
1662                    ),
1663                    self.base,
1664                )
1665            }),
1666        );
1667    }
1668
1669    fn annotate_framework_keys(&mut self) {
1670        annotate_issue_array(
1671            self.json,
1672            "invalid_client_exports",
1673            self.results.invalid_client_exports.iter().map(|item| {
1674                issue_was_introduced(
1675                    &invalid_client_export_key(&item.export, self.root),
1676                    self.base,
1677                )
1678            }),
1679        );
1680        annotate_issue_array(
1681            self.json,
1682            "mixed_client_server_barrels",
1683            self.results.mixed_client_server_barrels.iter().map(|item| {
1684                issue_was_introduced(
1685                    &mixed_client_server_barrel_key(&item.barrel, self.root),
1686                    self.base,
1687                )
1688            }),
1689        );
1690        annotate_issue_array(
1691            self.json,
1692            "misplaced_directives",
1693            self.results.misplaced_directives.iter().map(|item| {
1694                issue_was_introduced(
1695                    &misplaced_directive_key(&item.directive_site, self.root),
1696                    self.base,
1697                )
1698            }),
1699        );
1700        annotate_issue_array(
1701            self.json,
1702            "unprovided_injects",
1703            self.results.unprovided_injects.iter().map(|item| {
1704                issue_was_introduced(&unprovided_inject_key(&item.inject, self.root), self.base)
1705            }),
1706        );
1707    }
1708
1709    fn annotate_component_keys(&mut self) {
1710        self.annotate_component_render_keys();
1711        self.annotate_component_io_keys();
1712    }
1713
1714    /// Annotate rendered-component, prop, and emit issue arrays.
1715    fn annotate_component_render_keys(&mut self) {
1716        annotate_issue_array(
1717            self.json,
1718            "unrendered_components",
1719            self.results.unrendered_components.iter().map(|item| {
1720                issue_was_introduced(
1721                    &unrendered_component_key(&item.component, self.root),
1722                    self.base,
1723                )
1724            }),
1725        );
1726        annotate_issue_array(
1727            self.json,
1728            "unused_component_props",
1729            self.results.unused_component_props.iter().map(|item| {
1730                issue_was_introduced(&unused_component_prop_key(&item.prop, self.root), self.base)
1731            }),
1732        );
1733        annotate_issue_array(
1734            self.json,
1735            "unused_component_emits",
1736            self.results.unused_component_emits.iter().map(|item| {
1737                issue_was_introduced(&unused_component_emit_key(&item.emit, self.root), self.base)
1738            }),
1739        );
1740    }
1741
1742    /// Annotate component input/output, Svelte event, and server-action issue arrays.
1743    fn annotate_component_io_keys(&mut self) {
1744        annotate_issue_array(
1745            self.json,
1746            "unused_component_inputs",
1747            self.results.unused_component_inputs.iter().map(|item| {
1748                issue_was_introduced(
1749                    &unused_component_input_key(&item.input, self.root),
1750                    self.base,
1751                )
1752            }),
1753        );
1754        annotate_issue_array(
1755            self.json,
1756            "unused_component_outputs",
1757            self.results.unused_component_outputs.iter().map(|item| {
1758                issue_was_introduced(
1759                    &unused_component_output_key(&item.output, self.root),
1760                    self.base,
1761                )
1762            }),
1763        );
1764        annotate_issue_array(
1765            self.json,
1766            "unused_svelte_events",
1767            self.results.unused_svelte_events.iter().map(|item| {
1768                issue_was_introduced(&unused_svelte_event_key(&item.event, self.root), self.base)
1769            }),
1770        );
1771        annotate_issue_array(
1772            self.json,
1773            "unused_server_actions",
1774            self.results.unused_server_actions.iter().map(|item| {
1775                issue_was_introduced(
1776                    &unused_server_action_key(&item.action, self.root),
1777                    self.base,
1778                )
1779            }),
1780        );
1781    }
1782
1783    fn annotate_route_keys(&mut self) {
1784        annotate_issue_array(
1785            self.json,
1786            "route_collisions",
1787            self.results.route_collisions.iter().map(|item| {
1788                issue_was_introduced(&route_collision_key(&item.collision, self.root), self.base)
1789            }),
1790        );
1791        annotate_issue_array(
1792            self.json,
1793            "dynamic_segment_name_conflicts",
1794            self.results
1795                .dynamic_segment_name_conflicts
1796                .iter()
1797                .map(|item| {
1798                    issue_was_introduced(
1799                        &dynamic_segment_name_conflict_key(&item.conflict, self.root),
1800                        self.base,
1801                    )
1802                }),
1803        );
1804    }
1805
1806    fn annotate_graph(&mut self) {
1807        annotate_graph_json(self.json, self.results, self.root, self.base);
1808    }
1809
1810    fn annotate_catalog(&mut self) {
1811        annotate_catalog_json(self.json, self.results, self.root, self.base);
1812    }
1813}
1814
1815fn annotate_dependency_json(
1816    json: &mut serde_json::Value,
1817    results: &fallow_core::results::AnalysisResults,
1818    root: &Path,
1819    base: &FxHashSet<String>,
1820) {
1821    annotate_issue_array(
1822        json,
1823        "unused_dependencies",
1824        results
1825            .unused_dependencies
1826            .iter()
1827            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
1828    );
1829    annotate_issue_array(
1830        json,
1831        "unused_dev_dependencies",
1832        results
1833            .unused_dev_dependencies
1834            .iter()
1835            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
1836    );
1837    annotate_issue_array(
1838        json,
1839        "unused_optional_dependencies",
1840        results
1841            .unused_optional_dependencies
1842            .iter()
1843            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
1844    );
1845}
1846
1847fn annotate_member_json(
1848    json: &mut serde_json::Value,
1849    results: &fallow_core::results::AnalysisResults,
1850    root: &Path,
1851    base: &FxHashSet<String>,
1852) {
1853    annotate_issue_array(
1854        json,
1855        "unused_enum_members",
1856        results.unused_enum_members.iter().map(|item| {
1857            issue_was_introduced(
1858                &unused_member_key("unused-enum-member", &item.member, root),
1859                base,
1860            )
1861        }),
1862    );
1863    annotate_issue_array(
1864        json,
1865        "unused_class_members",
1866        results.unused_class_members.iter().map(|item| {
1867            issue_was_introduced(
1868                &unused_member_key("unused-class-member", &item.member, root),
1869                base,
1870            )
1871        }),
1872    );
1873    annotate_issue_array(
1874        json,
1875        "unused_store_members",
1876        results.unused_store_members.iter().map(|item| {
1877            issue_was_introduced(
1878                &unused_member_key("unused-store-member", &item.member, root),
1879                base,
1880            )
1881        }),
1882    );
1883}
1884
1885fn annotate_graph_json(
1886    json: &mut serde_json::Value,
1887    results: &fallow_core::results::AnalysisResults,
1888    root: &Path,
1889    base: &FxHashSet<String>,
1890) {
1891    annotate_cycle_json(json, results, root, base);
1892    annotate_boundary_json(json, results, root, base);
1893    annotate_policy_json(json, results, root, base);
1894}
1895
1896fn annotate_cycle_json(
1897    json: &mut serde_json::Value,
1898    results: &fallow_core::results::AnalysisResults,
1899    root: &Path,
1900    base: &FxHashSet<String>,
1901) {
1902    annotate_issue_array(
1903        json,
1904        "circular_dependencies",
1905        results.circular_dependencies.iter().map(|item| {
1906            let mut files: Vec<String> = item
1907                .cycle
1908                .files
1909                .iter()
1910                .map(|path| relative_key_path(path, root))
1911                .collect();
1912            files.sort();
1913            issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
1914        }),
1915    );
1916    annotate_issue_array(
1917        json,
1918        "re_export_cycles",
1919        results.re_export_cycles.iter().map(|item| {
1920            let kind = match item.cycle.kind {
1921                fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
1922                fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
1923            };
1924            let mut files: Vec<String> = item
1925                .cycle
1926                .files
1927                .iter()
1928                .map(|path| relative_key_path(path, root))
1929                .collect();
1930            files.sort();
1931            issue_was_introduced(&format!("re-export-cycle:{kind}:{}", files.join("|")), base)
1932        }),
1933    );
1934}
1935
1936fn annotate_boundary_json(
1937    json: &mut serde_json::Value,
1938    results: &fallow_core::results::AnalysisResults,
1939    root: &Path,
1940    base: &FxHashSet<String>,
1941) {
1942    annotate_issue_array(
1943        json,
1944        "boundary_violations",
1945        results.boundary_violations.iter().map(|item| {
1946            issue_was_introduced(
1947                &format!(
1948                    "boundary-violation:{}:{}:{}",
1949                    relative_key_path(&item.violation.from_path, root),
1950                    relative_key_path(&item.violation.to_path, root),
1951                    item.violation.import_specifier
1952                ),
1953                base,
1954            )
1955        }),
1956    );
1957    annotate_issue_array(
1958        json,
1959        "boundary_coverage_violations",
1960        results.boundary_coverage_violations.iter().map(|item| {
1961            issue_was_introduced(
1962                &format!(
1963                    "boundary-coverage:{}",
1964                    relative_key_path(&item.violation.path, root)
1965                ),
1966                base,
1967            )
1968        }),
1969    );
1970    annotate_issue_array(
1971        json,
1972        "boundary_call_violations",
1973        results.boundary_call_violations.iter().map(|item| {
1974            issue_was_introduced(
1975                &format!(
1976                    "boundary-call:{}:{}",
1977                    relative_key_path(&item.violation.path, root),
1978                    item.violation.callee
1979                ),
1980                base,
1981            )
1982        }),
1983    );
1984}
1985
1986fn annotate_policy_json(
1987    json: &mut serde_json::Value,
1988    results: &fallow_core::results::AnalysisResults,
1989    root: &Path,
1990    base: &FxHashSet<String>,
1991) {
1992    annotate_issue_array(
1993        json,
1994        "policy_violations",
1995        results.policy_violations.iter().map(|item| {
1996            issue_was_introduced(
1997                &format!(
1998                    "policy-violation:{}:{}/{}:{}",
1999                    relative_key_path(&item.violation.path, root),
2000                    item.violation.pack,
2001                    item.violation.rule_id,
2002                    item.violation.matched
2003                ),
2004                base,
2005            )
2006        }),
2007    );
2008    annotate_issue_array(
2009        json,
2010        "stale_suppressions",
2011        results
2012            .stale_suppressions
2013            .iter()
2014            .map(|item| issue_was_introduced(&stale_suppression_key(item, root), base)),
2015    );
2016}
2017
2018fn annotate_catalog_json(
2019    json: &mut serde_json::Value,
2020    results: &fallow_core::results::AnalysisResults,
2021    root: &Path,
2022    base: &FxHashSet<String>,
2023) {
2024    annotate_catalog_entry_json(json, results, root, base);
2025    annotate_dependency_override_json(json, results, root, base);
2026}
2027
2028/// Annotate catalog-reference, catalog-entry, and empty-group issue arrays.
2029fn annotate_catalog_entry_json(
2030    json: &mut serde_json::Value,
2031    results: &fallow_core::results::AnalysisResults,
2032    root: &Path,
2033    base: &FxHashSet<String>,
2034) {
2035    annotate_issue_array(
2036        json,
2037        "unresolved_catalog_references",
2038        results.unresolved_catalog_references.iter().map(|item| {
2039            issue_was_introduced(
2040                &format!(
2041                    "unresolved-catalog-reference:{}:{}:{}:{}",
2042                    relative_key_path(&item.reference.path, root),
2043                    item.reference.line,
2044                    item.reference.catalog_name,
2045                    item.reference.entry_name
2046                ),
2047                base,
2048            )
2049        }),
2050    );
2051    annotate_issue_array(
2052        json,
2053        "unused_catalog_entries",
2054        results
2055            .unused_catalog_entries
2056            .iter()
2057            .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
2058    );
2059    annotate_issue_array(
2060        json,
2061        "empty_catalog_groups",
2062        results
2063            .empty_catalog_groups
2064            .iter()
2065            .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
2066    );
2067}
2068
2069/// Annotate dependency-override issue arrays (unused and misconfigured).
2070fn annotate_dependency_override_json(
2071    json: &mut serde_json::Value,
2072    results: &fallow_core::results::AnalysisResults,
2073    root: &Path,
2074    base: &FxHashSet<String>,
2075) {
2076    annotate_issue_array(
2077        json,
2078        "unused_dependency_overrides",
2079        results.unused_dependency_overrides.iter().map(|item| {
2080            issue_was_introduced(
2081                &format!(
2082                    "unused-dependency-override:{}:{}:{}",
2083                    relative_key_path(&item.entry.path, root),
2084                    item.entry.line,
2085                    item.entry.raw_key
2086                ),
2087                base,
2088            )
2089        }),
2090    );
2091    annotate_issue_array(
2092        json,
2093        "misconfigured_dependency_overrides",
2094        results
2095            .misconfigured_dependency_overrides
2096            .iter()
2097            .map(|item| {
2098                issue_was_introduced(
2099                    &format!(
2100                        "misconfigured-dependency-override:{}:{}:{}",
2101                        relative_key_path(&item.entry.path, root),
2102                        item.entry.line,
2103                        item.entry.raw_key
2104                    ),
2105                    base,
2106                )
2107            }),
2108    );
2109}
2110
2111pub(super) fn annotate_health_json(
2112    json: &mut serde_json::Value,
2113    report: &crate::health_types::HealthReport,
2114    root: &Path,
2115    base: &FxHashSet<String>,
2116) {
2117    let Some(items) = json
2118        .get_mut("findings")
2119        .and_then(serde_json::Value::as_array_mut)
2120    else {
2121        return;
2122    };
2123    for (item, finding) in items.iter_mut().zip(&report.findings) {
2124        if let serde_json::Value::Object(map) = item {
2125            map.insert(
2126                "introduced".to_string(),
2127                serde_json::json!(issue_was_introduced(
2128                    &health_finding_key(finding, root),
2129                    base
2130                )),
2131            );
2132        }
2133    }
2134}
2135
2136pub(super) fn annotate_dupes_json(
2137    json: &mut serde_json::Value,
2138    report: &fallow_core::duplicates::DuplicationReport,
2139    root: &Path,
2140    base: &FxHashSet<String>,
2141) {
2142    let Some(items) = json
2143        .get_mut("clone_groups")
2144        .and_then(serde_json::Value::as_array_mut)
2145    else {
2146        return;
2147    };
2148    for (item, group) in items.iter_mut().zip(&report.clone_groups) {
2149        if let serde_json::Value::Object(map) = item {
2150            map.insert(
2151                "introduced".to_string(),
2152                serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
2153            );
2154        }
2155    }
2156}
2157
2158pub(super) fn health_keys(
2159    report: &crate::health_types::HealthReport,
2160    root: &Path,
2161) -> FxHashSet<String> {
2162    report
2163        .findings
2164        .iter()
2165        .map(|finding| health_finding_key(finding, root))
2166        .collect()
2167}
2168
2169pub(super) fn health_finding_key(
2170    finding: &crate::health_types::ComplexityViolation,
2171    root: &Path,
2172) -> String {
2173    format!(
2174        "complexity:{}:{}:{:?}",
2175        relative_key_path(&finding.path, root),
2176        finding.name,
2177        finding.exceeded
2178    )
2179}
2180
2181pub(super) fn dupes_keys(
2182    report: &fallow_core::duplicates::DuplicationReport,
2183    root: &Path,
2184) -> FxHashSet<String> {
2185    report
2186        .clone_groups
2187        .iter()
2188        .map(|group| dupe_group_key(group, root))
2189        .collect()
2190}
2191
2192pub(super) fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> String {
2193    let mut files: Vec<String> = group
2194        .instances
2195        .iter()
2196        .map(|instance| relative_key_path(&instance.file, root))
2197        .collect();
2198    files.sort();
2199    files.dedup();
2200    let mut hasher = DefaultHasher::new();
2201    for instance in &group.instances {
2202        instance.fragment.hash(&mut hasher);
2203    }
2204    format!(
2205        "dupe:{}:{}:{}:{:x}",
2206        files.join("|"),
2207        group.token_count,
2208        group.line_count,
2209        hasher.finish()
2210    )
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215    use std::path::{Path, PathBuf};
2216
2217    use fallow_core::duplicates::{CloneGroup, CloneInstance, DuplicationReport};
2218    use fallow_core::extract::MemberKind;
2219    use fallow_core::results::{
2220        AnalysisResults, BoundaryCallViolation, BoundaryCallViolationFinding,
2221        BoundaryCoverageViolation, BoundaryCoverageViolationFinding, BoundaryViolation,
2222        BoundaryViolationFinding, CircularDependency, CircularDependencyFinding,
2223        DependencyLocation, DependencyOverrideMisconfigReason, DependencyOverrideSource,
2224        DuplicateExport, DuplicateExportFinding, DuplicateLocation, DynamicSegmentNameConflict,
2225        DynamicSegmentNameConflictFinding, EmptyCatalogGroup, EmptyCatalogGroupFinding, ImportSite,
2226        MisconfiguredDependencyOverride, MisconfiguredDependencyOverrideFinding, PolicyRuleKind,
2227        PolicyViolation, PolicyViolationFinding, PolicyViolationSeverity, PrivateTypeLeak,
2228        PrivateTypeLeakFinding, ReExportCycle, ReExportCycleFinding, ReExportCycleKind,
2229        RouteCollision, RouteCollisionFinding, StaleSuppression, SuppressionOrigin,
2230        TestOnlyDependency, TestOnlyDependencyFinding, TypeOnlyDependency,
2231        TypeOnlyDependencyFinding, UnlistedDependency, UnlistedDependencyFinding, UnprovidedInject,
2232        UnprovidedInjectFinding, UnrenderedComponent, UnrenderedComponentFinding,
2233        UnresolvedCatalogReference, UnresolvedCatalogReferenceFinding, UnresolvedImport,
2234        UnresolvedImportFinding, UnusedCatalogEntry, UnusedCatalogEntryFinding,
2235        UnusedClassMemberFinding, UnusedComponentEmit, UnusedComponentEmitFinding,
2236        UnusedComponentInput, UnusedComponentInputFinding, UnusedComponentOutput,
2237        UnusedComponentOutputFinding, UnusedComponentProp, UnusedComponentPropFinding,
2238        UnusedDependency, UnusedDependencyFinding, UnusedDependencyOverride,
2239        UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
2240        UnusedExport, UnusedExportFinding, UnusedFile, UnusedFileFinding, UnusedLoadDataKey,
2241        UnusedLoadDataKeyFinding, UnusedMember, UnusedOptionalDependencyFinding,
2242        UnusedServerAction, UnusedServerActionFinding, UnusedStoreMemberFinding, UnusedSvelteEvent,
2243        UnusedSvelteEventFinding, UnusedTypeFinding,
2244    };
2245    use rustc_hash::FxHashSet;
2246    use serde_json::json;
2247
2248    use crate::health_types::{
2249        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
2250    };
2251
2252    use super::{
2253        annotate_dead_code_json, annotate_dupes_json, annotate_health_json, dead_code_keys,
2254        dupe_group_key, dupes_keys, health_finding_key, health_keys, relative_key_path,
2255        retain_introduced_dead_code,
2256    };
2257
2258    fn root() -> PathBuf {
2259        PathBuf::from("/repo")
2260    }
2261
2262    fn export(path: &Path, name: &str) -> UnusedExportFinding {
2263        UnusedExportFinding::with_actions(UnusedExport {
2264            path: path.to_path_buf(),
2265            export_name: name.to_string(),
2266            is_type_only: false,
2267            line: 1,
2268            col: 0,
2269            span_start: 0,
2270            is_re_export: false,
2271        })
2272    }
2273
2274    fn unused_file(path: &Path) -> UnusedFileFinding {
2275        UnusedFileFinding::with_actions(UnusedFile {
2276            path: path.to_path_buf(),
2277        })
2278    }
2279
2280    fn dependency(path: &Path, package_name: &str) -> UnusedDependencyFinding {
2281        UnusedDependencyFinding::with_actions(UnusedDependency {
2282            package_name: package_name.to_string(),
2283            location: DependencyLocation::Dependencies,
2284            path: path.to_path_buf(),
2285            line: 4,
2286            used_in_workspaces: Vec::new(),
2287        })
2288    }
2289
2290    fn unresolved(path: &Path, specifier: &str) -> UnresolvedImportFinding {
2291        UnresolvedImportFinding::with_actions(UnresolvedImport {
2292            path: path.to_path_buf(),
2293            specifier: specifier.to_string(),
2294            line: 2,
2295            col: 1,
2296            specifier_col: 8,
2297        })
2298    }
2299
2300    fn unlisted(path: &Path, package_name: &str) -> UnlistedDependencyFinding {
2301        UnlistedDependencyFinding::with_actions(UnlistedDependency {
2302            package_name: package_name.to_string(),
2303            imported_from: vec![
2304                ImportSite {
2305                    path: path.to_path_buf(),
2306                    line: 9,
2307                    col: 2,
2308                },
2309                ImportSite {
2310                    path: path.to_path_buf(),
2311                    line: 9,
2312                    col: 2,
2313                },
2314            ],
2315        })
2316    }
2317
2318    fn duplicate_export(root: &Path) -> DuplicateExportFinding {
2319        DuplicateExportFinding::with_actions(DuplicateExport {
2320            export_name: "Button".to_string(),
2321            locations: vec![
2322                DuplicateLocation {
2323                    path: root.join("src/b.ts"),
2324                    line: 1,
2325                    col: 0,
2326                },
2327                DuplicateLocation {
2328                    path: root.join("src/a.ts"),
2329                    line: 1,
2330                    col: 0,
2331                },
2332                DuplicateLocation {
2333                    path: root.join("src/a.ts"),
2334                    line: 2,
2335                    col: 0,
2336                },
2337            ],
2338        })
2339    }
2340
2341    fn sample_results(root: &Path) -> AnalysisResults {
2342        let source = root.join("src/page.ts");
2343        let package_json = root.join("package.json");
2344        let mut results = AnalysisResults::default();
2345        results
2346            .unused_files
2347            .push(unused_file(&root.join("src/dead.ts")));
2348        results.unused_exports.push(export(&source, "loader"));
2349        results
2350            .unused_dependencies
2351            .push(dependency(&package_json, "left-pad"));
2352        results
2353            .unresolved_imports
2354            .push(unresolved(&source, "./missing"));
2355        results.unlisted_dependencies.push(unlisted(&source, "zod"));
2356        results.duplicate_exports.push(duplicate_export(root));
2357        results
2358    }
2359
2360    #[test]
2361    fn relative_key_path_strips_root_and_normalizes_separators() {
2362        let path = Path::new("/repo/src\\feature\\index.ts");
2363        assert_eq!(
2364            relative_key_path(path, Path::new("/repo")),
2365            "src/feature/index.ts"
2366        );
2367    }
2368
2369    #[test]
2370    fn dead_code_keys_are_stable_for_unsorted_and_duplicate_locations() {
2371        let root = root();
2372        let keys = dead_code_keys(&sample_results(&root), &root);
2373
2374        assert!(keys.contains("unused-file:src/dead.ts"));
2375        assert!(keys.contains("unused-export:src/page.ts:loader"));
2376        assert!(keys.contains("unused-dependency:package.json:left-pad"));
2377        assert!(keys.contains("unresolved-import:src/page.ts:./missing"));
2378        assert!(keys.contains("unlisted-dependency:zod:src/page.ts:9:2"));
2379        assert!(keys.contains("duplicate-export:Button:src/a.ts|src/b.ts"));
2380    }
2381
2382    #[test]
2383    fn dead_code_keys_cover_type_member_and_dependency_variants() {
2384        let root = root();
2385        let source = root.join("src/types.ts");
2386        let package_json = root.join("package.json");
2387        let mut results = AnalysisResults::default();
2388        results
2389            .unused_types
2390            .push(UnusedTypeFinding::with_actions(UnusedExport {
2391                path: source.clone(),
2392                export_name: "UnusedType".to_string(),
2393                is_type_only: true,
2394                line: 3,
2395                col: 0,
2396                span_start: 12,
2397                is_re_export: false,
2398            }));
2399        results
2400            .private_type_leaks
2401            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
2402                path: source.clone(),
2403                export_name: "makePublic".to_string(),
2404                type_name: "PrivateShape".to_string(),
2405                line: 7,
2406                col: 12,
2407                span_start: 64,
2408            }));
2409        results
2410            .unused_dev_dependencies
2411            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2412                package_name: "vite".to_string(),
2413                location: DependencyLocation::DevDependencies,
2414                path: package_json.clone(),
2415                line: 10,
2416                used_in_workspaces: Vec::new(),
2417            }));
2418        results
2419            .unused_optional_dependencies
2420            .push(UnusedOptionalDependencyFinding::with_actions(
2421                UnusedDependency {
2422                    package_name: "fsevents".to_string(),
2423                    location: DependencyLocation::OptionalDependencies,
2424                    path: package_json.clone(),
2425                    line: 11,
2426                    used_in_workspaces: Vec::new(),
2427                },
2428            ));
2429        results
2430            .unused_enum_members
2431            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
2432                path: source.clone(),
2433                parent_name: "Status".to_string(),
2434                member_name: "Idle".to_string(),
2435                kind: MemberKind::EnumMember,
2436                line: 15,
2437                col: 2,
2438            }));
2439        results
2440            .unused_class_members
2441            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2442                path: source,
2443                parent_name: "Controller".to_string(),
2444                member_name: "legacy".to_string(),
2445                kind: MemberKind::ClassMethod,
2446                line: 21,
2447                col: 2,
2448            }));
2449        results
2450            .type_only_dependencies
2451            .push(TypeOnlyDependencyFinding::with_actions(
2452                TypeOnlyDependency {
2453                    package_name: "zod".to_string(),
2454                    path: package_json.clone(),
2455                    line: 12,
2456                },
2457            ));
2458        results
2459            .test_only_dependencies
2460            .push(TestOnlyDependencyFinding::with_actions(
2461                TestOnlyDependency {
2462                    package_name: "vitest".to_string(),
2463                    path: package_json,
2464                    line: 13,
2465                },
2466            ));
2467
2468        let keys = dead_code_keys(&results, &root);
2469
2470        assert!(keys.contains("unused-type:src/types.ts:UnusedType"));
2471        assert!(keys.contains("private-type-leak:src/types.ts:makePublic:PrivateShape"));
2472        assert!(keys.contains("unused-dev-dependency:package.json:vite"));
2473        assert!(keys.contains("unused-optional-dependency:package.json:fsevents"));
2474        assert!(keys.contains("unused-enum-member:src/types.ts:Status:Idle"));
2475        assert!(keys.contains("unused-class-member:src/types.ts:Controller:legacy"));
2476        assert!(keys.contains("type-only-dependency:package.json:zod"));
2477        assert!(keys.contains("test-only-dependency:package.json:vitest"));
2478    }
2479
2480    #[expect(
2481        clippy::too_many_lines,
2482        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2483    )]
2484    fn graph_boundary_catalog_override_results(root: &std::path::Path) -> AnalysisResults {
2485        let source = root.join("src/app.ts");
2486        let other = root.join("src/other.ts");
2487        let workspace = root.join("pnpm-workspace.yaml");
2488        let mut results = AnalysisResults::default();
2489        results
2490            .circular_dependencies
2491            .push(CircularDependencyFinding::with_actions(
2492                CircularDependency {
2493                    files: vec![other.clone(), source.clone()],
2494                    length: 2,
2495                    line: 4,
2496                    col: 0,
2497                    edges: Vec::new(),
2498                    is_cross_package: false,
2499                },
2500            ));
2501        results
2502            .re_export_cycles
2503            .push(ReExportCycleFinding::with_actions(ReExportCycle {
2504                files: vec![source.clone()],
2505                kind: ReExportCycleKind::SelfLoop,
2506            }));
2507        results
2508            .boundary_violations
2509            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
2510                from_path: source.clone(),
2511                to_path: other,
2512                from_zone: "ui".to_string(),
2513                to_zone: "server".to_string(),
2514                import_specifier: "../other".to_string(),
2515                line: 1,
2516                col: 0,
2517            }));
2518        results
2519            .boundary_coverage_violations
2520            .push(BoundaryCoverageViolationFinding::with_actions(
2521                BoundaryCoverageViolation {
2522                    path: root.join("src/unmatched.ts"),
2523                    line: 1,
2524                    col: 0,
2525                },
2526            ));
2527        results
2528            .boundary_call_violations
2529            .push(BoundaryCallViolationFinding::with_actions(
2530                BoundaryCallViolation {
2531                    path: source.clone(),
2532                    line: 12,
2533                    col: 4,
2534                    zone: "ui".to_string(),
2535                    callee: "child_process.exec".to_string(),
2536                    pattern: "child_process.*".to_string(),
2537                },
2538            ));
2539        results.stale_suppressions.push(StaleSuppression {
2540            path: source,
2541            line: 2,
2542            col: 0,
2543            origin: SuppressionOrigin::Comment {
2544                issue_kind: Some("unused-export".to_string()),
2545                reason: None,
2546                is_file_level: false,
2547                kind_known: true,
2548            },
2549            missing_reason: false,
2550            actions: StaleSuppression::actions_for(false),
2551        });
2552        results.stale_suppressions.push(StaleSuppression {
2553            path: root.join("src/app.ts"),
2554            line: 2,
2555            col: 0,
2556            origin: SuppressionOrigin::Comment {
2557                issue_kind: Some("unused-export".to_string()),
2558                reason: None,
2559                is_file_level: false,
2560                kind_known: true,
2561            },
2562            missing_reason: true,
2563            actions: StaleSuppression::actions_for(true),
2564        });
2565        results.unresolved_catalog_references.push(
2566            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
2567                entry_name: "react".to_string(),
2568                catalog_name: "default".to_string(),
2569                path: root.join("packages/app/package.json"),
2570                line: 9,
2571                available_in_catalogs: vec!["react18".to_string()],
2572            }),
2573        );
2574        results
2575            .unused_catalog_entries
2576            .push(UnusedCatalogEntryFinding::with_actions(
2577                UnusedCatalogEntry {
2578                    entry_name: "lodash".to_string(),
2579                    catalog_name: "default".to_string(),
2580                    path: workspace.clone(),
2581                    line: 3,
2582                    hardcoded_consumers: Vec::new(),
2583                },
2584            ));
2585        results
2586            .empty_catalog_groups
2587            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2588                catalog_name: "react17".to_string(),
2589                path: workspace.clone(),
2590                line: 7,
2591            }));
2592        results
2593            .unused_dependency_overrides
2594            .push(UnusedDependencyOverrideFinding::with_actions(
2595                UnusedDependencyOverride {
2596                    raw_key: "left-pad".to_string(),
2597                    target_package: "left-pad".to_string(),
2598                    parent_package: None,
2599                    version_constraint: None,
2600                    version_range: "^1.3.0".to_string(),
2601                    source: DependencyOverrideSource::PnpmWorkspaceYaml,
2602                    path: workspace.clone(),
2603                    line: 11,
2604                    hint: None,
2605                },
2606            ));
2607        results.misconfigured_dependency_overrides.push(
2608            MisconfiguredDependencyOverrideFinding::with_actions(MisconfiguredDependencyOverride {
2609                raw_key: ">".to_string(),
2610                target_package: None,
2611                raw_value: String::new(),
2612                reason: DependencyOverrideMisconfigReason::UnparsableKey,
2613                source: DependencyOverrideSource::PnpmWorkspaceYaml,
2614                path: workspace,
2615                line: 12,
2616            }),
2617        );
2618        results
2619    }
2620
2621    #[test]
2622    fn dead_code_keys_cover_graph_boundary_catalog_and_override_variants() {
2623        let root = root();
2624        let results = graph_boundary_catalog_override_results(&root);
2625
2626        let keys = dead_code_keys(&results, &root);
2627
2628        assert!(keys.contains("circular-dependency:src/app.ts|src/other.ts"));
2629        assert!(keys.contains("re-export-cycle:self-loop:src/app.ts"));
2630        assert!(keys.contains("boundary-violation:src/app.ts:src/other.ts:../other"));
2631        assert!(keys.contains("boundary-coverage:src/unmatched.ts"));
2632        assert!(keys.contains("boundary-call:src/app.ts:child_process.exec"));
2633        assert!(
2634            keys.contains("stale-suppression:src/app.ts:// fallow-ignore-next-line unused-export")
2635        );
2636        assert!(keys.contains(
2637            "missing-suppression-reason:src/app.ts:// fallow-ignore-next-line unused-export"
2638        ));
2639        assert!(
2640            keys.contains("unresolved-catalog-reference:packages/app/package.json:9:default:react")
2641        );
2642        assert!(keys.contains("unused-catalog-entry:pnpm-workspace.yaml:3:default:lodash"));
2643        assert!(keys.contains("empty-catalog-group:pnpm-workspace.yaml:7:react17"));
2644        assert!(keys.contains("unused-dependency-override:pnpm-workspace.yaml:11:left-pad"));
2645        assert!(keys.contains("misconfigured-dependency-override:pnpm-workspace.yaml:12:>"));
2646    }
2647
2648    #[test]
2649    fn retain_introduced_dead_code_keeps_only_findings_absent_from_base() {
2650        let root = root();
2651        let mut results = sample_results(&root);
2652        let base = FxHashSet::from_iter([
2653            "unused-file:src/dead.ts".to_string(),
2654            "unused-dependency:package.json:left-pad".to_string(),
2655            "unresolved-import:src/page.ts:./missing".to_string(),
2656        ]);
2657
2658        retain_introduced_dead_code(&mut results, &root, Some(&base));
2659
2660        assert!(results.unused_files.is_empty());
2661        assert!(results.unused_dependencies.is_empty());
2662        assert!(results.unresolved_imports.is_empty());
2663        assert_eq!(results.unused_exports.len(), 1);
2664        assert_eq!(results.unlisted_dependencies.len(), 1);
2665        assert_eq!(results.duplicate_exports.len(), 1);
2666    }
2667
2668    #[test]
2669    fn annotate_dead_code_json_marks_introduced_status_by_matching_key_order() {
2670        let root = root();
2671        let results = sample_results(&root);
2672        let base = FxHashSet::from_iter([
2673            "unused-file:src/dead.ts".to_string(),
2674            "unlisted-dependency:zod:src/page.ts:9:2".to_string(),
2675        ]);
2676        let mut json = json!({
2677            "unused_files": [{}],
2678            "unused_exports": [{}],
2679            "unused_dependencies": [{}],
2680            "unresolved_imports": [{}],
2681            "unlisted_dependencies": [{}],
2682            "duplicate_exports": [{}],
2683        });
2684
2685        annotate_dead_code_json(&mut json, &results, &root, &base);
2686
2687        assert_eq!(json["unused_files"][0]["introduced"], false);
2688        assert_eq!(json["unused_exports"][0]["introduced"], true);
2689        assert_eq!(json["unused_dependencies"][0]["introduced"], true);
2690        assert_eq!(json["unresolved_imports"][0]["introduced"], true);
2691        assert_eq!(json["unlisted_dependencies"][0]["introduced"], false);
2692        assert_eq!(json["duplicate_exports"][0]["introduced"], true);
2693    }
2694
2695    // --- key-building coverage for lines 68-177 (framework-specific key fns) ---
2696
2697    #[test]
2698    fn dead_code_keys_cover_framework_inject_and_render_variants() {
2699        let root = root();
2700        let src = root.join("src/App.vue");
2701        let mut results = AnalysisResults::default();
2702        results
2703            .unprovided_injects
2704            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
2705                path: src.clone(),
2706                key_name: "userStore".to_string(),
2707                framework: "vue".to_string(),
2708                line: 5,
2709                col: 0,
2710            }));
2711        results
2712            .unrendered_components
2713            .push(UnrenderedComponentFinding::with_actions(
2714                UnrenderedComponent {
2715                    path: src.clone(),
2716                    component_name: "MyModal".to_string(),
2717                    framework: "vue".to_string(),
2718                    reachable_via: None,
2719                    line: 1,
2720                    col: 0,
2721                },
2722            ));
2723        results
2724            .unused_component_props
2725            .push(UnusedComponentPropFinding::with_actions(
2726                UnusedComponentProp {
2727                    path: src.clone(),
2728                    component_name: "MyModal".to_string(),
2729                    prop_name: "title".to_string(),
2730                    line: 3,
2731                    col: 2,
2732                },
2733            ));
2734        results
2735            .unused_component_emits
2736            .push(UnusedComponentEmitFinding::with_actions(
2737                UnusedComponentEmit {
2738                    path: src,
2739                    component_name: "MyModal".to_string(),
2740                    emit_name: "close".to_string(),
2741                    line: 4,
2742                    col: 2,
2743                },
2744            ));
2745        results
2746            .unused_svelte_events
2747            .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2748                path: root.join("src/Counter.svelte"),
2749                component_name: "Counter".to_string(),
2750                event_name: "increment".to_string(),
2751                line: 8,
2752                col: 0,
2753            }));
2754
2755        let keys = dead_code_keys(&results, &root);
2756
2757        assert!(keys.contains("unprovided-inject:src/App.vue:userStore"));
2758        assert!(keys.contains("unrendered-component:src/App.vue:MyModal"));
2759        assert!(keys.contains("unused-component-prop:src/App.vue:title"));
2760        assert!(keys.contains("unused-component-emit:src/App.vue:close"));
2761        assert!(keys.contains("unused-svelte-event:src/Counter.svelte:increment"));
2762    }
2763
2764    #[test]
2765    fn dead_code_keys_cover_server_action_load_data_and_route_variants() {
2766        let root = root();
2767        let actions_file = root.join("src/actions/submit.ts");
2768        let page_file = root.join("src/routes/blog/+page.server.ts");
2769        let route_file = root.join("app/(auth)/login/page.tsx");
2770        let route_file2 = root.join("app/login/page.tsx");
2771        let mut results = AnalysisResults::default();
2772        results
2773            .unused_server_actions
2774            .push(UnusedServerActionFinding::with_actions(
2775                UnusedServerAction {
2776                    path: actions_file,
2777                    action_name: "submitForm".to_string(),
2778                    line: 2,
2779                    col: 0,
2780                },
2781            ));
2782        results
2783            .unused_load_data_keys
2784            .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2785                path: page_file,
2786                key_name: "posts".to_string(),
2787                line: 10,
2788                col: 4,
2789                route_dir: None,
2790            }));
2791        results
2792            .route_collisions
2793            .push(RouteCollisionFinding::with_actions(RouteCollision {
2794                path: route_file.clone(),
2795                url: "/login".to_string(),
2796                conflicting_paths: vec![route_file2.clone()],
2797                line: 1,
2798                col: 0,
2799            }));
2800        results.dynamic_segment_name_conflicts.push(
2801            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
2802                path: route_file,
2803                position: "/shop".to_string(),
2804                conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
2805                conflicting_paths: vec![route_file2],
2806                line: 1,
2807                col: 0,
2808            }),
2809        );
2810
2811        let keys = dead_code_keys(&results, &root);
2812
2813        assert!(keys.contains("unused-server-action:src/actions/submit.ts:submitForm"));
2814        assert!(keys.contains("unused-load-data-key:src/routes/blog/+page.server.ts:posts"));
2815        assert!(keys.contains("route-collision:app/(auth)/login/page.tsx:/login"));
2816        assert!(keys.contains("dynamic-segment-name-conflict:app/(auth)/login/page.tsx:/shop"));
2817    }
2818
2819    #[test]
2820    fn dead_code_keys_cover_angular_input_output_and_policy_variants() {
2821        let root = root();
2822        let component = root.join("src/app/card.component.ts");
2823        let src = root.join("src/utils.ts");
2824        let mut results = AnalysisResults::default();
2825        results
2826            .unused_component_inputs
2827            .push(UnusedComponentInputFinding::with_actions(
2828                UnusedComponentInput {
2829                    path: component.clone(),
2830                    component_name: "CardComponent".to_string(),
2831                    input_name: "label".to_string(),
2832                    line: 12,
2833                    col: 4,
2834                },
2835            ));
2836        results
2837            .unused_component_outputs
2838            .push(UnusedComponentOutputFinding::with_actions(
2839                UnusedComponentOutput {
2840                    path: component,
2841                    component_name: "CardComponent".to_string(),
2842                    output_name: "clicked".to_string(),
2843                    line: 13,
2844                    col: 4,
2845                },
2846            ));
2847        results
2848            .policy_violations
2849            .push(PolicyViolationFinding::with_actions(PolicyViolation {
2850                path: src,
2851                line: 7,
2852                col: 0,
2853                pack: "security".to_string(),
2854                rule_id: "no-eval".to_string(),
2855                kind: PolicyRuleKind::BannedCall,
2856                matched: "eval".to_string(),
2857                severity: PolicyViolationSeverity::Error,
2858                message: None,
2859            }));
2860
2861        let keys = dead_code_keys(&results, &root);
2862
2863        assert!(keys.contains("unused-component-input:src/app/card.component.ts:label"));
2864        assert!(keys.contains("unused-component-output:src/app/card.component.ts:clicked"));
2865        assert!(keys.contains("policy-violation:src/utils.ts:security/no-eval:eval"));
2866    }
2867
2868    #[test]
2869    fn dead_code_keys_cover_re_export_cycle_multi_node_variant() {
2870        let root = root();
2871        let a = root.join("src/a.ts");
2872        let b = root.join("src/b.ts");
2873        let mut results = AnalysisResults::default();
2874        results
2875            .re_export_cycles
2876            .push(ReExportCycleFinding::with_actions(ReExportCycle {
2877                files: vec![b, a],
2878                kind: ReExportCycleKind::MultiNode,
2879            }));
2880
2881        let keys = dead_code_keys(&results, &root);
2882
2883        // multi-node variant hits line 275; files are sorted
2884        assert!(keys.contains("re-export-cycle:multi-node:src/a.ts|src/b.ts"));
2885    }
2886
2887    #[test]
2888    fn dead_code_keys_cover_unused_store_member() {
2889        let root = root();
2890        let src = root.join("src/store.ts");
2891        let mut results = AnalysisResults::default();
2892        results
2893            .unused_store_members
2894            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
2895                path: src,
2896                parent_name: "useAuthStore".to_string(),
2897                member_name: "resetPassword".to_string(),
2898                kind: MemberKind::ClassMethod,
2899                line: 42,
2900                col: 2,
2901            }));
2902
2903        let keys = dead_code_keys(&results, &root);
2904
2905        assert!(keys.contains("unused-store-member:src/store.ts:useAuthStore:resetPassword"));
2906    }
2907
2908    // --- annotate_dead_code_json for framework / component / render / policy arrays ---
2909
2910    #[test]
2911    fn annotate_dead_code_json_marks_framework_keys_correctly() {
2912        let root = root();
2913        let src = root.join("src/App.vue");
2914        let mut results = AnalysisResults::default();
2915        results
2916            .unprovided_injects
2917            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
2918                path: src.clone(),
2919                key_name: "theme".to_string(),
2920                framework: "vue".to_string(),
2921                line: 3,
2922                col: 0,
2923            }));
2924        results
2925            .unrendered_components
2926            .push(UnrenderedComponentFinding::with_actions(
2927                UnrenderedComponent {
2928                    path: src.clone(),
2929                    component_name: "Dialog".to_string(),
2930                    framework: "vue".to_string(),
2931                    reachable_via: None,
2932                    line: 1,
2933                    col: 0,
2934                },
2935            ));
2936        results
2937            .unused_component_props
2938            .push(UnusedComponentPropFinding::with_actions(
2939                UnusedComponentProp {
2940                    path: src.clone(),
2941                    component_name: "Dialog".to_string(),
2942                    prop_name: "open".to_string(),
2943                    line: 5,
2944                    col: 2,
2945                },
2946            ));
2947        results
2948            .unused_component_emits
2949            .push(UnusedComponentEmitFinding::with_actions(
2950                UnusedComponentEmit {
2951                    path: src,
2952                    component_name: "Dialog".to_string(),
2953                    emit_name: "dismiss".to_string(),
2954                    line: 6,
2955                    col: 2,
2956                },
2957            ));
2958
2959        // Only the inject is in the base; the rest are new.
2960        let base = FxHashSet::from_iter(["unprovided-inject:src/App.vue:theme".to_string()]);
2961        let mut json_val = json!({
2962            "unprovided_injects": [{}],
2963            "unrendered_components": [{}],
2964            "unused_component_props": [{}],
2965            "unused_component_emits": [{}],
2966        });
2967
2968        annotate_dead_code_json(&mut json_val, &results, &root, &base);
2969
2970        assert_eq!(json_val["unprovided_injects"][0]["introduced"], false);
2971        assert_eq!(json_val["unrendered_components"][0]["introduced"], true);
2972        assert_eq!(json_val["unused_component_props"][0]["introduced"], true);
2973        assert_eq!(json_val["unused_component_emits"][0]["introduced"], true);
2974    }
2975
2976    #[test]
2977    fn annotate_dead_code_json_marks_component_io_and_route_keys_correctly() {
2978        let root = root();
2979        let component = root.join("src/card.component.ts");
2980        let svelte_file = root.join("src/Counter.svelte");
2981        let page_file = root.join("src/routes/+page.server.ts");
2982        let route_file = root.join("app/about/page.tsx");
2983        let route_file2 = root.join("app/(info)/about/page.tsx");
2984        let mut results = AnalysisResults::default();
2985        results
2986            .unused_component_inputs
2987            .push(UnusedComponentInputFinding::with_actions(
2988                UnusedComponentInput {
2989                    path: component.clone(),
2990                    component_name: "CardComponent".to_string(),
2991                    input_name: "size".to_string(),
2992                    line: 8,
2993                    col: 2,
2994                },
2995            ));
2996        results
2997            .unused_component_outputs
2998            .push(UnusedComponentOutputFinding::with_actions(
2999                UnusedComponentOutput {
3000                    path: component,
3001                    component_name: "CardComponent".to_string(),
3002                    output_name: "hovered".to_string(),
3003                    line: 9,
3004                    col: 2,
3005                },
3006            ));
3007        results
3008            .unused_svelte_events
3009            .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
3010                path: svelte_file,
3011                component_name: "Counter".to_string(),
3012                event_name: "reset".to_string(),
3013                line: 12,
3014                col: 0,
3015            }));
3016        results
3017            .unused_server_actions
3018            .push(UnusedServerActionFinding::with_actions(
3019                UnusedServerAction {
3020                    path: page_file,
3021                    action_name: "deletePost".to_string(),
3022                    line: 3,
3023                    col: 0,
3024                },
3025            ));
3026        results
3027            .route_collisions
3028            .push(RouteCollisionFinding::with_actions(RouteCollision {
3029                path: route_file.clone(),
3030                url: "/about".to_string(),
3031                conflicting_paths: vec![route_file2.clone()],
3032                line: 1,
3033                col: 0,
3034            }));
3035        results.dynamic_segment_name_conflicts.push(
3036            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
3037                path: route_file,
3038                position: "/".to_string(),
3039                conflicting_segments: vec!["[id]".to_string()],
3040                conflicting_paths: vec![route_file2],
3041                line: 1,
3042                col: 0,
3043            }),
3044        );
3045
3046        // Nothing is in base: all are introduced.
3047        let base = FxHashSet::default();
3048        let mut json_val = json!({
3049            "unused_component_inputs": [{}],
3050            "unused_component_outputs": [{}],
3051            "unused_svelte_events": [{}],
3052            "unused_server_actions": [{}],
3053            "route_collisions": [{}],
3054            "dynamic_segment_name_conflicts": [{}],
3055        });
3056
3057        annotate_dead_code_json(&mut json_val, &results, &root, &base);
3058
3059        assert_eq!(json_val["unused_component_inputs"][0]["introduced"], true);
3060        assert_eq!(json_val["unused_component_outputs"][0]["introduced"], true);
3061        assert_eq!(json_val["unused_svelte_events"][0]["introduced"], true);
3062        assert_eq!(json_val["unused_server_actions"][0]["introduced"], true);
3063        assert_eq!(json_val["route_collisions"][0]["introduced"], true);
3064        assert_eq!(
3065            json_val["dynamic_segment_name_conflicts"][0]["introduced"],
3066            true
3067        );
3068    }
3069
3070    #[test]
3071    fn annotate_dead_code_json_marks_members_and_dependencies_correctly() {
3072        let root = root();
3073        let src = root.join("src/types.ts");
3074        let pkg = root.join("package.json");
3075        let mut results = AnalysisResults::default();
3076        results
3077            .unused_enum_members
3078            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3079                path: src.clone(),
3080                parent_name: "Color".to_string(),
3081                member_name: "Blue".to_string(),
3082                kind: MemberKind::EnumMember,
3083                line: 5,
3084                col: 2,
3085            }));
3086        results
3087            .unused_class_members
3088            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
3089                path: src.clone(),
3090                parent_name: "Service".to_string(),
3091                member_name: "reset".to_string(),
3092                kind: MemberKind::ClassMethod,
3093                line: 20,
3094                col: 2,
3095            }));
3096        results
3097            .unused_store_members
3098            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
3099                path: src,
3100                parent_name: "useStore".to_string(),
3101                member_name: "logout".to_string(),
3102                kind: MemberKind::ClassMethod,
3103                line: 30,
3104                col: 2,
3105            }));
3106        results
3107            .unused_dev_dependencies
3108            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3109                package_name: "typescript".to_string(),
3110                location: DependencyLocation::DevDependencies,
3111                path: pkg.clone(),
3112                line: 8,
3113                used_in_workspaces: Vec::new(),
3114            }));
3115        results
3116            .type_only_dependencies
3117            .push(TypeOnlyDependencyFinding::with_actions(
3118                TypeOnlyDependency {
3119                    package_name: "zod".to_string(),
3120                    path: pkg.clone(),
3121                    line: 9,
3122                },
3123            ));
3124        results
3125            .test_only_dependencies
3126            .push(TestOnlyDependencyFinding::with_actions(
3127                TestOnlyDependency {
3128                    package_name: "vitest".to_string(),
3129                    path: pkg,
3130                    line: 10,
3131                },
3132            ));
3133
3134        // Enum member and dev-dep are in base; class member, store member, type-only
3135        // dep, and test-only dep are new.
3136        let base = FxHashSet::from_iter([
3137            "unused-enum-member:src/types.ts:Color:Blue".to_string(),
3138            "unused-dev-dependency:package.json:typescript".to_string(),
3139        ]);
3140        let mut json_val = json!({
3141            "unused_enum_members": [{}],
3142            "unused_class_members": [{}],
3143            "unused_store_members": [{}],
3144            "unused_dev_dependencies": [{}],
3145            "type_only_dependencies": [{}],
3146            "test_only_dependencies": [{}],
3147        });
3148
3149        annotate_dead_code_json(&mut json_val, &results, &root, &base);
3150
3151        assert_eq!(json_val["unused_enum_members"][0]["introduced"], false);
3152        assert_eq!(json_val["unused_class_members"][0]["introduced"], true);
3153        assert_eq!(json_val["unused_store_members"][0]["introduced"], true);
3154        assert_eq!(json_val["unused_dev_dependencies"][0]["introduced"], false);
3155        assert_eq!(json_val["type_only_dependencies"][0]["introduced"], true);
3156        assert_eq!(json_val["test_only_dependencies"][0]["introduced"], true);
3157    }
3158
3159    #[test]
3160    fn annotate_dead_code_json_handles_missing_json_key_gracefully() {
3161        // annotate_issue_array is a no-op when the key is absent; this covers
3162        // the early-return branch inside annotate_issue_array (lines 1477-1479).
3163        let root = root();
3164        let results = sample_results(&root);
3165        let base = FxHashSet::default();
3166        let mut json_val = json!({"other_key": []});
3167
3168        // Must not panic when the expected arrays are absent.
3169        annotate_dead_code_json(&mut json_val, &results, &root, &base);
3170    }
3171
3172    // --- annotate_health_json ---
3173
3174    fn make_violation(path: &Path, name: &str) -> ComplexityViolation {
3175        ComplexityViolation {
3176            path: path.to_path_buf(),
3177            name: name.to_string(),
3178            line: 1,
3179            col: 0,
3180            cyclomatic: 20,
3181            cognitive: 5,
3182            line_count: 30,
3183            param_count: 2,
3184            react_hook_count: 0,
3185            react_jsx_max_depth: 0,
3186            react_prop_count: 0,
3187            react_hook_profile: None,
3188            exceeded: ExceededThreshold::Cyclomatic,
3189            severity: FindingSeverity::High,
3190            crap: None,
3191            coverage_pct: None,
3192            coverage_tier: None,
3193            coverage_source: None,
3194            inherited_from: None,
3195            component_rollup: None,
3196            contributions: Vec::new(),
3197            effective_thresholds: None,
3198            threshold_source: None,
3199        }
3200    }
3201
3202    fn make_health_report(paths_and_names: &[(&Path, &str)]) -> HealthReport {
3203        let findings = paths_and_names
3204            .iter()
3205            .map(|(path, name)| HealthFinding::from(make_violation(path, name)))
3206            .collect();
3207        HealthReport {
3208            findings,
3209            ..HealthReport::default()
3210        }
3211    }
3212
3213    #[test]
3214    fn health_keys_produces_stable_key_per_finding() {
3215        let root = root();
3216        let path = root.join("src/heavy.ts");
3217        let report = make_health_report(&[(&path, "processAll")]);
3218        let keys = health_keys(&report, &root);
3219        assert!(keys.contains("complexity:src/heavy.ts:processAll:Cyclomatic"));
3220    }
3221
3222    #[test]
3223    fn health_finding_key_uses_path_name_and_exceeded() {
3224        let root = root();
3225        let path = root.join("src/heavy.ts");
3226        let violation = make_violation(&path, "render");
3227        let key = health_finding_key(&violation, &root);
3228        assert_eq!(key, "complexity:src/heavy.ts:render:Cyclomatic");
3229    }
3230
3231    #[test]
3232    fn annotate_health_json_marks_introduced_and_inherited_flags() {
3233        let root = root();
3234        let path_a = root.join("src/heavy.ts");
3235        let path_b = root.join("src/other.ts");
3236        let report = make_health_report(&[(&path_a, "doWork"), (&path_b, "render")]);
3237
3238        // Only path_b:render is in the base.
3239        let base = FxHashSet::from_iter(["complexity:src/other.ts:render:Cyclomatic".to_string()]);
3240        let mut json_val = json!({
3241            "findings": [{}, {}],
3242        });
3243
3244        annotate_health_json(&mut json_val, &report, &root, &base);
3245
3246        assert_eq!(json_val["findings"][0]["introduced"], true);
3247        assert_eq!(json_val["findings"][1]["introduced"], false);
3248    }
3249
3250    #[test]
3251    fn annotate_health_json_is_noop_when_findings_key_absent() {
3252        let root = root();
3253        let report = make_health_report(&[]);
3254        let base = FxHashSet::default();
3255        let mut json_val = json!({"summary": {}});
3256        // Must not panic.
3257        annotate_health_json(&mut json_val, &report, &root, &base);
3258    }
3259
3260    // --- annotate_dupes_json and dupe_group_key ---
3261
3262    fn make_clone_group(files: &[PathBuf], fragment: &str) -> CloneGroup {
3263        CloneGroup {
3264            instances: files
3265                .iter()
3266                .map(|f| CloneInstance {
3267                    file: f.clone(),
3268                    start_line: 1,
3269                    end_line: 5,
3270                    start_col: 0,
3271                    end_col: 80,
3272                    fragment: fragment.to_string(),
3273                })
3274                .collect(),
3275            token_count: 10,
3276            line_count: 5,
3277        }
3278    }
3279
3280    fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3281        DuplicationReport {
3282            clone_groups: groups,
3283            clone_families: Vec::new(),
3284            mirrored_directories: Vec::new(),
3285            stats: fallow_core::duplicates::DuplicationStats::default(),
3286        }
3287    }
3288
3289    #[test]
3290    fn dupe_group_key_is_stable_for_sorted_deduplicated_files() {
3291        let root = root();
3292        let a = root.join("src/a.ts");
3293        let b = root.join("src/b.ts");
3294        // Build two groups with same fragment but different file order.
3295        let group_ab = make_clone_group(&[a.clone(), b.clone()], "const x = 1;");
3296        let group_ba = make_clone_group(&[b, a], "const x = 1;");
3297        let key_ab = dupe_group_key(&group_ab, &root);
3298        let key_ba = dupe_group_key(&group_ba, &root);
3299        // File list is sorted so both keys share the same file prefix.
3300        assert!(key_ab.starts_with("dupe:src/a.ts|src/b.ts:"));
3301        assert!(key_ba.starts_with("dupe:src/a.ts|src/b.ts:"));
3302        // Same fragment => same hash.
3303        assert_eq!(key_ab, key_ba);
3304    }
3305
3306    #[test]
3307    fn dupes_keys_produces_one_key_per_clone_group() {
3308        let root = root();
3309        let a = root.join("src/a.ts");
3310        let b = root.join("src/b.ts");
3311        let groups = vec![
3312            make_clone_group(&[a.clone(), b.clone()], "block one"),
3313            make_clone_group(&[a, b], "block two"),
3314        ];
3315        let report = make_duplication_report(groups);
3316        let keys = dupes_keys(&report, &root);
3317        assert_eq!(keys.len(), 2);
3318    }
3319
3320    #[test]
3321    fn annotate_dupes_json_marks_introduced_and_inherited_flags() {
3322        let root = root();
3323        let a = root.join("src/a.ts");
3324        let b = root.join("src/b.ts");
3325        let group_new = make_clone_group(&[a.clone(), b.clone()], "new block");
3326        let group_old = make_clone_group(&[a, b], "old block");
3327        let old_key = dupe_group_key(&group_old, &root);
3328        let base = FxHashSet::from_iter([old_key]);
3329        let report = make_duplication_report(vec![group_new, group_old]);
3330        let mut json_val = json!({
3331            "clone_groups": [{}, {}],
3332        });
3333
3334        annotate_dupes_json(&mut json_val, &report, &root, &base);
3335
3336        assert_eq!(json_val["clone_groups"][0]["introduced"], true);
3337        assert_eq!(json_val["clone_groups"][1]["introduced"], false);
3338    }
3339
3340    #[test]
3341    fn annotate_dupes_json_is_noop_when_clone_groups_key_absent() {
3342        let root = root();
3343        let report = make_duplication_report(Vec::new());
3344        let base = FxHashSet::default();
3345        let mut json_val = json!({"stats": {}});
3346        // Must not panic.
3347        annotate_dupes_json(&mut json_val, &report, &root, &base);
3348    }
3349
3350    // --- retain_introduced_dead_code with None base (no-op path, line 1127) ---
3351
3352    #[test]
3353    fn retain_introduced_dead_code_is_noop_when_base_is_none() {
3354        let root = root();
3355        let mut results = sample_results(&root);
3356        let original_file_count = results.unused_files.len();
3357        let original_export_count = results.unused_exports.len();
3358
3359        retain_introduced_dead_code(&mut results, &root, None);
3360
3361        // Nothing should be filtered when base is None.
3362        assert_eq!(results.unused_files.len(), original_file_count);
3363        assert_eq!(results.unused_exports.len(), original_export_count);
3364    }
3365
3366    // --- retain_introduced_dead_code covers framework / component / graph findings ---
3367
3368    #[test]
3369    fn retain_introduced_dead_code_filters_framework_findings() {
3370        let root = root();
3371        let src = root.join("src/App.vue");
3372        let mut results = AnalysisResults::default();
3373        results
3374            .unprovided_injects
3375            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3376                path: src.clone(),
3377                key_name: "existing".to_string(),
3378                framework: "vue".to_string(),
3379                line: 1,
3380                col: 0,
3381            }));
3382        results
3383            .unprovided_injects
3384            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3385                path: src.clone(),
3386                key_name: "new".to_string(),
3387                framework: "vue".to_string(),
3388                line: 2,
3389                col: 0,
3390            }));
3391        results
3392            .unrendered_components
3393            .push(UnrenderedComponentFinding::with_actions(
3394                UnrenderedComponent {
3395                    path: src,
3396                    component_name: "OldWidget".to_string(),
3397                    framework: "vue".to_string(),
3398                    reachable_via: None,
3399                    line: 1,
3400                    col: 0,
3401                },
3402            ));
3403
3404        let base = FxHashSet::from_iter([
3405            "unprovided-inject:src/App.vue:existing".to_string(),
3406            "unrendered-component:src/App.vue:OldWidget".to_string(),
3407        ]);
3408
3409        retain_introduced_dead_code(&mut results, &root, Some(&base));
3410
3411        // Only "new" inject survives; OldWidget is filtered.
3412        assert_eq!(results.unprovided_injects.len(), 1);
3413        assert_eq!(results.unprovided_injects[0].inject.key_name, "new");
3414        assert!(results.unrendered_components.is_empty());
3415    }
3416
3417    #[test]
3418    fn retain_introduced_dead_code_filters_graph_findings() {
3419        let root = root();
3420        let a = root.join("src/a.ts");
3421        let b = root.join("src/b.ts");
3422        let mut results = AnalysisResults::default();
3423        results
3424            .circular_dependencies
3425            .push(CircularDependencyFinding::with_actions(
3426                CircularDependency {
3427                    files: vec![a.clone(), b],
3428                    length: 2,
3429                    line: 1,
3430                    col: 0,
3431                    edges: Vec::new(),
3432                    is_cross_package: false,
3433                },
3434            ));
3435        results
3436            .re_export_cycles
3437            .push(ReExportCycleFinding::with_actions(ReExportCycle {
3438                files: vec![a],
3439                kind: ReExportCycleKind::SelfLoop,
3440            }));
3441
3442        // The circular dep is in the base; the re-export cycle is new.
3443        let base = FxHashSet::from_iter(["circular-dependency:src/a.ts|src/b.ts".to_string()]);
3444
3445        retain_introduced_dead_code(&mut results, &root, Some(&base));
3446
3447        assert!(results.circular_dependencies.is_empty());
3448        assert_eq!(results.re_export_cycles.len(), 1);
3449    }
3450}