Skip to main content

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