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