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