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::{FxHashMap, FxHashSet};
6
7use fallow_config::{ResolvedConfig, Severity};
8use fallow_types::envelope::AuditIntroduced;
9
10/// One dead-code finding classified for audit comparison.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AuditFindingRecord {
13    /// JSON collection containing the finding.
14    pub collection: &'static str,
15    /// Stable position inside the collection for typed annotation routing.
16    pub ordinal: usize,
17    /// Stable cross-run identity used for base comparison.
18    pub stable_key: String,
19    /// Whether the finding is absent from the base snapshot.
20    pub introduced: bool,
21    /// Effective rule severity after per-file overrides.
22    pub effective_severity: Severity,
23}
24
25/// Exhaustive dead-code comparison ledger shared by audit verdict and output.
26#[derive(Debug, Clone, Default)]
27pub struct DeadCodeAuditLedger {
28    records: Vec<AuditFindingRecord>,
29    keys: FxHashSet<String>,
30    introduced_keys: FxHashSet<String>,
31    inherited_keys: FxHashSet<String>,
32    #[cfg(test)]
33    classifications: usize,
34}
35
36impl DeadCodeAuditLedger {
37    /// Classified findings in deterministic output order.
38    #[must_use]
39    pub fn records(&self) -> &[AuditFindingRecord] {
40        &self.records
41    }
42
43    /// Stable current-run key set for cache snapshots and graph hashes.
44    #[must_use]
45    pub const fn keys(&self) -> &FxHashSet<String> {
46        &self.keys
47    }
48
49    #[cfg(test)]
50    const fn classification_count(&self) -> usize {
51        self.classifications
52    }
53
54    /// Number of visible findings introduced since the base snapshot.
55    #[must_use]
56    pub fn introduced_count(&self) -> usize {
57        self.introduced_keys.len()
58    }
59
60    /// Number of findings whose effective severity is not off.
61    #[must_use]
62    pub fn visible_count(&self) -> usize {
63        self.records
64            .iter()
65            .filter(|record| record.effective_severity != Severity::Off)
66            .count()
67    }
68
69    /// Number of visible findings inherited from the base snapshot.
70    #[must_use]
71    pub fn inherited_count(&self) -> usize {
72        self.inherited_keys.len()
73    }
74
75    /// Whether an introduced finding has effective error severity.
76    #[must_use]
77    pub fn has_introduced_errors(&self) -> bool {
78        self.records
79            .iter()
80            .any(|record| record.introduced && record.effective_severity == Severity::Error)
81    }
82
83    /// Whether an introduced finding has effective warning severity.
84    #[must_use]
85    pub fn has_introduced_warnings(&self) -> bool {
86        self.records
87            .iter()
88            .any(|record| record.introduced && record.effective_severity == Severity::Warn)
89    }
90
91    /// Whether any current finding has effective error severity.
92    #[must_use]
93    pub fn has_errors(&self) -> bool {
94        self.records
95            .iter()
96            .any(|record| record.effective_severity == Severity::Error)
97    }
98
99    /// Demote introduced findings that have no syntactic counterpart in the
100    /// current head run to inherited (advisory) status.
101    ///
102    /// Used by the audit's degraded type-aware path: when base and head
103    /// semantic identities cannot be compared, attribution diffs the
104    /// pre-refinement syntactic key sets. A head finding absent from its own
105    /// syntactic set exists only because of semantic evidence and cannot be
106    /// attributed against a syntactic base, so it must not fail the new-only
107    /// gate. Findings present syntactically keep their classification, so a
108    /// genuinely new syntactic finding still gates.
109    pub fn demote_unattributable_introductions(&mut self, head_syntactic: &FxHashSet<String>) {
110        for record in &mut self.records {
111            if record.introduced && !head_syntactic.contains(&record.stable_key) {
112                record.introduced = false;
113                self.introduced_keys.remove(&record.stable_key);
114                if record.effective_severity != Severity::Off {
115                    self.inherited_keys.insert(record.stable_key.clone());
116                }
117            }
118        }
119    }
120
121    /// Persist comparison membership into existing typed output fields.
122    ///
123    /// `StaleSuppression` is the sole legacy finding without a typed
124    /// `introduced` slot. Its serializer keeps a narrow stable-key fallback.
125    #[expect(
126        clippy::too_many_lines,
127        reason = "exhaustive result destructuring and field annotation must stay together so new finding collections require an explicit audit decision"
128    )]
129    pub fn annotate_results(&self, results: &mut fallow_types::results::AnalysisResults) {
130        let fallow_types::results::AnalysisResults {
131            unused_files,
132            unused_exports,
133            unused_types,
134            private_type_leaks,
135            unused_dependencies,
136            unused_dev_dependencies,
137            unused_optional_dependencies,
138            unused_enum_members,
139            unused_class_members,
140            unused_store_members,
141            unresolved_imports,
142            unlisted_dependencies,
143            duplicate_exports,
144            type_only_dependencies,
145            test_only_dependencies,
146            dev_dependencies_in_production,
147            circular_dependencies,
148            re_export_cycles,
149            boundary_violations,
150            boundary_coverage_violations,
151            boundary_call_violations,
152            policy_violations,
153            stale_suppressions: _stale_suppressions,
154            unused_catalog_entries,
155            empty_catalog_groups,
156            unresolved_catalog_references,
157            unused_dependency_overrides,
158            misconfigured_dependency_overrides,
159            invalid_client_exports,
160            mixed_client_server_barrels,
161            misplaced_directives,
162            unprovided_injects,
163            unrendered_components,
164            unused_component_props,
165            unused_component_emits,
166            unused_component_inputs,
167            unused_component_outputs,
168            unused_svelte_events,
169            unused_server_actions,
170            unused_load_data_keys,
171            unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
172            route_collisions,
173            dynamic_segment_name_conflicts,
174            suppression_count: _suppression_count,
175            unused_component_props_exempted: _unused_component_props_exempted,
176            active_suppressions: _active_suppressions,
177            feature_flags: _feature_flags,
178            security_findings: _security_findings,
179            security_unresolved_edge_files: _security_unresolved_edge_files,
180            security_unresolved_callee_sites: _security_unresolved_callee_sites,
181            security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
182            prop_drilling_chains: _prop_drilling_chains,
183            thin_wrappers: _thin_wrappers,
184            duplicate_prop_shapes: _duplicate_prop_shapes,
185            export_usages: _export_usages,
186            entry_point_summary: _entry_point_summary,
187            render_fan_in: _render_fan_in,
188            react_component_intel: _react_component_intel,
189            semantic_framework_contracts: _semantic_framework_contracts,
190        } = results;
191
192        macro_rules! annotate {
193            ($field:ident, $collection:literal) => {
194                for (item, introduced) in $field.iter_mut().zip(
195                    self.records
196                        .iter()
197                        .filter(|record| record.collection == $collection)
198                        .map(|record| record.introduced),
199                ) {
200                    item.introduced = Some(AuditIntroduced(introduced));
201                }
202            };
203        }
204
205        annotate!(unused_files, "unused_files");
206        annotate!(unused_exports, "unused_exports");
207        annotate!(unused_types, "unused_types");
208        annotate!(private_type_leaks, "private_type_leaks");
209        annotate!(unused_dependencies, "unused_dependencies");
210        annotate!(unused_dev_dependencies, "unused_dev_dependencies");
211        annotate!(unused_optional_dependencies, "unused_optional_dependencies");
212        annotate!(unused_enum_members, "unused_enum_members");
213        annotate!(unused_class_members, "unused_class_members");
214        annotate!(unused_store_members, "unused_store_members");
215        annotate!(unresolved_imports, "unresolved_imports");
216        annotate!(unlisted_dependencies, "unlisted_dependencies");
217        annotate!(duplicate_exports, "duplicate_exports");
218        annotate!(type_only_dependencies, "type_only_dependencies");
219        annotate!(test_only_dependencies, "test_only_dependencies");
220        annotate!(
221            dev_dependencies_in_production,
222            "dev_dependencies_in_production"
223        );
224        annotate!(circular_dependencies, "circular_dependencies");
225        annotate!(re_export_cycles, "re_export_cycles");
226        annotate!(boundary_violations, "boundary_violations");
227        annotate!(boundary_coverage_violations, "boundary_coverage_violations");
228        annotate!(boundary_call_violations, "boundary_call_violations");
229        annotate!(policy_violations, "policy_violations");
230        annotate!(unused_catalog_entries, "unused_catalog_entries");
231        annotate!(empty_catalog_groups, "empty_catalog_groups");
232        annotate!(
233            unresolved_catalog_references,
234            "unresolved_catalog_references"
235        );
236        annotate!(unused_dependency_overrides, "unused_dependency_overrides");
237        annotate!(
238            misconfigured_dependency_overrides,
239            "misconfigured_dependency_overrides"
240        );
241        annotate!(invalid_client_exports, "invalid_client_exports");
242        annotate!(mixed_client_server_barrels, "mixed_client_server_barrels");
243        annotate!(misplaced_directives, "misplaced_directives");
244        annotate!(unprovided_injects, "unprovided_injects");
245        annotate!(unrendered_components, "unrendered_components");
246        annotate!(route_collisions, "route_collisions");
247        annotate!(
248            dynamic_segment_name_conflicts,
249            "dynamic_segment_name_conflicts"
250        );
251        annotate!(unused_component_props, "unused_component_props");
252        annotate!(unused_component_emits, "unused_component_emits");
253        annotate!(unused_component_inputs, "unused_component_inputs");
254        annotate!(unused_component_outputs, "unused_component_outputs");
255        annotate!(unused_svelte_events, "unused_svelte_events");
256        annotate!(unused_server_actions, "unused_server_actions");
257        annotate!(unused_load_data_keys, "unused_load_data_keys");
258    }
259}
260
261/// Stable-key membership for one non-dead-code audit domain.
262#[derive(Debug, Clone, Default)]
263pub struct AuditDomainLedger {
264    records: Vec<(String, bool)>,
265    keys: FxHashSet<String>,
266    introduced_keys: FxHashSet<String>,
267    inherited_keys: FxHashSet<String>,
268}
269
270impl AuditDomainLedger {
271    /// Compare ordered stable keys against an optional base snapshot.
272    #[must_use]
273    pub fn compare(
274        keys: impl IntoIterator<Item = String>,
275        base: Option<&FxHashSet<String>>,
276    ) -> Self {
277        let mut records = Vec::new();
278        let mut unique_keys = FxHashSet::default();
279        let mut introduced_keys = FxHashSet::default();
280        let mut inherited_keys = FxHashSet::default();
281        for key in keys {
282            let introduced = base.is_some_and(|base| !base.contains(&key));
283            if base.is_some() {
284                if introduced {
285                    introduced_keys.insert(key.clone());
286                } else {
287                    inherited_keys.insert(key.clone());
288                }
289            }
290            unique_keys.insert(key.clone());
291            records.push((key, introduced));
292        }
293        Self {
294            records,
295            keys: unique_keys,
296            introduced_keys,
297            inherited_keys,
298        }
299    }
300
301    /// Stable current-run key set.
302    #[must_use]
303    pub const fn keys(&self) -> &FxHashSet<String> {
304        &self.keys
305    }
306
307    /// Number of introduced findings.
308    #[must_use]
309    pub fn introduced_count(&self) -> usize {
310        self.introduced_keys.len()
311    }
312
313    /// Number of inherited findings.
314    #[must_use]
315    pub fn inherited_count(&self) -> usize {
316        self.inherited_keys.len()
317    }
318
319    /// Introduced membership in typed output order.
320    pub fn introduced(&self) -> impl ExactSizeIterator<Item = bool> + '_ {
321        self.records.iter().map(|(_, introduced)| *introduced)
322    }
323
324    /// Demote introduced findings whose keys are in `demote` to inherited
325    /// (advisory) status so they no longer fail the new-only gate.
326    pub fn demote_introductions(&mut self, demote: &FxHashSet<String>) {
327        for (key, introduced) in &mut self.records {
328            if *introduced && demote.contains(key) {
329                *introduced = false;
330                self.introduced_keys.remove(key);
331                self.inherited_keys.insert(key.clone());
332            }
333        }
334    }
335}
336
337/// One-pass audit comparison shared by attribution, verdict, and annotations.
338#[derive(Debug, Clone, Default)]
339pub struct AuditComparison {
340    /// Dead-code finding ledger with effective severity routing.
341    pub dead_code: DeadCodeAuditLedger,
342    /// Complexity finding membership.
343    pub health: AuditDomainLedger,
344    /// Duplication group membership.
345    pub dupes: AuditDomainLedger,
346    /// Styling finding membership.
347    pub styling: AuditDomainLedger,
348}
349
350/// Inputs for building one [`AuditComparison`].
351pub struct AuditComparisonInput<'a> {
352    /// Current-run dead-code analysis results to classify.
353    pub results: &'a fallow_types::results::AnalysisResults,
354    /// Resolved config that drives effective severity routing.
355    pub config: &'a ResolvedConfig,
356    /// Project root that dead-code key paths are made relative to.
357    pub root: &'a Path,
358    /// Current-run health report whose findings are classified.
359    pub health: &'a fallow_output::HealthReport,
360    /// Root that health finding key paths are made relative to; may differ
361    /// from `root` when health runs over a different tree.
362    pub health_root: &'a Path,
363    /// Precomputed duplication group keys in typed output order.
364    pub dupe_keys: Vec<String>,
365    /// Precomputed styling finding keys in typed output order.
366    pub styling_keys: Vec<String>,
367    /// Base-snapshot dead-code keys; `None` disables attribution so no
368    /// finding is marked introduced.
369    pub base_dead_code: Option<&'a FxHashSet<String>>,
370    /// Base-snapshot complexity keys; `None` disables attribution so no
371    /// finding is marked introduced.
372    pub base_health: Option<&'a FxHashSet<String>>,
373    /// Base-snapshot duplication keys; `None` disables attribution so no
374    /// group is marked introduced.
375    pub base_dupes: Option<&'a FxHashSet<String>>,
376    /// Base-snapshot styling keys; `None` disables attribution so no
377    /// finding is marked introduced.
378    pub base_styling: Option<&'a FxHashSet<String>>,
379}
380
381impl AuditComparison {
382    /// Classify every audit finding once against the base snapshot.
383    #[must_use]
384    pub fn build(input: AuditComparisonInput<'_>) -> Self {
385        Self {
386            dead_code: dead_code_audit_ledger(
387                input.results,
388                input.root,
389                input.config,
390                input.base_dead_code,
391            ),
392            health: AuditDomainLedger::compare(
393                input
394                    .health
395                    .findings
396                    .iter()
397                    .map(|finding| health_finding_key(finding, input.health_root)),
398                input.base_health,
399            ),
400            dupes: AuditDomainLedger::compare(input.dupe_keys, input.base_dupes),
401            styling: AuditDomainLedger::compare(input.styling_keys, input.base_styling),
402        }
403    }
404
405    /// Persist introduced membership into typed dead-code and health findings.
406    pub fn annotate_typed_findings(
407        &self,
408        results: &mut fallow_types::results::AnalysisResults,
409        health: &mut fallow_output::HealthReport,
410    ) {
411        self.dead_code.annotate_results(results);
412        for (finding, introduced) in health.findings.iter_mut().zip(self.health.introduced()) {
413            finding.introduced = Some(introduced);
414        }
415    }
416}
417
418/// Remap root-relative path tokens inside base-snapshot attribution keys so a
419/// finding on a renamed file matches its base counterpart under the old path.
420///
421/// `renames` maps base-relative old paths to head-relative new paths, both in
422/// [`relative_key_path`] form. Keys embed paths as whole `:`-separated
423/// segments, with sorted `|`-separated path lists inside one segment for
424/// multi-file findings (duplication groups, circular dependencies, duplicate
425/// exports). Segments are remapped only on an exact path match, and
426/// multi-path list segments are re-sorted after remapping so they stay
427/// canonical. Non-path segments (names, lines, hashes) never collide with a
428/// relative path and pass through unchanged.
429#[must_use]
430#[expect(
431    clippy::implicit_hasher,
432    reason = "fallow standardizes on FxHashMap/FxHashSet across audit attribution keys"
433)]
434pub fn remap_keys_for_renames(
435    keys: &FxHashSet<String>,
436    renames: &FxHashMap<String, String>,
437) -> FxHashSet<String> {
438    keys.iter()
439        .map(|key| remap_key_for_renames(key, renames))
440        .collect()
441}
442
443fn remap_key_for_renames(key: &str, renames: &FxHashMap<String, String>) -> String {
444    if !key
445        .split([':', '|'])
446        .any(|segment| renames.contains_key(segment))
447    {
448        return key.to_string();
449    }
450    let segments: Vec<String> = key
451        .split(':')
452        .map(|segment| {
453            if segment.contains('|') {
454                let mut pieces: Vec<&str> = segment
455                    .split('|')
456                    .map(|piece| renames.get(piece).map_or(piece, String::as_str))
457                    .collect();
458                pieces.sort_unstable();
459                pieces.dedup();
460                pieces.join("|")
461            } else {
462                renames
463                    .get(segment)
464                    .cloned()
465                    .unwrap_or_else(|| segment.to_string())
466            }
467        })
468        .collect();
469    segments.join(":")
470}
471
472/// Canonical path form used inside attribution keys: root-relative with
473/// forward slashes on every platform. Paths outside `root` keep their
474/// simplified absolute form so keys stay unique.
475pub fn relative_key_path(path: &Path, root: &Path) -> String {
476    let simple_path = dunce::simplified(path);
477    let simple_root = dunce::simplified(root);
478    simple_path
479        .strip_prefix(simple_root)
480        .unwrap_or(simple_path)
481        .to_string_lossy()
482        .replace('\\', "/")
483}
484
485fn dependency_location_key(location: &fallow_types::results::DependencyLocation) -> &'static str {
486    match location {
487        fallow_types::results::DependencyLocation::Dependencies => "unused-dependency",
488        fallow_types::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
489        fallow_types::results::DependencyLocation::OptionalDependencies => {
490            "unused-optional-dependency"
491        }
492    }
493}
494
495fn unused_dependency_key(item: &fallow_types::results::UnusedDependency, root: &Path) -> String {
496    format!(
497        "{}:{}:{}",
498        dependency_location_key(&item.location),
499        relative_key_path(&item.path, root),
500        item.package_name
501    )
502}
503
504fn invalid_client_export_key(
505    item: &fallow_types::results::InvalidClientExport,
506    root: &Path,
507) -> String {
508    format!(
509        "invalid-client-export:{}:{}",
510        relative_key_path(&item.path, root),
511        item.export_name
512    )
513}
514
515fn mixed_client_server_barrel_key(
516    item: &fallow_types::results::MixedClientServerBarrel,
517    root: &Path,
518) -> String {
519    format!(
520        "mixed-client-server-barrel:{}:{}:{}",
521        relative_key_path(&item.path, root),
522        item.client_origin,
523        item.server_origin
524    )
525}
526
527fn misplaced_directive_key(
528    item: &fallow_types::results::MisplacedDirective,
529    root: &Path,
530) -> String {
531    format!(
532        "misplaced-directive:{}:{}:{}",
533        relative_key_path(&item.path, root),
534        item.line,
535        item.directive
536    )
537}
538
539fn unprovided_inject_key(item: &fallow_types::results::UnprovidedInject, root: &Path) -> String {
540    format!(
541        "unprovided-inject:{}:{}",
542        relative_key_path(&item.path, root),
543        item.key_name
544    )
545}
546
547fn unrendered_component_key(
548    item: &fallow_types::results::UnrenderedComponent,
549    root: &Path,
550) -> String {
551    format!(
552        "unrendered-component:{}:{}",
553        relative_key_path(&item.path, root),
554        item.component_name
555    )
556}
557
558fn unused_component_prop_key(
559    item: &fallow_types::results::UnusedComponentProp,
560    root: &Path,
561) -> String {
562    format!(
563        "unused-component-prop:{}:{}",
564        relative_key_path(&item.path, root),
565        item.prop_name
566    )
567}
568
569fn unused_component_emit_key(
570    item: &fallow_types::results::UnusedComponentEmit,
571    root: &Path,
572) -> String {
573    format!(
574        "unused-component-emit:{}:{}",
575        relative_key_path(&item.path, root),
576        item.emit_name
577    )
578}
579
580fn unused_component_input_key(
581    item: &fallow_types::results::UnusedComponentInput,
582    root: &Path,
583) -> String {
584    format!(
585        "unused-component-input:{}:{}",
586        relative_key_path(&item.path, root),
587        item.input_name
588    )
589}
590
591fn unused_component_output_key(
592    item: &fallow_types::results::UnusedComponentOutput,
593    root: &Path,
594) -> String {
595    format!(
596        "unused-component-output:{}:{}",
597        relative_key_path(&item.path, root),
598        item.output_name
599    )
600}
601
602fn unused_svelte_event_key(item: &fallow_types::results::UnusedSvelteEvent, root: &Path) -> String {
603    format!(
604        "unused-svelte-event:{}:{}",
605        relative_key_path(&item.path, root),
606        item.event_name
607    )
608}
609
610fn unused_server_action_key(
611    item: &fallow_types::results::UnusedServerAction,
612    root: &Path,
613) -> String {
614    format!(
615        "unused-server-action:{}:{}",
616        relative_key_path(&item.path, root),
617        item.action_name
618    )
619}
620
621fn unused_load_data_key_key(
622    item: &fallow_types::results::UnusedLoadDataKey,
623    root: &Path,
624) -> String {
625    format!(
626        "unused-load-data-key:{}:{}",
627        relative_key_path(&item.path, root),
628        item.key_name
629    )
630}
631
632fn route_collision_key(item: &fallow_types::results::RouteCollision, root: &Path) -> String {
633    format!(
634        "route-collision:{}:{}",
635        relative_key_path(&item.path, root),
636        item.url
637    )
638}
639
640fn dynamic_segment_name_conflict_key(
641    item: &fallow_types::results::DynamicSegmentNameConflict,
642    root: &Path,
643) -> String {
644    format!(
645        "dynamic-segment-name-conflict:{}:{}",
646        relative_key_path(&item.path, root),
647        item.position
648    )
649}
650
651fn unlisted_dependency_key(
652    item: &fallow_types::results::UnlistedDependency,
653    root: &Path,
654) -> String {
655    let mut sites = item
656        .imported_from
657        .iter()
658        .map(|site| {
659            format!(
660                "{}:{}:{}",
661                relative_key_path(&site.path, root),
662                site.line,
663                site.col
664            )
665        })
666        .collect::<Vec<_>>();
667    sites.sort();
668    sites.dedup();
669    format!(
670        "unlisted-dependency:{}:{}",
671        item.package_name,
672        sites.join("|")
673    )
674}
675
676fn unused_member_key(
677    rule_id: &str,
678    item: &fallow_types::results::UnusedMember,
679    root: &Path,
680) -> String {
681    format!(
682        "{}:{}:{}:{}",
683        rule_id,
684        relative_key_path(&item.path, root),
685        item.parent_name,
686        item.member_name
687    )
688}
689
690fn unused_catalog_entry_key(
691    item: &fallow_types::results::UnusedCatalogEntry,
692    root: &Path,
693) -> String {
694    format!(
695        "unused-catalog-entry:{}:{}:{}:{}",
696        relative_key_path(&item.path, root),
697        item.line,
698        item.catalog_name,
699        item.entry_name
700    )
701}
702
703fn empty_catalog_group_key(item: &fallow_types::results::EmptyCatalogGroup, root: &Path) -> String {
704    format!(
705        "empty-catalog-group:{}:{}:{}",
706        relative_key_path(&item.path, root),
707        item.line,
708        item.catalog_name
709    )
710}
711
712fn sorted_relative_path_keys<'a>(
713    paths: impl Iterator<Item = &'a Path>,
714    root: &Path,
715) -> Vec<String> {
716    let mut keys = paths
717        .map(|path| relative_key_path(path, root))
718        .collect::<Vec<_>>();
719    keys.sort();
720    keys
721}
722
723fn duplicate_export_key(
724    item: &fallow_types::output_dead_code::DuplicateExportFinding,
725    root: &Path,
726) -> String {
727    let mut locations = sorted_relative_path_keys(
728        item.export.locations.iter().map(|loc| loc.path.as_path()),
729        root,
730    );
731    locations.dedup();
732    format!(
733        "duplicate-export:{}:{}",
734        item.export.export_name,
735        locations.join("|")
736    )
737}
738
739fn circular_dependency_key(
740    item: &fallow_types::output_dead_code::CircularDependencyFinding,
741    root: &Path,
742) -> String {
743    let files = sorted_relative_path_keys(
744        item.cycle.files.iter().map(std::path::PathBuf::as_path),
745        root,
746    );
747    format!("circular-dependency:{}", files.join("|"))
748}
749
750fn re_export_cycle_key(
751    item: &fallow_types::output_dead_code::ReExportCycleFinding,
752    root: &Path,
753) -> String {
754    let kind = match item.cycle.kind {
755        fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
756        fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
757    };
758    let files = sorted_relative_path_keys(
759        item.cycle.files.iter().map(std::path::PathBuf::as_path),
760        root,
761    );
762    format!("re-export-cycle:{kind}:{}", files.join("|"))
763}
764
765fn boundary_violation_key(
766    item: &fallow_types::output_dead_code::BoundaryViolationFinding,
767    root: &Path,
768) -> String {
769    format!(
770        "boundary-violation:{}:{}:{}",
771        relative_key_path(&item.violation.from_path, root),
772        relative_key_path(&item.violation.to_path, root),
773        item.violation.import_specifier
774    )
775}
776
777fn boundary_coverage_key(
778    item: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
779    root: &Path,
780) -> String {
781    format!(
782        "boundary-coverage:{}",
783        relative_key_path(&item.violation.path, root)
784    )
785}
786
787fn boundary_call_key(
788    item: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
789    root: &Path,
790) -> String {
791    format!(
792        "boundary-call:{}:{}",
793        relative_key_path(&item.violation.path, root),
794        item.violation.callee
795    )
796}
797
798fn policy_violation_key(
799    item: &fallow_types::output_dead_code::PolicyViolationFinding,
800    root: &Path,
801) -> String {
802    format!(
803        "policy-violation:{}:{}/{}:{}",
804        relative_key_path(&item.violation.path, root),
805        item.violation.pack,
806        item.violation.rule_id,
807        item.violation.matched
808    )
809}
810
811fn stale_suppression_key(item: &fallow_types::results::StaleSuppression, root: &Path) -> String {
812    let rule_id = if item.missing_reason {
813        "missing-suppression-reason"
814    } else {
815        "stale-suppression"
816    };
817    format!(
818        "{rule_id}:{}:{}",
819        relative_key_path(&item.path, root),
820        item.description()
821    )
822}
823
824fn unresolved_catalog_reference_key(
825    item: &fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding,
826    root: &Path,
827) -> String {
828    format!(
829        "unresolved-catalog-reference:{}:{}:{}:{}",
830        relative_key_path(&item.reference.path, root),
831        item.reference.line,
832        item.reference.catalog_name,
833        item.reference.entry_name
834    )
835}
836
837fn unused_dependency_override_key(
838    item: &fallow_types::output_dead_code::UnusedDependencyOverrideFinding,
839    root: &Path,
840) -> String {
841    format!(
842        "unused-dependency-override:{}:{}:{}",
843        relative_key_path(&item.entry.path, root),
844        item.entry.line,
845        item.entry.raw_key
846    )
847}
848
849fn misconfigured_dependency_override_key(
850    item: &fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding,
851    root: &Path,
852) -> String {
853    format!(
854        "misconfigured-dependency-override:{}:{}:{}",
855        relative_key_path(&item.entry.path, root),
856        item.entry.line,
857        item.entry.raw_key
858    )
859}
860
861/// Build the set of audit attribution keys for all dead-code findings in
862/// `results`.
863///
864/// Each key is a stable string that uniquely identifies one finding across
865/// runs (e.g. `unused-file:src/dead.ts`, `unused-export:src/a.ts:Foo`).
866/// `retain_introduced_dead_code` and `annotate_dead_code_json` use the same
867/// key format to diff the current run against a base snapshot.
868///
869/// This destructure is deliberately exhaustive: adding a field to
870/// `AnalysisResults` must fail compilation here so the author decides
871/// explicitly whether the new finding type needs an audit key (add a loop)
872/// or has no key representation today (bind with underscore and document why).
873///
874/// Sibling exhaustive sites: `fallow_engine::changed_files::filter_results_by_changed_files`,
875/// The six dependency-related finding slices, bundled so the dependency
876/// dispatcher takes one parameter instead of six.
877#[derive(Clone, Copy)]
878#[allow(
879    clippy::struct_field_names,
880    reason = "field names mirror the AnalysisResults field names so the destructure stays shorthand"
881)]
882struct DependencyFindingSlices<'a> {
883    unused_dependencies: &'a [fallow_types::output_dead_code::UnusedDependencyFinding],
884    unused_dev_dependencies: &'a [fallow_types::output_dead_code::UnusedDevDependencyFinding],
885    unused_optional_dependencies:
886        &'a [fallow_types::output_dead_code::UnusedOptionalDependencyFinding],
887    unlisted_dependencies: &'a [fallow_types::output_dead_code::UnlistedDependencyFinding],
888    type_only_dependencies: &'a [fallow_types::output_dead_code::TypeOnlyDependencyFinding],
889    test_only_dependencies: &'a [fallow_types::output_dead_code::TestOnlyDependencyFinding],
890    dev_dependencies_in_production:
891        &'a [fallow_types::output_dead_code::DevDependencyInProductionFinding],
892}
893
894/// The six framework-specific finding slices, bundled so the framework
895/// dispatcher takes one parameter instead of six.
896#[derive(Clone, Copy)]
897struct FrameworkFindingSlices<'a> {
898    unprovided_injects: &'a [fallow_types::output_dead_code::UnprovidedInjectFinding],
899    unrendered_components: &'a [fallow_types::output_dead_code::UnrenderedComponentFinding],
900    unused_server_actions: &'a [fallow_types::output_dead_code::UnusedServerActionFinding],
901    unused_load_data_keys: &'a [fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
902    route_collisions: &'a [fallow_types::output_dead_code::RouteCollisionFinding],
903    dynamic_segment_name_conflicts:
904        &'a [fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
905}
906
907/// `dead_code_keys`, `retain_introduced_dead_code`.
908/// Non-exhaustive siblings the compiler will NOT flag (wire manually when a
909/// finding type is added): `annotate_dead_code_json` (same key formats, this
910/// file) and the per-collection severity branches in
911/// `crates/cli/src/check/rules.rs` (`apply_rules`, `has_error_severity_issues`).
912/// TypeScript mirror: `editors/vscode/scripts/codegen-contracts.mjs` derives
913/// backwards-compatible aliases from `fallow schema` `ts_alias` rows.
914pub fn dead_code_keys(
915    results: &fallow_types::results::AnalysisResults,
916    root: &Path,
917) -> FxHashSet<String> {
918    let mut collector = DeadCodeKeyCollector::new(root);
919    collector.add_all_findings(results);
920    collector.into_keys()
921}
922
923/// Build the exhaustive dead-code comparison ledger once for an audit run.
924#[must_use]
925#[expect(
926    clippy::implicit_hasher,
927    reason = "fallow standardizes on FxHashSet across audit attribution keys"
928)]
929pub fn dead_code_audit_ledger(
930    results: &fallow_types::results::AnalysisResults,
931    root: &Path,
932    config: &ResolvedConfig,
933    base: Option<&FxHashSet<String>>,
934) -> DeadCodeAuditLedger {
935    let mut collector = DeadCodeKeyCollector::for_comparison(root, config, base);
936    collector.add_all_findings(results);
937    collector.into_ledger()
938}
939
940impl DeadCodeKeyCollector<'_> {
941    #[expect(
942        clippy::too_many_lines,
943        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"
944    )]
945    fn add_all_findings(&mut self, results: &fallow_types::results::AnalysisResults) {
946        let fallow_types::results::AnalysisResults {
947            unused_files,
948            unused_exports,
949            unused_types,
950            private_type_leaks,
951            unused_dependencies,
952            unused_dev_dependencies,
953            unused_optional_dependencies,
954            unused_enum_members,
955            unused_class_members,
956            unused_store_members,
957            unresolved_imports,
958            unlisted_dependencies,
959            duplicate_exports,
960            type_only_dependencies,
961            test_only_dependencies,
962            dev_dependencies_in_production,
963            circular_dependencies,
964            re_export_cycles,
965            boundary_violations,
966            boundary_coverage_violations,
967            boundary_call_violations,
968            policy_violations,
969            stale_suppressions,
970            unused_catalog_entries,
971            empty_catalog_groups,
972            unresolved_catalog_references,
973            unused_dependency_overrides,
974            misconfigured_dependency_overrides,
975            invalid_client_exports,
976            mixed_client_server_barrels,
977            misplaced_directives,
978            unprovided_injects,
979            unrendered_components,
980            unused_component_props,
981            unused_component_emits,
982            unused_component_inputs,
983            unused_component_outputs,
984            unused_svelte_events,
985            unused_server_actions,
986            unused_load_data_keys,
987            unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
988            route_collisions,
989            dynamic_segment_name_conflicts,
990            // Non-finding fields: counts and metadata, not attributable to a key.
991            suppression_count: _suppression_count,
992            unused_component_props_exempted: _unused_component_props_exempted,
993            active_suppressions: _active_suppressions,
994            feature_flags: _feature_flags,
995            // Security findings are emitted via `fallow security`, not the audit
996            // dead-code gate; they have no dead-code key representation today.
997            security_findings: _security_findings,
998            security_unresolved_edge_files: _security_unresolved_edge_files,
999            security_unresolved_callee_sites: _security_unresolved_callee_sites,
1000            security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
1001            // Prop-drilling is a dormant multi-file health signal (rule defaults to
1002            // `off`); like security findings it has no dead-code attribution key.
1003            prop_drilling_chains: _prop_drilling_chains,
1004            // Thin wrappers are a dormant health signal (rule defaults to `off`); a
1005            // candidate-for-inlining record, not a dead-code attribution key.
1006            thin_wrappers: _thin_wrappers,
1007            // Duplicate prop shapes are a dormant multi-file health signal (rule
1008            // defaults to `off`); a missing-abstraction record, not a dead-code
1009            // attribution key.
1010            duplicate_prop_shapes: _duplicate_prop_shapes,
1011            // Export usages and entry-point summary are metadata, not issue
1012            // collections; no key needed.
1013            export_usages: _export_usages,
1014            entry_point_summary: _entry_point_summary,
1015            // Render fan-in is a whole-project descriptive metric, not an issue
1016            // collection; no attribution key needed.
1017            render_fan_in: _render_fan_in,
1018            // Per-component React intel is a descriptive ambient-editor carrier,
1019            // not an issue collection; no attribution key needed.
1020            react_component_intel: _react_component_intel,
1021            semantic_framework_contracts: _semantic_framework_contracts,
1022        } = results;
1023
1024        self.add_core_findings(
1025            unused_files,
1026            unused_exports,
1027            unused_types,
1028            private_type_leaks,
1029        );
1030        self.add_client_directive_findings(
1031            invalid_client_exports,
1032            mixed_client_server_barrels,
1033            misplaced_directives,
1034        );
1035        self.add_dependency_findings(&DependencyFindingSlices {
1036            unused_dependencies,
1037            unused_dev_dependencies,
1038            unused_optional_dependencies,
1039            unlisted_dependencies,
1040            type_only_dependencies,
1041            test_only_dependencies,
1042            dev_dependencies_in_production,
1043        });
1044        self.add_dependency_override_findings(
1045            unused_dependency_overrides,
1046            misconfigured_dependency_overrides,
1047        );
1048        self.add_member_findings(
1049            unused_enum_members,
1050            unused_class_members,
1051            unused_store_members,
1052        );
1053        self.add_component_contract_findings(
1054            unused_component_props,
1055            unused_component_emits,
1056            unused_component_inputs,
1057            unused_component_outputs,
1058            unused_svelte_events,
1059        );
1060        self.add_graph_findings(
1061            unresolved_imports,
1062            duplicate_exports,
1063            circular_dependencies,
1064            re_export_cycles,
1065        );
1066        self.add_boundary_findings(
1067            boundary_violations,
1068            boundary_coverage_violations,
1069            boundary_call_violations,
1070            policy_violations,
1071            stale_suppressions,
1072        );
1073        self.add_catalog_findings(
1074            unresolved_catalog_references,
1075            unused_catalog_entries,
1076            empty_catalog_groups,
1077        );
1078        self.add_framework_findings(&FrameworkFindingSlices {
1079            unprovided_injects,
1080            unrendered_components,
1081            unused_server_actions,
1082            unused_load_data_keys,
1083            route_collisions,
1084            dynamic_segment_name_conflicts,
1085        });
1086    }
1087}
1088
1089#[derive(Clone, Copy)]
1090enum AuditCollection {
1091    UnusedFiles,
1092    UnusedExports,
1093    UnusedTypes,
1094    PrivateTypeLeaks,
1095    UnusedDependencies,
1096    UnusedDevDependencies,
1097    UnusedOptionalDependencies,
1098    UnusedEnumMembers,
1099    UnusedClassMembers,
1100    UnusedStoreMembers,
1101    UnresolvedImports,
1102    UnlistedDependencies,
1103    DuplicateExports,
1104    TypeOnlyDependencies,
1105    TestOnlyDependencies,
1106    DevDependenciesInProduction,
1107    CircularDependencies,
1108    ReExportCycles,
1109    BoundaryViolations,
1110    BoundaryCoverageViolations,
1111    BoundaryCallViolations,
1112    PolicyViolations,
1113    StaleSuppressions,
1114    UnusedCatalogEntries,
1115    EmptyCatalogGroups,
1116    UnresolvedCatalogReferences,
1117    UnusedDependencyOverrides,
1118    MisconfiguredDependencyOverrides,
1119    InvalidClientExports,
1120    MixedClientServerBarrels,
1121    MisplacedDirectives,
1122    UnprovidedInjects,
1123    UnrenderedComponents,
1124    RouteCollisions,
1125    DynamicSegmentNameConflicts,
1126    UnusedComponentProps,
1127    UnusedComponentEmits,
1128    UnusedComponentInputs,
1129    UnusedComponentOutputs,
1130    UnusedSvelteEvents,
1131    UnusedServerActions,
1132    UnusedLoadDataKeys,
1133}
1134
1135impl AuditCollection {
1136    const fn json_key(self) -> &'static str {
1137        match self {
1138            Self::UnusedFiles => "unused_files",
1139            Self::UnusedExports => "unused_exports",
1140            Self::UnusedTypes => "unused_types",
1141            Self::PrivateTypeLeaks => "private_type_leaks",
1142            Self::UnusedDependencies => "unused_dependencies",
1143            Self::UnusedDevDependencies => "unused_dev_dependencies",
1144            Self::UnusedOptionalDependencies => "unused_optional_dependencies",
1145            Self::UnusedEnumMembers => "unused_enum_members",
1146            Self::UnusedClassMembers => "unused_class_members",
1147            Self::UnusedStoreMembers => "unused_store_members",
1148            Self::UnresolvedImports => "unresolved_imports",
1149            Self::UnlistedDependencies => "unlisted_dependencies",
1150            Self::DuplicateExports => "duplicate_exports",
1151            Self::TypeOnlyDependencies => "type_only_dependencies",
1152            Self::TestOnlyDependencies => "test_only_dependencies",
1153            Self::DevDependenciesInProduction => "dev_dependencies_in_production",
1154            Self::CircularDependencies => "circular_dependencies",
1155            Self::ReExportCycles => "re_export_cycles",
1156            Self::BoundaryViolations => "boundary_violations",
1157            Self::BoundaryCoverageViolations => "boundary_coverage_violations",
1158            Self::BoundaryCallViolations => "boundary_call_violations",
1159            Self::PolicyViolations => "policy_violations",
1160            Self::StaleSuppressions => "stale_suppressions",
1161            Self::UnusedCatalogEntries => "unused_catalog_entries",
1162            Self::EmptyCatalogGroups => "empty_catalog_groups",
1163            Self::UnresolvedCatalogReferences => "unresolved_catalog_references",
1164            Self::UnusedDependencyOverrides => "unused_dependency_overrides",
1165            Self::MisconfiguredDependencyOverrides => "misconfigured_dependency_overrides",
1166            Self::InvalidClientExports => "invalid_client_exports",
1167            Self::MixedClientServerBarrels => "mixed_client_server_barrels",
1168            Self::MisplacedDirectives => "misplaced_directives",
1169            Self::UnprovidedInjects => "unprovided_injects",
1170            Self::UnrenderedComponents => "unrendered_components",
1171            Self::RouteCollisions => "route_collisions",
1172            Self::DynamicSegmentNameConflicts => "dynamic_segment_name_conflicts",
1173            Self::UnusedComponentProps => "unused_component_props",
1174            Self::UnusedComponentEmits => "unused_component_emits",
1175            Self::UnusedComponentInputs => "unused_component_inputs",
1176            Self::UnusedComponentOutputs => "unused_component_outputs",
1177            Self::UnusedSvelteEvents => "unused_svelte_events",
1178            Self::UnusedServerActions => "unused_server_actions",
1179            Self::UnusedLoadDataKeys => "unused_load_data_keys",
1180        }
1181    }
1182}
1183
1184struct DeadCodeKeyCollector<'a> {
1185    root: &'a Path,
1186    keys: FxHashSet<String>,
1187    introduced_keys: FxHashSet<String>,
1188    inherited_keys: FxHashSet<String>,
1189    records: Vec<AuditFindingRecord>,
1190    collection_counts: FxHashMap<&'static str, usize>,
1191    config: Option<&'a ResolvedConfig>,
1192    base: Option<&'a FxHashSet<String>>,
1193    #[cfg(test)]
1194    classifications: usize,
1195}
1196
1197impl<'a> DeadCodeKeyCollector<'a> {
1198    fn new(root: &'a Path) -> Self {
1199        Self {
1200            root,
1201            keys: FxHashSet::default(),
1202            introduced_keys: FxHashSet::default(),
1203            inherited_keys: FxHashSet::default(),
1204            records: Vec::new(),
1205            collection_counts: FxHashMap::default(),
1206            config: None,
1207            base: None,
1208            #[cfg(test)]
1209            classifications: 0,
1210        }
1211    }
1212
1213    fn for_comparison(
1214        root: &'a Path,
1215        config: &'a ResolvedConfig,
1216        base: Option<&'a FxHashSet<String>>,
1217    ) -> Self {
1218        Self {
1219            root,
1220            keys: FxHashSet::default(),
1221            introduced_keys: FxHashSet::default(),
1222            inherited_keys: FxHashSet::default(),
1223            records: Vec::new(),
1224            collection_counts: FxHashMap::default(),
1225            config: Some(config),
1226            base,
1227            #[cfg(test)]
1228            classifications: 0,
1229        }
1230    }
1231
1232    fn into_keys(self) -> FxHashSet<String> {
1233        self.keys
1234    }
1235
1236    fn into_ledger(self) -> DeadCodeAuditLedger {
1237        DeadCodeAuditLedger {
1238            records: self.records,
1239            keys: self.keys,
1240            introduced_keys: self.introduced_keys,
1241            inherited_keys: self.inherited_keys,
1242            #[cfg(test)]
1243            classifications: self.classifications,
1244        }
1245    }
1246
1247    fn insert(&mut self, collection: AuditCollection, key: String, effective_severity: Severity) {
1248        let collection = collection.json_key();
1249        let ordinal = self.collection_counts.entry(collection).or_default();
1250        if self.config.is_some() {
1251            #[cfg(test)]
1252            {
1253                self.classifications += 1;
1254            }
1255            let introduced = self.base.is_some_and(|base| !base.contains(&key));
1256            if effective_severity != Severity::Off && self.base.is_some() {
1257                if introduced {
1258                    self.introduced_keys.insert(key.clone());
1259                } else {
1260                    self.inherited_keys.insert(key.clone());
1261                }
1262            }
1263            self.records.push(AuditFindingRecord {
1264                collection,
1265                ordinal: *ordinal,
1266                introduced,
1267                effective_severity,
1268                stable_key: key.clone(),
1269            });
1270        }
1271        *ordinal += 1;
1272        self.keys.insert(key);
1273    }
1274
1275    fn insert_file(
1276        &mut self,
1277        collection: AuditCollection,
1278        key: String,
1279        path: &Path,
1280        severity: fn(&fallow_config::RulesConfig) -> Severity,
1281    ) {
1282        let effective = self.config.map_or(Severity::Off, |config| {
1283            severity(&config.resolve_rules_for_path(path))
1284        });
1285        self.insert(collection, key, effective);
1286    }
1287
1288    fn insert_project(
1289        &mut self,
1290        collection: AuditCollection,
1291        key: String,
1292        severity: fn(&fallow_config::RulesConfig) -> Severity,
1293    ) {
1294        let effective = self
1295            .config
1296            .map_or(Severity::Off, |config| severity(&config.rules));
1297        self.insert(collection, key, effective);
1298    }
1299
1300    fn add_core_findings(
1301        &mut self,
1302        unused_files: &[fallow_types::output_dead_code::UnusedFileFinding],
1303        unused_exports: &[fallow_types::output_dead_code::UnusedExportFinding],
1304        unused_types: &[fallow_types::output_dead_code::UnusedTypeFinding],
1305        private_type_leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
1306    ) {
1307        self.add_unused_files(unused_files);
1308        self.add_unused_exports(unused_exports);
1309        self.add_unused_types(unused_types);
1310        self.add_private_type_leaks(private_type_leaks);
1311    }
1312
1313    fn add_client_directive_findings(
1314        &mut self,
1315        invalid_client_exports: &[fallow_types::output_dead_code::InvalidClientExportFinding],
1316        mixed_client_server_barrels: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
1317        misplaced_directives: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1318    ) {
1319        self.add_invalid_client_exports(invalid_client_exports);
1320        self.add_mixed_client_server_barrels(mixed_client_server_barrels);
1321        self.add_misplaced_directives(misplaced_directives);
1322    }
1323
1324    fn add_dependency_findings(&mut self, deps: &DependencyFindingSlices<'_>) {
1325        let DependencyFindingSlices {
1326            unused_dependencies,
1327            unused_dev_dependencies,
1328            unused_optional_dependencies,
1329            unlisted_dependencies,
1330            type_only_dependencies,
1331            test_only_dependencies,
1332            dev_dependencies_in_production,
1333        } = *deps;
1334        self.add_unused_dependencies(unused_dependencies);
1335        self.add_unused_dev_dependencies(unused_dev_dependencies);
1336        self.add_unused_optional_dependencies(unused_optional_dependencies);
1337        self.add_unlisted_dependencies(unlisted_dependencies);
1338        self.add_type_only_dependencies(type_only_dependencies);
1339        self.add_test_only_dependencies(test_only_dependencies);
1340        self.add_dev_dependencies_in_production(dev_dependencies_in_production);
1341    }
1342
1343    fn add_dependency_override_findings(
1344        &mut self,
1345        unused_dependency_overrides: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1346        misconfigured_dependency_overrides: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1347    ) {
1348        self.add_unused_dependency_overrides(unused_dependency_overrides);
1349        self.add_misconfigured_dependency_overrides(misconfigured_dependency_overrides);
1350    }
1351
1352    fn add_member_findings(
1353        &mut self,
1354        unused_enum_members: &[fallow_types::output_dead_code::UnusedEnumMemberFinding],
1355        unused_class_members: &[fallow_types::output_dead_code::UnusedClassMemberFinding],
1356        unused_store_members: &[fallow_types::output_dead_code::UnusedStoreMemberFinding],
1357    ) {
1358        self.add_unused_enum_members(unused_enum_members);
1359        self.add_unused_class_members(unused_class_members);
1360        self.add_unused_store_members(unused_store_members);
1361    }
1362
1363    fn add_component_contract_findings(
1364        &mut self,
1365        unused_component_props: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1366        unused_component_emits: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1367        unused_component_inputs: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1368        unused_component_outputs: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1369        unused_svelte_events: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1370    ) {
1371        self.add_unused_component_props(unused_component_props);
1372        self.add_unused_component_emits(unused_component_emits);
1373        self.add_unused_component_inputs(unused_component_inputs);
1374        self.add_unused_component_outputs(unused_component_outputs);
1375        self.add_unused_svelte_events(unused_svelte_events);
1376    }
1377
1378    fn add_graph_findings(
1379        &mut self,
1380        unresolved_imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
1381        duplicate_exports: &[fallow_types::output_dead_code::DuplicateExportFinding],
1382        circular_dependencies: &[fallow_types::output_dead_code::CircularDependencyFinding],
1383        re_export_cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
1384    ) {
1385        self.add_unresolved_imports(unresolved_imports);
1386        self.add_duplicate_exports(duplicate_exports);
1387        self.add_circular_dependencies(circular_dependencies);
1388        self.add_re_export_cycles(re_export_cycles);
1389    }
1390
1391    fn add_boundary_findings(
1392        &mut self,
1393        boundary_violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
1394        boundary_coverage_violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
1395        boundary_call_violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
1396        policy_violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
1397        stale_suppressions: &[fallow_types::results::StaleSuppression],
1398    ) {
1399        self.add_boundary_violations(boundary_violations);
1400        self.add_boundary_coverage_violations(boundary_coverage_violations);
1401        self.add_boundary_call_violations(boundary_call_violations);
1402        self.add_policy_violations(policy_violations);
1403        self.add_stale_suppressions(stale_suppressions);
1404    }
1405
1406    fn add_catalog_findings(
1407        &mut self,
1408        unresolved_catalog_references: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1409        unused_catalog_entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1410        empty_catalog_groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1411    ) {
1412        self.add_unresolved_catalog_references(unresolved_catalog_references);
1413        self.add_unused_catalog_entries(unused_catalog_entries);
1414        self.add_empty_catalog_groups(empty_catalog_groups);
1415    }
1416
1417    fn add_framework_findings(&mut self, framework: &FrameworkFindingSlices<'_>) {
1418        let FrameworkFindingSlices {
1419            unprovided_injects,
1420            unrendered_components,
1421            unused_server_actions,
1422            unused_load_data_keys,
1423            route_collisions,
1424            dynamic_segment_name_conflicts,
1425        } = *framework;
1426        self.add_unprovided_injects(unprovided_injects);
1427        self.add_unrendered_components(unrendered_components);
1428        self.add_unused_server_actions(unused_server_actions);
1429        self.add_unused_load_data_keys(unused_load_data_keys);
1430        self.add_route_collisions(route_collisions);
1431        self.add_dynamic_segment_name_conflicts(dynamic_segment_name_conflicts);
1432    }
1433
1434    fn add_unused_files(&mut self, items: &[fallow_types::output_dead_code::UnusedFileFinding]) {
1435        for item in items {
1436            self.insert_file(
1437                AuditCollection::UnusedFiles,
1438                format!(
1439                    "unused-file:{}",
1440                    relative_key_path(&item.file.path, self.root)
1441                ),
1442                &item.file.path,
1443                |rules| rules.unused_files,
1444            );
1445        }
1446    }
1447
1448    fn add_unused_exports(
1449        &mut self,
1450        items: &[fallow_types::output_dead_code::UnusedExportFinding],
1451    ) {
1452        for item in items {
1453            self.insert_file(
1454                AuditCollection::UnusedExports,
1455                format!(
1456                    "unused-export:{}:{}",
1457                    relative_key_path(&item.export.path, self.root),
1458                    item.export.export_name
1459                ),
1460                &item.export.path,
1461                |rules| rules.unused_exports,
1462            );
1463        }
1464    }
1465
1466    fn add_unused_types(&mut self, items: &[fallow_types::output_dead_code::UnusedTypeFinding]) {
1467        for item in items {
1468            self.insert_file(
1469                AuditCollection::UnusedTypes,
1470                format!(
1471                    "unused-type:{}:{}",
1472                    relative_key_path(&item.export.path, self.root),
1473                    item.export.export_name
1474                ),
1475                &item.export.path,
1476                |rules| rules.unused_types,
1477            );
1478        }
1479    }
1480
1481    fn add_private_type_leaks(
1482        &mut self,
1483        items: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
1484    ) {
1485        for item in items {
1486            self.insert_file(
1487                AuditCollection::PrivateTypeLeaks,
1488                format!(
1489                    "private-type-leak:{}:{}:{}",
1490                    relative_key_path(&item.leak.path, self.root),
1491                    item.leak.export_name,
1492                    item.leak.type_name
1493                ),
1494                &item.leak.path,
1495                |rules| rules.private_type_leaks,
1496            );
1497        }
1498    }
1499
1500    fn add_invalid_client_exports(
1501        &mut self,
1502        items: &[fallow_types::output_dead_code::InvalidClientExportFinding],
1503    ) {
1504        for item in items {
1505            self.insert_file(
1506                AuditCollection::InvalidClientExports,
1507                invalid_client_export_key(&item.export, self.root),
1508                &item.export.path,
1509                |rules| rules.invalid_client_export,
1510            );
1511        }
1512    }
1513
1514    fn add_mixed_client_server_barrels(
1515        &mut self,
1516        items: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
1517    ) {
1518        for item in items {
1519            self.insert_file(
1520                AuditCollection::MixedClientServerBarrels,
1521                mixed_client_server_barrel_key(&item.barrel, self.root),
1522                &item.barrel.path,
1523                |rules| rules.mixed_client_server_barrel,
1524            );
1525        }
1526    }
1527
1528    fn add_misplaced_directives(
1529        &mut self,
1530        items: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1531    ) {
1532        for item in items {
1533            self.insert_file(
1534                AuditCollection::MisplacedDirectives,
1535                misplaced_directive_key(&item.directive_site, self.root),
1536                &item.directive_site.path,
1537                |rules| rules.misplaced_directive,
1538            );
1539        }
1540    }
1541
1542    fn add_unprovided_injects(
1543        &mut self,
1544        items: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
1545    ) {
1546        for item in items {
1547            self.insert_file(
1548                AuditCollection::UnprovidedInjects,
1549                unprovided_inject_key(&item.inject, self.root),
1550                &item.inject.path,
1551                |rules| rules.unprovided_injects,
1552            );
1553        }
1554    }
1555
1556    fn add_unrendered_components(
1557        &mut self,
1558        items: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1559    ) {
1560        for item in items {
1561            self.insert_file(
1562                AuditCollection::UnrenderedComponents,
1563                unrendered_component_key(&item.component, self.root),
1564                &item.component.path,
1565                |rules| rules.unrendered_components,
1566            );
1567        }
1568    }
1569
1570    fn add_unused_component_props(
1571        &mut self,
1572        items: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1573    ) {
1574        for item in items {
1575            self.insert_file(
1576                AuditCollection::UnusedComponentProps,
1577                unused_component_prop_key(&item.prop, self.root),
1578                &item.prop.path,
1579                |rules| rules.unused_component_props,
1580            );
1581        }
1582    }
1583
1584    fn add_unused_component_emits(
1585        &mut self,
1586        items: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1587    ) {
1588        for item in items {
1589            self.insert_file(
1590                AuditCollection::UnusedComponentEmits,
1591                unused_component_emit_key(&item.emit, self.root),
1592                &item.emit.path,
1593                |rules| rules.unused_component_emits,
1594            );
1595        }
1596    }
1597
1598    fn add_unused_component_inputs(
1599        &mut self,
1600        items: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1601    ) {
1602        for item in items {
1603            self.insert_file(
1604                AuditCollection::UnusedComponentInputs,
1605                unused_component_input_key(&item.input, self.root),
1606                &item.input.path,
1607                |rules| rules.unused_component_inputs,
1608            );
1609        }
1610    }
1611
1612    fn add_unused_component_outputs(
1613        &mut self,
1614        items: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1615    ) {
1616        for item in items {
1617            self.insert_file(
1618                AuditCollection::UnusedComponentOutputs,
1619                unused_component_output_key(&item.output, self.root),
1620                &item.output.path,
1621                |rules| rules.unused_component_outputs,
1622            );
1623        }
1624    }
1625
1626    fn add_unused_svelte_events(
1627        &mut self,
1628        items: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1629    ) {
1630        for item in items {
1631            self.insert_file(
1632                AuditCollection::UnusedSvelteEvents,
1633                unused_svelte_event_key(&item.event, self.root),
1634                &item.event.path,
1635                |rules| rules.unused_svelte_events,
1636            );
1637        }
1638    }
1639
1640    fn add_unused_server_actions(
1641        &mut self,
1642        items: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1643    ) {
1644        for item in items {
1645            self.insert_file(
1646                AuditCollection::UnusedServerActions,
1647                unused_server_action_key(&item.action, self.root),
1648                &item.action.path,
1649                |rules| rules.unused_server_actions,
1650            );
1651        }
1652    }
1653
1654    fn add_unused_load_data_keys(
1655        &mut self,
1656        items: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1657    ) {
1658        for item in items {
1659            self.insert_file(
1660                AuditCollection::UnusedLoadDataKeys,
1661                unused_load_data_key_key(&item.key, self.root),
1662                &item.key.path,
1663                |rules| rules.unused_load_data_keys,
1664            );
1665        }
1666    }
1667
1668    fn add_route_collisions(
1669        &mut self,
1670        items: &[fallow_types::output_dead_code::RouteCollisionFinding],
1671    ) {
1672        for item in items {
1673            self.insert_file(
1674                AuditCollection::RouteCollisions,
1675                route_collision_key(&item.collision, self.root),
1676                &item.collision.path,
1677                |rules| rules.route_collision,
1678            );
1679        }
1680    }
1681
1682    fn add_dynamic_segment_name_conflicts(
1683        &mut self,
1684        items: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1685    ) {
1686        for item in items {
1687            self.insert_file(
1688                AuditCollection::DynamicSegmentNameConflicts,
1689                dynamic_segment_name_conflict_key(&item.conflict, self.root),
1690                &item.conflict.path,
1691                |rules| rules.dynamic_segment_name_conflict,
1692            );
1693        }
1694    }
1695
1696    fn add_unused_dependencies(
1697        &mut self,
1698        items: &[fallow_types::output_dead_code::UnusedDependencyFinding],
1699    ) {
1700        for item in items {
1701            self.insert_project(
1702                AuditCollection::UnusedDependencies,
1703                unused_dependency_key(&item.dep, self.root),
1704                |rules| rules.unused_dependencies,
1705            );
1706        }
1707    }
1708
1709    fn add_unused_dev_dependencies(
1710        &mut self,
1711        items: &[fallow_types::output_dead_code::UnusedDevDependencyFinding],
1712    ) {
1713        for item in items {
1714            self.insert_project(
1715                AuditCollection::UnusedDevDependencies,
1716                unused_dependency_key(&item.dep, self.root),
1717                |rules| rules.unused_dev_dependencies,
1718            );
1719        }
1720    }
1721
1722    fn add_unused_optional_dependencies(
1723        &mut self,
1724        items: &[fallow_types::output_dead_code::UnusedOptionalDependencyFinding],
1725    ) {
1726        for item in items {
1727            self.insert_project(
1728                AuditCollection::UnusedOptionalDependencies,
1729                unused_dependency_key(&item.dep, self.root),
1730                |rules| rules.unused_optional_dependencies,
1731            );
1732        }
1733    }
1734
1735    fn add_unused_enum_members(
1736        &mut self,
1737        items: &[fallow_types::output_dead_code::UnusedEnumMemberFinding],
1738    ) {
1739        for item in items {
1740            self.insert_file(
1741                AuditCollection::UnusedEnumMembers,
1742                unused_member_key("unused-enum-member", &item.member, self.root),
1743                &item.member.path,
1744                |rules| rules.unused_enum_members,
1745            );
1746        }
1747    }
1748
1749    fn add_unused_class_members(
1750        &mut self,
1751        items: &[fallow_types::output_dead_code::UnusedClassMemberFinding],
1752    ) {
1753        for item in items {
1754            self.insert_file(
1755                AuditCollection::UnusedClassMembers,
1756                unused_member_key("unused-class-member", &item.member, self.root),
1757                &item.member.path,
1758                |rules| rules.unused_class_members,
1759            );
1760        }
1761    }
1762
1763    fn add_unused_store_members(
1764        &mut self,
1765        items: &[fallow_types::output_dead_code::UnusedStoreMemberFinding],
1766    ) {
1767        for item in items {
1768            self.insert_file(
1769                AuditCollection::UnusedStoreMembers,
1770                unused_member_key("unused-store-member", &item.member, self.root),
1771                &item.member.path,
1772                |rules| rules.unused_store_members,
1773            );
1774        }
1775    }
1776
1777    fn add_unresolved_imports(
1778        &mut self,
1779        items: &[fallow_types::output_dead_code::UnresolvedImportFinding],
1780    ) {
1781        for item in items {
1782            self.insert_file(
1783                AuditCollection::UnresolvedImports,
1784                format!(
1785                    "unresolved-import:{}:{}",
1786                    relative_key_path(&item.import.path, self.root),
1787                    item.import.specifier
1788                ),
1789                &item.import.path,
1790                |rules| rules.unresolved_imports,
1791            );
1792        }
1793    }
1794
1795    fn add_unlisted_dependencies(
1796        &mut self,
1797        items: &[fallow_types::output_dead_code::UnlistedDependencyFinding],
1798    ) {
1799        for item in items {
1800            self.insert_project(
1801                AuditCollection::UnlistedDependencies,
1802                unlisted_dependency_key(&item.dep, self.root),
1803                |rules| rules.unlisted_dependencies,
1804            );
1805        }
1806    }
1807
1808    fn add_duplicate_exports(
1809        &mut self,
1810        items: &[fallow_types::output_dead_code::DuplicateExportFinding],
1811    ) {
1812        for item in items {
1813            self.insert_project(
1814                AuditCollection::DuplicateExports,
1815                duplicate_export_key(item, self.root),
1816                |rules| rules.duplicate_exports,
1817            );
1818        }
1819    }
1820
1821    fn add_type_only_dependencies(
1822        &mut self,
1823        items: &[fallow_types::output_dead_code::TypeOnlyDependencyFinding],
1824    ) {
1825        for item in items {
1826            self.insert_project(
1827                AuditCollection::TypeOnlyDependencies,
1828                format!(
1829                    "type-only-dependency:{}:{}",
1830                    relative_key_path(&item.dep.path, self.root),
1831                    item.dep.package_name
1832                ),
1833                |rules| rules.type_only_dependencies,
1834            );
1835        }
1836    }
1837
1838    fn add_test_only_dependencies(
1839        &mut self,
1840        items: &[fallow_types::output_dead_code::TestOnlyDependencyFinding],
1841    ) {
1842        for item in items {
1843            self.insert_project(
1844                AuditCollection::TestOnlyDependencies,
1845                format!(
1846                    "test-only-dependency:{}:{}",
1847                    relative_key_path(&item.dep.path, self.root),
1848                    item.dep.package_name
1849                ),
1850                |rules| rules.test_only_dependencies,
1851            );
1852        }
1853    }
1854
1855    fn add_dev_dependencies_in_production(
1856        &mut self,
1857        items: &[fallow_types::output_dead_code::DevDependencyInProductionFinding],
1858    ) {
1859        for item in items {
1860            self.insert_project(
1861                AuditCollection::DevDependenciesInProduction,
1862                format!(
1863                    "dev-dependency-in-production:{}:{}",
1864                    relative_key_path(&item.dep.path, self.root),
1865                    item.dep.package_name
1866                ),
1867                |rules| rules.dev_dependencies_in_production,
1868            );
1869        }
1870    }
1871
1872    fn add_circular_dependencies(
1873        &mut self,
1874        items: &[fallow_types::output_dead_code::CircularDependencyFinding],
1875    ) {
1876        for item in items {
1877            let severity = self.config.map_or(Severity::Off, |config| {
1878                item.cycle
1879                    .files
1880                    .iter()
1881                    .fold(Severity::Off, |current, path| {
1882                        merge_severity(
1883                            current,
1884                            config.resolve_rules_for_path(path).circular_dependencies,
1885                        )
1886                    })
1887            });
1888            self.insert(
1889                AuditCollection::CircularDependencies,
1890                circular_dependency_key(item, self.root),
1891                severity,
1892            );
1893        }
1894    }
1895
1896    fn add_re_export_cycles(
1897        &mut self,
1898        items: &[fallow_types::output_dead_code::ReExportCycleFinding],
1899    ) {
1900        for item in items {
1901            self.insert_project(
1902                AuditCollection::ReExportCycles,
1903                re_export_cycle_key(item, self.root),
1904                |rules| rules.re_export_cycle,
1905            );
1906        }
1907    }
1908
1909    fn add_boundary_violations(
1910        &mut self,
1911        items: &[fallow_types::output_dead_code::BoundaryViolationFinding],
1912    ) {
1913        for item in items {
1914            self.insert_file(
1915                AuditCollection::BoundaryViolations,
1916                boundary_violation_key(item, self.root),
1917                &item.violation.from_path,
1918                |rules| rules.boundary_violation,
1919            );
1920        }
1921    }
1922
1923    fn add_boundary_coverage_violations(
1924        &mut self,
1925        items: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
1926    ) {
1927        for item in items {
1928            self.insert_file(
1929                AuditCollection::BoundaryCoverageViolations,
1930                boundary_coverage_key(item, self.root),
1931                &item.violation.path,
1932                |rules| rules.boundary_violation,
1933            );
1934        }
1935    }
1936
1937    fn add_boundary_call_violations(
1938        &mut self,
1939        items: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
1940    ) {
1941        for item in items {
1942            self.insert_file(
1943                AuditCollection::BoundaryCallViolations,
1944                boundary_call_key(item, self.root),
1945                &item.violation.path,
1946                |rules| rules.boundary_violation,
1947            );
1948        }
1949    }
1950
1951    fn add_policy_violations(
1952        &mut self,
1953        items: &[fallow_types::output_dead_code::PolicyViolationFinding],
1954    ) {
1955        for item in items {
1956            let severity = match item.violation.severity {
1957                fallow_types::results::PolicyViolationSeverity::Error => Severity::Error,
1958                fallow_types::results::PolicyViolationSeverity::Warn => Severity::Warn,
1959            };
1960            self.insert(
1961                AuditCollection::PolicyViolations,
1962                policy_violation_key(item, self.root),
1963                severity,
1964            );
1965        }
1966    }
1967
1968    fn add_stale_suppressions(&mut self, items: &[fallow_types::results::StaleSuppression]) {
1969        for item in items {
1970            let effective = self.config.map_or(Severity::Off, |config| {
1971                let rules = config.resolve_rules_for_path(&item.path);
1972                if item.missing_reason {
1973                    rules.require_suppression_reason
1974                } else {
1975                    rules.stale_suppressions
1976                }
1977            });
1978            self.insert(
1979                AuditCollection::StaleSuppressions,
1980                stale_suppression_key(item, self.root),
1981                effective,
1982            );
1983        }
1984    }
1985
1986    fn add_unresolved_catalog_references(
1987        &mut self,
1988        items: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1989    ) {
1990        for item in items {
1991            self.insert_file(
1992                AuditCollection::UnresolvedCatalogReferences,
1993                unresolved_catalog_reference_key(item, self.root),
1994                &item.reference.path,
1995                |rules| rules.unresolved_catalog_references,
1996            );
1997        }
1998    }
1999
2000    fn add_unused_catalog_entries(
2001        &mut self,
2002        items: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
2003    ) {
2004        for item in items {
2005            self.insert_project(
2006                AuditCollection::UnusedCatalogEntries,
2007                unused_catalog_entry_key(&item.entry, self.root),
2008                |rules| rules.unused_catalog_entries,
2009            );
2010        }
2011    }
2012
2013    fn add_empty_catalog_groups(
2014        &mut self,
2015        items: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
2016    ) {
2017        for item in items {
2018            self.insert_file(
2019                AuditCollection::EmptyCatalogGroups,
2020                empty_catalog_group_key(&item.group, self.root),
2021                &item.group.path,
2022                |rules| rules.empty_catalog_groups,
2023            );
2024        }
2025    }
2026
2027    fn add_unused_dependency_overrides(
2028        &mut self,
2029        items: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
2030    ) {
2031        for item in items {
2032            self.insert_file(
2033                AuditCollection::UnusedDependencyOverrides,
2034                unused_dependency_override_key(item, self.root),
2035                &item.entry.path,
2036                |rules| rules.unused_dependency_overrides,
2037            );
2038        }
2039    }
2040
2041    fn add_misconfigured_dependency_overrides(
2042        &mut self,
2043        items: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
2044    ) {
2045        for item in items {
2046            self.insert_file(
2047                AuditCollection::MisconfiguredDependencyOverrides,
2048                misconfigured_dependency_override_key(item, self.root),
2049                &item.entry.path,
2050                |rules| rules.misconfigured_dependency_overrides,
2051            );
2052        }
2053    }
2054}
2055
2056const fn merge_severity(left: Severity, right: Severity) -> Severity {
2057    match (left, right) {
2058        (Severity::Error, _) | (_, Severity::Error) => Severity::Error,
2059        (Severity::Warn, _) | (_, Severity::Warn) => Severity::Warn,
2060        (Severity::Off, Severity::Off) => Severity::Off,
2061    }
2062}
2063
2064/// Retain only findings whose audit key was NOT present in `base` (i.e. was
2065/// introduced on the current branch).
2066///
2067/// When `base` is `None` (no baseline), all findings are kept.
2068///
2069/// This destructure is deliberately exhaustive: adding a field to
2070/// `AnalysisResults` must fail compilation here so the author decides
2071/// explicitly whether the new finding type needs an introduced-retain (add a
2072/// retain block) or has no key representation today (bind with underscore and
2073/// document why).
2074///
2075/// Sibling exhaustive sites: `fallow_engine::changed_files::filter_results_by_changed_files`,
2076/// `dead_code_keys`, `retain_introduced_dead_code`.
2077/// Non-exhaustive siblings the compiler will NOT flag (wire manually when a
2078/// finding type is added): `annotate_dead_code_json` (same key formats, this
2079/// file) and the per-collection severity branches in
2080/// `crates/cli/src/check/rules.rs` (`apply_rules`, `has_error_severity_issues`).
2081/// TypeScript mirror: `editors/vscode/scripts/codegen-contracts.mjs` derives
2082/// backwards-compatible aliases from `fallow schema` `ts_alias` rows.
2083#[expect(
2084    clippy::implicit_hasher,
2085    reason = "fallow standardizes on FxHashSet across audit attribution keys"
2086)]
2087pub fn retain_introduced_dead_code(
2088    results: &mut fallow_types::results::AnalysisResults,
2089    root: &Path,
2090    base: Option<&FxHashSet<String>>,
2091) {
2092    let Some(base) = base else {
2093        return;
2094    };
2095
2096    // Compute the introduced set before taking any mutable borrows. Note the
2097    // order differs from the pre-destructure code, which narrowed
2098    // unused_files/exports/types first and computed keys from the narrowed
2099    // results. Computing from the un-narrowed results is equivalent: those
2100    // retains keep exactly the items whose key is NOT in `base`, and the
2101    // `!base.contains(key)` filter below removes the same base-member keys
2102    // from the full key set, so `introduced` is identical either way.
2103    let introduced = introduced_dead_code_keys(results, root, base);
2104    classify_introduced_dead_code_fields(results);
2105
2106    // The three "fast path" retains use a direct base-lookup rather than the
2107    // introduced set; both predicates are equivalent for these collections
2108    // (see the `introduced` comment above), so this preserves the original
2109    // behavior.
2110    retain_introduced_fast_paths(
2111        &mut results.unused_files,
2112        &mut results.unused_exports,
2113        &mut results.unused_types,
2114        root,
2115        base,
2116    );
2117    retain_introduced_core_findings(results, root, &introduced);
2118    retain_introduced_dependency_and_graph_findings(results, root, &introduced);
2119    retain_introduced_workspace_findings(results, root, &introduced);
2120    retain_introduced_framework_findings(results, root, &introduced);
2121}
2122
2123fn introduced_dead_code_keys(
2124    results: &fallow_types::results::AnalysisResults,
2125    root: &Path,
2126    base: &FxHashSet<String>,
2127) -> FxHashSet<String> {
2128    dead_code_keys(results, root)
2129        .into_iter()
2130        .filter(|key| !base.contains(key))
2131        .collect()
2132}
2133
2134fn classify_introduced_dead_code_fields(results: &fallow_types::results::AnalysisResults) {
2135    let fallow_types::results::AnalysisResults {
2136        unused_files: _unused_files,
2137        unused_exports: _unused_exports,
2138        unused_types: _unused_types,
2139        private_type_leaks: _private_type_leaks,
2140        unused_dependencies: _unused_dependencies,
2141        unused_dev_dependencies: _unused_dev_dependencies,
2142        unused_optional_dependencies: _unused_optional_dependencies,
2143        unused_enum_members: _unused_enum_members,
2144        unused_class_members: _unused_class_members,
2145        unused_store_members: _unused_store_members,
2146        unresolved_imports: _unresolved_imports,
2147        unlisted_dependencies: _unlisted_dependencies,
2148        duplicate_exports: _duplicate_exports,
2149        type_only_dependencies: _type_only_dependencies,
2150        test_only_dependencies: _test_only_dependencies,
2151        dev_dependencies_in_production: _dev_dependencies_in_production,
2152        circular_dependencies: _circular_dependencies,
2153        re_export_cycles: _re_export_cycles,
2154        boundary_violations: _boundary_violations,
2155        boundary_coverage_violations: _boundary_coverage_violations,
2156        boundary_call_violations: _boundary_call_violations,
2157        policy_violations: _policy_violations,
2158        stale_suppressions: _stale_suppressions,
2159        unused_catalog_entries: _unused_catalog_entries,
2160        empty_catalog_groups: _empty_catalog_groups,
2161        unresolved_catalog_references: _unresolved_catalog_references,
2162        unused_dependency_overrides: _unused_dependency_overrides,
2163        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
2164        invalid_client_exports: _invalid_client_exports,
2165        mixed_client_server_barrels: _mixed_client_server_barrels,
2166        misplaced_directives: _misplaced_directives,
2167        unprovided_injects: _unprovided_injects,
2168        unrendered_components: _unrendered_components,
2169        unused_component_props: _unused_component_props,
2170        unused_component_emits: _unused_component_emits,
2171        unused_component_inputs: _unused_component_inputs,
2172        unused_component_outputs: _unused_component_outputs,
2173        unused_svelte_events: _unused_svelte_events,
2174        unused_server_actions: _unused_server_actions,
2175        unused_load_data_keys: _unused_load_data_keys,
2176        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
2177        route_collisions: _route_collisions,
2178        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
2179        // Non-finding fields: counts and metadata, not subject to base-keyed
2180        // filtering.
2181        suppression_count: _suppression_count,
2182        unused_component_props_exempted: _unused_component_props_exempted,
2183        active_suppressions: _active_suppressions,
2184        feature_flags: _feature_flags,
2185        // Security findings are emitted via `fallow security`, not the audit
2186        // dead-code gate; they have no key representation and are not filtered
2187        // here.
2188        security_findings: _security_findings,
2189        security_unresolved_edge_files: _security_unresolved_edge_files,
2190        security_unresolved_callee_sites: _security_unresolved_callee_sites,
2191        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
2192        // Prop-drilling is a dormant multi-file health signal (rule defaults to
2193        // `off`); it carries no dead-code key and is not base-filtered here.
2194        prop_drilling_chains: _prop_drilling_chains,
2195        // Thin wrappers are a dormant health signal (rule defaults to `off`);
2196        // no dead-code key and not base-filtered here.
2197        thin_wrappers: _thin_wrappers,
2198        // Duplicate prop shapes are a dormant multi-file health signal (rule
2199        // defaults to `off`); no dead-code key and not base-filtered here.
2200        duplicate_prop_shapes: _duplicate_prop_shapes,
2201        // Export usages and entry-point summary are metadata, not issue
2202        // collections; no key needed.
2203        export_usages: _export_usages,
2204        entry_point_summary: _entry_point_summary,
2205        // Render fan-in is a whole-project descriptive metric, not an issue
2206        // collection; no key needed.
2207        render_fan_in: _render_fan_in,
2208        // Per-component React intel is a descriptive ambient-editor carrier, not
2209        // an issue collection; no key needed.
2210        react_component_intel: _react_component_intel,
2211        semantic_framework_contracts: _semantic_framework_contracts,
2212    } = results;
2213}
2214
2215fn retain_introduced_fast_paths(
2216    unused_files: &mut Vec<fallow_types::output_dead_code::UnusedFileFinding>,
2217    unused_exports: &mut Vec<fallow_types::output_dead_code::UnusedExportFinding>,
2218    unused_types: &mut Vec<fallow_types::output_dead_code::UnusedTypeFinding>,
2219    root: &Path,
2220    base: &FxHashSet<String>,
2221) {
2222    unused_files.retain(|item| {
2223        !base.contains(&format!(
2224            "unused-file:{}",
2225            relative_key_path(&item.file.path, root)
2226        ))
2227    });
2228    unused_exports.retain(|item| {
2229        !base.contains(&format!(
2230            "unused-export:{}:{}",
2231            relative_key_path(&item.export.path, root),
2232            item.export.export_name
2233        ))
2234    });
2235    unused_types.retain(|item| {
2236        !base.contains(&format!(
2237            "unused-type:{}:{}",
2238            relative_key_path(&item.export.path, root),
2239            item.export.export_name
2240        ))
2241    });
2242}
2243
2244fn keep_introduced(introduced: &FxHashSet<String>, key: impl AsRef<str>) -> bool {
2245    introduced.contains(key.as_ref())
2246}
2247
2248fn retain_introduced_core_findings(
2249    results: &mut fallow_types::results::AnalysisResults,
2250    root: &Path,
2251    introduced: &FxHashSet<String>,
2252) {
2253    results.private_type_leaks.retain(|item| {
2254        keep_introduced(
2255            introduced,
2256            format!(
2257                "private-type-leak:{}:{}:{}",
2258                relative_key_path(&item.leak.path, root),
2259                item.leak.export_name,
2260                item.leak.type_name
2261            ),
2262        )
2263    });
2264    results.unused_enum_members.retain(|item| {
2265        keep_introduced(
2266            introduced,
2267            unused_member_key("unused-enum-member", &item.member, root),
2268        )
2269    });
2270    results.unused_class_members.retain(|item| {
2271        keep_introduced(
2272            introduced,
2273            unused_member_key("unused-class-member", &item.member, root),
2274        )
2275    });
2276    results.unused_store_members.retain(|item| {
2277        keep_introduced(
2278            introduced,
2279            unused_member_key("unused-store-member", &item.member, root),
2280        )
2281    });
2282    results.unresolved_imports.retain(|item| {
2283        keep_introduced(
2284            introduced,
2285            format!(
2286                "unresolved-import:{}:{}",
2287                relative_key_path(&item.import.path, root),
2288                item.import.specifier
2289            ),
2290        )
2291    });
2292}
2293
2294fn retain_introduced_dependency_and_graph_findings(
2295    results: &mut fallow_types::results::AnalysisResults,
2296    root: &Path,
2297    introduced: &FxHashSet<String>,
2298) {
2299    results
2300        .unused_dependencies
2301        .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
2302    results
2303        .unused_dev_dependencies
2304        .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
2305    results
2306        .unused_optional_dependencies
2307        .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
2308    results
2309        .unlisted_dependencies
2310        .retain(|item| keep_introduced(introduced, unlisted_dependency_key(&item.dep, root)));
2311    results
2312        .duplicate_exports
2313        .retain(|item| keep_introduced(introduced, duplicate_export_key(item, root)));
2314    results.type_only_dependencies.retain(|item| {
2315        keep_introduced(
2316            introduced,
2317            format!(
2318                "type-only-dependency:{}:{}",
2319                relative_key_path(&item.dep.path, root),
2320                item.dep.package_name
2321            ),
2322        )
2323    });
2324    results.test_only_dependencies.retain(|item| {
2325        keep_introduced(
2326            introduced,
2327            format!(
2328                "test-only-dependency:{}:{}",
2329                relative_key_path(&item.dep.path, root),
2330                item.dep.package_name
2331            ),
2332        )
2333    });
2334    results
2335        .circular_dependencies
2336        .retain(|item| keep_introduced(introduced, circular_dependency_key(item, root)));
2337    results
2338        .re_export_cycles
2339        .retain(|item| keep_introduced(introduced, re_export_cycle_key(item, root)));
2340    results
2341        .boundary_violations
2342        .retain(|item| keep_introduced(introduced, boundary_violation_key(item, root)));
2343    results
2344        .boundary_coverage_violations
2345        .retain(|item| keep_introduced(introduced, boundary_coverage_key(item, root)));
2346    results
2347        .boundary_call_violations
2348        .retain(|item| keep_introduced(introduced, boundary_call_key(item, root)));
2349    results
2350        .policy_violations
2351        .retain(|item| keep_introduced(introduced, policy_violation_key(item, root)));
2352    results
2353        .stale_suppressions
2354        .retain(|item| keep_introduced(introduced, stale_suppression_key(item, root)));
2355}
2356
2357fn retain_introduced_workspace_findings(
2358    results: &mut fallow_types::results::AnalysisResults,
2359    root: &Path,
2360    introduced: &FxHashSet<String>,
2361) {
2362    results
2363        .unresolved_catalog_references
2364        .retain(|item| keep_introduced(introduced, unresolved_catalog_reference_key(item, root)));
2365    results
2366        .unused_catalog_entries
2367        .retain(|item| keep_introduced(introduced, unused_catalog_entry_key(&item.entry, root)));
2368    results
2369        .empty_catalog_groups
2370        .retain(|item| keep_introduced(introduced, empty_catalog_group_key(&item.group, root)));
2371    results
2372        .unused_dependency_overrides
2373        .retain(|item| keep_introduced(introduced, unused_dependency_override_key(item, root)));
2374    results.misconfigured_dependency_overrides.retain(|item| {
2375        keep_introduced(
2376            introduced,
2377            misconfigured_dependency_override_key(item, root),
2378        )
2379    });
2380}
2381
2382fn retain_introduced_framework_findings(
2383    results: &mut fallow_types::results::AnalysisResults,
2384    root: &Path,
2385    introduced: &FxHashSet<String>,
2386) {
2387    results
2388        .invalid_client_exports
2389        .retain(|item| keep_introduced(introduced, invalid_client_export_key(&item.export, root)));
2390    results.mixed_client_server_barrels.retain(|item| {
2391        keep_introduced(
2392            introduced,
2393            mixed_client_server_barrel_key(&item.barrel, root),
2394        )
2395    });
2396    results.misplaced_directives.retain(|item| {
2397        keep_introduced(
2398            introduced,
2399            misplaced_directive_key(&item.directive_site, root),
2400        )
2401    });
2402    results
2403        .unprovided_injects
2404        .retain(|item| keep_introduced(introduced, unprovided_inject_key(&item.inject, root)));
2405    results.unrendered_components.retain(|item| {
2406        keep_introduced(introduced, unrendered_component_key(&item.component, root))
2407    });
2408    results
2409        .unused_component_props
2410        .retain(|item| keep_introduced(introduced, unused_component_prop_key(&item.prop, root)));
2411    results
2412        .unused_component_emits
2413        .retain(|item| keep_introduced(introduced, unused_component_emit_key(&item.emit, root)));
2414    results
2415        .unused_component_inputs
2416        .retain(|item| keep_introduced(introduced, unused_component_input_key(&item.input, root)));
2417    results.unused_component_outputs.retain(|item| {
2418        keep_introduced(introduced, unused_component_output_key(&item.output, root))
2419    });
2420    results
2421        .unused_svelte_events
2422        .retain(|item| keep_introduced(introduced, unused_svelte_event_key(&item.event, root)));
2423    results
2424        .unused_server_actions
2425        .retain(|item| keep_introduced(introduced, unused_server_action_key(&item.action, root)));
2426    results
2427        .unused_load_data_keys
2428        .retain(|item| keep_introduced(introduced, unused_load_data_key_key(&item.key, root)));
2429    results
2430        .route_collisions
2431        .retain(|item| keep_introduced(introduced, route_collision_key(&item.collision, root)));
2432    results.dynamic_segment_name_conflicts.retain(|item| {
2433        keep_introduced(
2434            introduced,
2435            dynamic_segment_name_conflict_key(&item.conflict, root),
2436        )
2437    });
2438}
2439
2440fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
2441    !base.contains(key)
2442}
2443
2444fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
2445where
2446    I: IntoIterator<Item = bool>,
2447{
2448    let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
2449        return;
2450    };
2451    for (item, introduced) in items.iter_mut().zip(introduced) {
2452        if let serde_json::Value::Object(map) = item {
2453            map.insert("introduced".to_string(), serde_json::json!(introduced));
2454        }
2455    }
2456}
2457
2458/// Insert `"introduced": bool` into every dead-code finding object in the
2459/// serialized JSON by re-deriving each key and testing membership against
2460/// the base snapshot. JSON arrays must be in the same order as the typed
2461/// `results` collections they were serialized from.
2462#[expect(
2463    clippy::implicit_hasher,
2464    reason = "fallow standardizes on FxHashSet across audit attribution keys"
2465)]
2466pub fn annotate_dead_code_json(
2467    json: &mut serde_json::Value,
2468    results: &fallow_types::results::AnalysisResults,
2469    root: &Path,
2470    base: &FxHashSet<String>,
2471) {
2472    let mut annotator = DeadCodeJsonAnnotator {
2473        json,
2474        results,
2475        root,
2476        base,
2477    };
2478    annotator.annotate_file_symbols();
2479    annotator.annotate_dependencies();
2480    annotator.annotate_members();
2481    annotator.annotate_imports_and_exports();
2482    annotator.annotate_graph();
2483    annotator.annotate_catalog();
2484}
2485
2486/// Annotate the sole legacy dead-code collection without a typed
2487/// `introduced` field. Every wrapper-backed collection is annotated from the
2488/// persisted [`AuditComparison`] instead.
2489#[expect(
2490    clippy::implicit_hasher,
2491    reason = "fallow standardizes on FxHashSet across audit attribution keys"
2492)]
2493pub fn annotate_stale_suppressions_json(
2494    json: &mut serde_json::Value,
2495    results: &fallow_types::results::AnalysisResults,
2496    root: &Path,
2497    base: &FxHashSet<String>,
2498) {
2499    annotate_issue_array(
2500        json,
2501        "stale_suppressions",
2502        results
2503            .stale_suppressions
2504            .iter()
2505            .map(|item| issue_was_introduced(&stale_suppression_key(item, root), base)),
2506    );
2507}
2508
2509struct DeadCodeJsonAnnotator<'a> {
2510    json: &'a mut serde_json::Value,
2511    results: &'a fallow_types::results::AnalysisResults,
2512    root: &'a Path,
2513    base: &'a FxHashSet<String>,
2514}
2515
2516impl DeadCodeJsonAnnotator<'_> {
2517    fn annotate_file_symbols(&mut self) {
2518        annotate_issue_array(
2519            self.json,
2520            "unused_files",
2521            self.results.unused_files.iter().map(|item| {
2522                issue_was_introduced(
2523                    &format!(
2524                        "unused-file:{}",
2525                        relative_key_path(&item.file.path, self.root)
2526                    ),
2527                    self.base,
2528                )
2529            }),
2530        );
2531        annotate_issue_array(
2532            self.json,
2533            "unused_exports",
2534            self.results.unused_exports.iter().map(|item| {
2535                issue_was_introduced(
2536                    &format!(
2537                        "unused-export:{}:{}",
2538                        relative_key_path(&item.export.path, self.root),
2539                        item.export.export_name
2540                    ),
2541                    self.base,
2542                )
2543            }),
2544        );
2545        annotate_issue_array(
2546            self.json,
2547            "unused_types",
2548            self.results.unused_types.iter().map(|item| {
2549                issue_was_introduced(
2550                    &format!(
2551                        "unused-type:{}:{}",
2552                        relative_key_path(&item.export.path, self.root),
2553                        item.export.export_name
2554                    ),
2555                    self.base,
2556                )
2557            }),
2558        );
2559        annotate_issue_array(
2560            self.json,
2561            "private_type_leaks",
2562            self.results.private_type_leaks.iter().map(|item| {
2563                issue_was_introduced(
2564                    &format!(
2565                        "private-type-leak:{}:{}:{}",
2566                        relative_key_path(&item.leak.path, self.root),
2567                        item.leak.export_name,
2568                        item.leak.type_name
2569                    ),
2570                    self.base,
2571                )
2572            }),
2573        );
2574    }
2575
2576    fn annotate_dependencies(&mut self) {
2577        annotate_dependency_json(self.json, self.results, self.root, self.base);
2578        annotate_issue_array(
2579            self.json,
2580            "type_only_dependencies",
2581            self.results.type_only_dependencies.iter().map(|item| {
2582                issue_was_introduced(
2583                    &format!(
2584                        "type-only-dependency:{}:{}",
2585                        relative_key_path(&item.dep.path, self.root),
2586                        item.dep.package_name
2587                    ),
2588                    self.base,
2589                )
2590            }),
2591        );
2592        annotate_issue_array(
2593            self.json,
2594            "test_only_dependencies",
2595            self.results.test_only_dependencies.iter().map(|item| {
2596                issue_was_introduced(
2597                    &format!(
2598                        "test-only-dependency:{}:{}",
2599                        relative_key_path(&item.dep.path, self.root),
2600                        item.dep.package_name
2601                    ),
2602                    self.base,
2603                )
2604            }),
2605        );
2606    }
2607
2608    fn annotate_members(&mut self) {
2609        annotate_member_json(self.json, self.results, self.root, self.base);
2610    }
2611
2612    fn annotate_imports_and_exports(&mut self) {
2613        self.annotate_import_dependency_keys();
2614        self.annotate_framework_keys();
2615        self.annotate_component_keys();
2616        self.annotate_route_keys();
2617    }
2618
2619    fn annotate_import_dependency_keys(&mut self) {
2620        annotate_issue_array(
2621            self.json,
2622            "unresolved_imports",
2623            self.results.unresolved_imports.iter().map(|item| {
2624                issue_was_introduced(
2625                    &format!(
2626                        "unresolved-import:{}:{}",
2627                        relative_key_path(&item.import.path, self.root),
2628                        item.import.specifier
2629                    ),
2630                    self.base,
2631                )
2632            }),
2633        );
2634        annotate_issue_array(
2635            self.json,
2636            "unlisted_dependencies",
2637            self.results.unlisted_dependencies.iter().map(|item| {
2638                issue_was_introduced(&unlisted_dependency_key(&item.dep, self.root), self.base)
2639            }),
2640        );
2641        annotate_issue_array(
2642            self.json,
2643            "duplicate_exports",
2644            self.results.duplicate_exports.iter().map(|item| {
2645                let mut locations: Vec<String> = item
2646                    .export
2647                    .locations
2648                    .iter()
2649                    .map(|loc| relative_key_path(&loc.path, self.root))
2650                    .collect();
2651                locations.sort();
2652                locations.dedup();
2653                issue_was_introduced(
2654                    &format!(
2655                        "duplicate-export:{}:{}",
2656                        item.export.export_name,
2657                        locations.join("|")
2658                    ),
2659                    self.base,
2660                )
2661            }),
2662        );
2663    }
2664
2665    fn annotate_framework_keys(&mut self) {
2666        annotate_issue_array(
2667            self.json,
2668            "invalid_client_exports",
2669            self.results.invalid_client_exports.iter().map(|item| {
2670                issue_was_introduced(
2671                    &invalid_client_export_key(&item.export, self.root),
2672                    self.base,
2673                )
2674            }),
2675        );
2676        annotate_issue_array(
2677            self.json,
2678            "mixed_client_server_barrels",
2679            self.results.mixed_client_server_barrels.iter().map(|item| {
2680                issue_was_introduced(
2681                    &mixed_client_server_barrel_key(&item.barrel, self.root),
2682                    self.base,
2683                )
2684            }),
2685        );
2686        annotate_issue_array(
2687            self.json,
2688            "misplaced_directives",
2689            self.results.misplaced_directives.iter().map(|item| {
2690                issue_was_introduced(
2691                    &misplaced_directive_key(&item.directive_site, self.root),
2692                    self.base,
2693                )
2694            }),
2695        );
2696        annotate_issue_array(
2697            self.json,
2698            "unprovided_injects",
2699            self.results.unprovided_injects.iter().map(|item| {
2700                issue_was_introduced(&unprovided_inject_key(&item.inject, self.root), self.base)
2701            }),
2702        );
2703    }
2704
2705    fn annotate_component_keys(&mut self) {
2706        self.annotate_component_render_keys();
2707        self.annotate_component_io_keys();
2708    }
2709
2710    /// Annotate rendered-component, prop, and emit issue arrays.
2711    fn annotate_component_render_keys(&mut self) {
2712        annotate_issue_array(
2713            self.json,
2714            "unrendered_components",
2715            self.results.unrendered_components.iter().map(|item| {
2716                issue_was_introduced(
2717                    &unrendered_component_key(&item.component, self.root),
2718                    self.base,
2719                )
2720            }),
2721        );
2722        annotate_issue_array(
2723            self.json,
2724            "unused_component_props",
2725            self.results.unused_component_props.iter().map(|item| {
2726                issue_was_introduced(&unused_component_prop_key(&item.prop, self.root), self.base)
2727            }),
2728        );
2729        annotate_issue_array(
2730            self.json,
2731            "unused_component_emits",
2732            self.results.unused_component_emits.iter().map(|item| {
2733                issue_was_introduced(&unused_component_emit_key(&item.emit, self.root), self.base)
2734            }),
2735        );
2736    }
2737
2738    /// Annotate component input/output, Svelte event, and server-action issue arrays.
2739    fn annotate_component_io_keys(&mut self) {
2740        annotate_issue_array(
2741            self.json,
2742            "unused_component_inputs",
2743            self.results.unused_component_inputs.iter().map(|item| {
2744                issue_was_introduced(
2745                    &unused_component_input_key(&item.input, self.root),
2746                    self.base,
2747                )
2748            }),
2749        );
2750        annotate_issue_array(
2751            self.json,
2752            "unused_component_outputs",
2753            self.results.unused_component_outputs.iter().map(|item| {
2754                issue_was_introduced(
2755                    &unused_component_output_key(&item.output, self.root),
2756                    self.base,
2757                )
2758            }),
2759        );
2760        annotate_issue_array(
2761            self.json,
2762            "unused_svelte_events",
2763            self.results.unused_svelte_events.iter().map(|item| {
2764                issue_was_introduced(&unused_svelte_event_key(&item.event, self.root), self.base)
2765            }),
2766        );
2767        annotate_issue_array(
2768            self.json,
2769            "unused_server_actions",
2770            self.results.unused_server_actions.iter().map(|item| {
2771                issue_was_introduced(
2772                    &unused_server_action_key(&item.action, self.root),
2773                    self.base,
2774                )
2775            }),
2776        );
2777    }
2778
2779    fn annotate_route_keys(&mut self) {
2780        annotate_issue_array(
2781            self.json,
2782            "route_collisions",
2783            self.results.route_collisions.iter().map(|item| {
2784                issue_was_introduced(&route_collision_key(&item.collision, self.root), self.base)
2785            }),
2786        );
2787        annotate_issue_array(
2788            self.json,
2789            "dynamic_segment_name_conflicts",
2790            self.results
2791                .dynamic_segment_name_conflicts
2792                .iter()
2793                .map(|item| {
2794                    issue_was_introduced(
2795                        &dynamic_segment_name_conflict_key(&item.conflict, self.root),
2796                        self.base,
2797                    )
2798                }),
2799        );
2800    }
2801
2802    fn annotate_graph(&mut self) {
2803        annotate_graph_json(self.json, self.results, self.root, self.base);
2804    }
2805
2806    fn annotate_catalog(&mut self) {
2807        annotate_catalog_json(self.json, self.results, self.root, self.base);
2808    }
2809}
2810
2811fn annotate_dependency_json(
2812    json: &mut serde_json::Value,
2813    results: &fallow_types::results::AnalysisResults,
2814    root: &Path,
2815    base: &FxHashSet<String>,
2816) {
2817    annotate_issue_array(
2818        json,
2819        "unused_dependencies",
2820        results
2821            .unused_dependencies
2822            .iter()
2823            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2824    );
2825    annotate_issue_array(
2826        json,
2827        "unused_dev_dependencies",
2828        results
2829            .unused_dev_dependencies
2830            .iter()
2831            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2832    );
2833    annotate_issue_array(
2834        json,
2835        "unused_optional_dependencies",
2836        results
2837            .unused_optional_dependencies
2838            .iter()
2839            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2840    );
2841}
2842
2843fn annotate_member_json(
2844    json: &mut serde_json::Value,
2845    results: &fallow_types::results::AnalysisResults,
2846    root: &Path,
2847    base: &FxHashSet<String>,
2848) {
2849    annotate_issue_array(
2850        json,
2851        "unused_enum_members",
2852        results.unused_enum_members.iter().map(|item| {
2853            issue_was_introduced(
2854                &unused_member_key("unused-enum-member", &item.member, root),
2855                base,
2856            )
2857        }),
2858    );
2859    annotate_issue_array(
2860        json,
2861        "unused_class_members",
2862        results.unused_class_members.iter().map(|item| {
2863            issue_was_introduced(
2864                &unused_member_key("unused-class-member", &item.member, root),
2865                base,
2866            )
2867        }),
2868    );
2869    annotate_issue_array(
2870        json,
2871        "unused_store_members",
2872        results.unused_store_members.iter().map(|item| {
2873            issue_was_introduced(
2874                &unused_member_key("unused-store-member", &item.member, root),
2875                base,
2876            )
2877        }),
2878    );
2879}
2880
2881fn annotate_graph_json(
2882    json: &mut serde_json::Value,
2883    results: &fallow_types::results::AnalysisResults,
2884    root: &Path,
2885    base: &FxHashSet<String>,
2886) {
2887    annotate_cycle_json(json, results, root, base);
2888    annotate_boundary_json(json, results, root, base);
2889    annotate_policy_json(json, results, root, base);
2890}
2891
2892fn annotate_cycle_json(
2893    json: &mut serde_json::Value,
2894    results: &fallow_types::results::AnalysisResults,
2895    root: &Path,
2896    base: &FxHashSet<String>,
2897) {
2898    annotate_issue_array(
2899        json,
2900        "circular_dependencies",
2901        results.circular_dependencies.iter().map(|item| {
2902            let mut files: Vec<String> = item
2903                .cycle
2904                .files
2905                .iter()
2906                .map(|path| relative_key_path(path, root))
2907                .collect();
2908            files.sort();
2909            issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
2910        }),
2911    );
2912    annotate_issue_array(
2913        json,
2914        "re_export_cycles",
2915        results.re_export_cycles.iter().map(|item| {
2916            let kind = match item.cycle.kind {
2917                fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
2918                fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
2919            };
2920            let mut files: Vec<String> = item
2921                .cycle
2922                .files
2923                .iter()
2924                .map(|path| relative_key_path(path, root))
2925                .collect();
2926            files.sort();
2927            issue_was_introduced(&format!("re-export-cycle:{kind}:{}", files.join("|")), base)
2928        }),
2929    );
2930}
2931
2932fn annotate_boundary_json(
2933    json: &mut serde_json::Value,
2934    results: &fallow_types::results::AnalysisResults,
2935    root: &Path,
2936    base: &FxHashSet<String>,
2937) {
2938    annotate_issue_array(
2939        json,
2940        "boundary_violations",
2941        results.boundary_violations.iter().map(|item| {
2942            issue_was_introduced(
2943                &format!(
2944                    "boundary-violation:{}:{}:{}",
2945                    relative_key_path(&item.violation.from_path, root),
2946                    relative_key_path(&item.violation.to_path, root),
2947                    item.violation.import_specifier
2948                ),
2949                base,
2950            )
2951        }),
2952    );
2953    annotate_issue_array(
2954        json,
2955        "boundary_coverage_violations",
2956        results.boundary_coverage_violations.iter().map(|item| {
2957            issue_was_introduced(
2958                &format!(
2959                    "boundary-coverage:{}",
2960                    relative_key_path(&item.violation.path, root)
2961                ),
2962                base,
2963            )
2964        }),
2965    );
2966    annotate_issue_array(
2967        json,
2968        "boundary_call_violations",
2969        results.boundary_call_violations.iter().map(|item| {
2970            issue_was_introduced(
2971                &format!(
2972                    "boundary-call:{}:{}",
2973                    relative_key_path(&item.violation.path, root),
2974                    item.violation.callee
2975                ),
2976                base,
2977            )
2978        }),
2979    );
2980}
2981
2982fn annotate_policy_json(
2983    json: &mut serde_json::Value,
2984    results: &fallow_types::results::AnalysisResults,
2985    root: &Path,
2986    base: &FxHashSet<String>,
2987) {
2988    annotate_issue_array(
2989        json,
2990        "policy_violations",
2991        results.policy_violations.iter().map(|item| {
2992            issue_was_introduced(
2993                &format!(
2994                    "policy-violation:{}:{}/{}:{}",
2995                    relative_key_path(&item.violation.path, root),
2996                    item.violation.pack,
2997                    item.violation.rule_id,
2998                    item.violation.matched
2999                ),
3000                base,
3001            )
3002        }),
3003    );
3004    annotate_issue_array(
3005        json,
3006        "stale_suppressions",
3007        results
3008            .stale_suppressions
3009            .iter()
3010            .map(|item| issue_was_introduced(&stale_suppression_key(item, root), base)),
3011    );
3012}
3013
3014fn annotate_catalog_json(
3015    json: &mut serde_json::Value,
3016    results: &fallow_types::results::AnalysisResults,
3017    root: &Path,
3018    base: &FxHashSet<String>,
3019) {
3020    annotate_catalog_entry_json(json, results, root, base);
3021    annotate_dependency_override_json(json, results, root, base);
3022}
3023
3024/// Annotate catalog-reference, catalog-entry, and empty-group issue arrays.
3025fn annotate_catalog_entry_json(
3026    json: &mut serde_json::Value,
3027    results: &fallow_types::results::AnalysisResults,
3028    root: &Path,
3029    base: &FxHashSet<String>,
3030) {
3031    annotate_issue_array(
3032        json,
3033        "unresolved_catalog_references",
3034        results.unresolved_catalog_references.iter().map(|item| {
3035            issue_was_introduced(
3036                &format!(
3037                    "unresolved-catalog-reference:{}:{}:{}:{}",
3038                    relative_key_path(&item.reference.path, root),
3039                    item.reference.line,
3040                    item.reference.catalog_name,
3041                    item.reference.entry_name
3042                ),
3043                base,
3044            )
3045        }),
3046    );
3047    annotate_issue_array(
3048        json,
3049        "unused_catalog_entries",
3050        results
3051            .unused_catalog_entries
3052            .iter()
3053            .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
3054    );
3055    annotate_issue_array(
3056        json,
3057        "empty_catalog_groups",
3058        results
3059            .empty_catalog_groups
3060            .iter()
3061            .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
3062    );
3063}
3064
3065/// Annotate dependency-override issue arrays (unused and misconfigured).
3066fn annotate_dependency_override_json(
3067    json: &mut serde_json::Value,
3068    results: &fallow_types::results::AnalysisResults,
3069    root: &Path,
3070    base: &FxHashSet<String>,
3071) {
3072    annotate_issue_array(
3073        json,
3074        "unused_dependency_overrides",
3075        results.unused_dependency_overrides.iter().map(|item| {
3076            issue_was_introduced(
3077                &format!(
3078                    "unused-dependency-override:{}:{}:{}",
3079                    relative_key_path(&item.entry.path, root),
3080                    item.entry.line,
3081                    item.entry.raw_key
3082                ),
3083                base,
3084            )
3085        }),
3086    );
3087    annotate_issue_array(
3088        json,
3089        "misconfigured_dependency_overrides",
3090        results
3091            .misconfigured_dependency_overrides
3092            .iter()
3093            .map(|item| {
3094                issue_was_introduced(
3095                    &format!(
3096                        "misconfigured-dependency-override:{}:{}:{}",
3097                        relative_key_path(&item.entry.path, root),
3098                        item.entry.line,
3099                        item.entry.raw_key
3100                    ),
3101                    base,
3102                )
3103            }),
3104    );
3105}
3106
3107/// Insert `"introduced": bool` into each `findings` and `styling_findings`
3108/// object in serialized health JSON, matching entries to the typed report by
3109/// array position.
3110#[expect(
3111    clippy::implicit_hasher,
3112    reason = "fallow standardizes on FxHashSet across audit attribution keys"
3113)]
3114pub fn annotate_health_json(
3115    json: &mut serde_json::Value,
3116    report: &fallow_output::HealthReport,
3117    root: &Path,
3118    base: &FxHashSet<String>,
3119) {
3120    if let Some(items) = json
3121        .get_mut("findings")
3122        .and_then(serde_json::Value::as_array_mut)
3123    {
3124        for (item, finding) in items.iter_mut().zip(&report.findings) {
3125            if let serde_json::Value::Object(map) = item {
3126                map.insert(
3127                    "introduced".to_string(),
3128                    serde_json::json!(issue_was_introduced(
3129                        &health_finding_key(finding, root),
3130                        base
3131                    )),
3132                );
3133            }
3134        }
3135    }
3136    if let Some(items) = json
3137        .get_mut("styling_findings")
3138        .and_then(serde_json::Value::as_array_mut)
3139    {
3140        for (item, finding) in items.iter_mut().zip(&report.styling_findings) {
3141            if let serde_json::Value::Object(map) = item {
3142                map.insert(
3143                    "introduced".to_string(),
3144                    serde_json::json!(issue_was_introduced(
3145                        &styling_finding_key(finding, root),
3146                        base
3147                    )),
3148                );
3149            }
3150        }
3151    }
3152}
3153
3154/// Insert `"introduced": bool` into each `clone_groups` object in serialized
3155/// duplication JSON, matching groups to the typed report by array position.
3156#[expect(
3157    clippy::implicit_hasher,
3158    reason = "fallow standardizes on FxHashSet across audit attribution keys"
3159)]
3160pub fn annotate_dupes_json(
3161    json: &mut serde_json::Value,
3162    report: &fallow_types::duplicates::DuplicationReport,
3163    root: &Path,
3164    base: &FxHashSet<String>,
3165) {
3166    let Some(items) = json
3167        .get_mut("clone_groups")
3168        .and_then(serde_json::Value::as_array_mut)
3169    else {
3170        return;
3171    };
3172    for (item, group) in items.iter_mut().zip(&report.clone_groups) {
3173        if let serde_json::Value::Object(map) = item {
3174            map.insert(
3175                "introduced".to_string(),
3176                serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
3177            );
3178        }
3179    }
3180}
3181
3182/// Attach precomputed introduced membership to an audit JSON array.
3183pub fn annotate_domain_json(
3184    json: &mut serde_json::Value,
3185    collection: &str,
3186    introduced: impl IntoIterator<Item = bool>,
3187) {
3188    annotate_issue_array(json, collection, introduced);
3189}
3190
3191/// Attribution key set for every complexity finding in a health report.
3192pub fn health_keys(report: &fallow_output::HealthReport, root: &Path) -> FxHashSet<String> {
3193    report
3194        .findings
3195        .iter()
3196        .map(|finding| health_finding_key(finding, root))
3197        .collect()
3198}
3199
3200/// Attribution key for one complexity finding:
3201/// `complexity:<path>:<function>:<exceeded metric>`. Line numbers are
3202/// deliberately excluded so a finding survives unrelated edits above it.
3203pub fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
3204    format!(
3205        "complexity:{}:{}:{:?}",
3206        relative_key_path(Path::new(&finding.path), root),
3207        finding.name,
3208        finding.exceeded
3209    )
3210}
3211
3212/// Attribution key set for every styling finding in a health report.
3213pub fn styling_keys(report: &fallow_output::HealthReport, root: &Path) -> FxHashSet<String> {
3214    report
3215        .styling_findings
3216        .iter()
3217        .map(|finding| styling_finding_key(finding, root))
3218        .collect()
3219}
3220
3221/// Attribution key for one styling finding:
3222/// `styling:<code>:<sub kind>:<path>:<line>:<value>`.
3223pub fn styling_finding_key(finding: &fallow_output::StylingFinding, root: &Path) -> String {
3224    format!(
3225        "styling:{}:{}:{}:{}:{}",
3226        finding.code,
3227        finding.sub_kind,
3228        relative_key_path(Path::new(&finding.path), root),
3229        finding.line,
3230        finding.value
3231    )
3232}
3233
3234/// Attribution key set for every clone group in a duplication report.
3235pub fn dupes_keys(
3236    report: &fallow_types::duplicates::DuplicationReport,
3237    root: &Path,
3238) -> FxHashSet<String> {
3239    report
3240        .clone_groups
3241        .iter()
3242        .map(|group| dupe_group_key(group, root))
3243        .collect()
3244}
3245
3246/// Attribution key for one clone group:
3247/// `dupe:<sorted files joined by |>:<tokens>:<lines>:<fragment hash>`.
3248/// The fragment hash keeps distinct groups spanning the same files apart;
3249/// line positions are excluded so a group survives shifts within its files.
3250pub fn dupe_group_key(group: &fallow_types::duplicates::CloneGroup, root: &Path) -> String {
3251    let mut files: Vec<String> = group
3252        .instances
3253        .iter()
3254        .map(|instance| relative_key_path(&instance.file, root))
3255        .collect();
3256    files.sort();
3257    files.dedup();
3258    let mut hasher = DefaultHasher::new();
3259    for instance in &group.instances {
3260        instance.fragment.hash(&mut hasher);
3261    }
3262    format!(
3263        "dupe:{}:{}:{}:{:x}",
3264        files.join("|"),
3265        group.token_count,
3266        group.line_count,
3267        hasher.finish()
3268    )
3269}
3270
3271/// Keys of clone groups whose every instance lies entirely outside the diff's
3272/// added lines: no instance range contains an added line, so the changeset did
3273/// not write the duplicated text, and the group only became reportable (under
3274/// a new attribution key) because the changeset removed code elsewhere;
3275/// deduplicating one region can create or re-shape a clone group across files
3276/// (issue #2164). New-only gating demotes these groups to inherited so a
3277/// clone-removal refactor is not failed by the duplication it did not write.
3278/// An instance whose path cannot be mapped into the diff's namespace is
3279/// treated as touched, so its group keeps gating.
3280pub fn preexisting_dupe_group_keys<'a>(
3281    groups: impl IntoIterator<Item = &'a fallow_types::duplicates::CloneGroup>,
3282    root: &Path,
3283    diff: &fallow_output::DiffIndex,
3284) -> FxHashSet<String> {
3285    let instance_touched = |instance: &fallow_types::duplicates::CloneInstance| -> bool {
3286        let Some(rel) = diff.key_for(&instance.file, root) else {
3287            return true;
3288        };
3289        let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
3290        let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
3291        diff.range_overlaps_added(&rel, start, end)
3292    };
3293    groups
3294        .into_iter()
3295        .filter(|group| !group.instances.iter().any(instance_touched))
3296        .map(|group| dupe_group_key(group, root))
3297        .collect()
3298}
3299
3300#[cfg(test)]
3301mod tests {
3302    use std::path::{Path, PathBuf};
3303
3304    use fallow_config::{FallowConfig, Severity};
3305    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationReport};
3306    use fallow_types::envelope::AuditIntroduced;
3307    use fallow_types::extract::MemberKind;
3308    use fallow_types::output_dead_code::*;
3309    use fallow_types::output_format::OutputFormat;
3310    use fallow_types::results::*;
3311    use rustc_hash::FxHashSet;
3312    use serde_json::json;
3313
3314    use fallow_output::{
3315        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
3316    };
3317
3318    use super::{
3319        AuditDomainLedger, annotate_dead_code_json, annotate_dupes_json, annotate_health_json,
3320        annotate_stale_suppressions_json, dead_code_audit_ledger, dead_code_keys, dupe_group_key,
3321        dupes_keys, health_finding_key, health_keys, relative_key_path, remap_keys_for_renames,
3322        retain_introduced_dead_code,
3323    };
3324
3325    fn root() -> PathBuf {
3326        PathBuf::from("/repo")
3327    }
3328
3329    fn export(path: &Path, name: &str) -> UnusedExportFinding {
3330        UnusedExportFinding::with_actions(UnusedExport {
3331            path: path.to_path_buf(),
3332            export_name: name.to_string(),
3333            is_type_only: false,
3334            line: 1,
3335            col: 0,
3336            span_start: 0,
3337            is_re_export: false,
3338        })
3339    }
3340
3341    fn unused_file(path: &Path) -> UnusedFileFinding {
3342        UnusedFileFinding::with_actions(UnusedFile {
3343            path: path.to_path_buf(),
3344        })
3345    }
3346
3347    fn dependency(path: &Path, package_name: &str) -> UnusedDependencyFinding {
3348        UnusedDependencyFinding::with_actions(UnusedDependency {
3349            package_name: package_name.to_string(),
3350            location: DependencyLocation::Dependencies,
3351            path: path.to_path_buf(),
3352            line: 4,
3353            used_in_workspaces: Vec::new(),
3354        })
3355    }
3356
3357    fn unresolved(path: &Path, specifier: &str) -> UnresolvedImportFinding {
3358        UnresolvedImportFinding::with_actions(UnresolvedImport {
3359            path: path.to_path_buf(),
3360            specifier: specifier.to_string(),
3361            line: 2,
3362            col: 1,
3363            specifier_col: 8,
3364        })
3365    }
3366
3367    fn unlisted(path: &Path, package_name: &str) -> UnlistedDependencyFinding {
3368        UnlistedDependencyFinding::with_actions(UnlistedDependency {
3369            package_name: package_name.to_string(),
3370            imported_from: vec![
3371                ImportSite {
3372                    path: path.to_path_buf(),
3373                    line: 9,
3374                    col: 2,
3375                },
3376                ImportSite {
3377                    path: path.to_path_buf(),
3378                    line: 9,
3379                    col: 2,
3380                },
3381            ],
3382        })
3383    }
3384
3385    fn duplicate_export(root: &Path) -> DuplicateExportFinding {
3386        DuplicateExportFinding::with_actions(DuplicateExport {
3387            export_name: "Button".to_string(),
3388            locations: vec![
3389                DuplicateLocation {
3390                    path: root.join("src/b.ts"),
3391                    line: 1,
3392                    col: 0,
3393                },
3394                DuplicateLocation {
3395                    path: root.join("src/a.ts"),
3396                    line: 1,
3397                    col: 0,
3398                },
3399                DuplicateLocation {
3400                    path: root.join("src/a.ts"),
3401                    line: 2,
3402                    col: 0,
3403                },
3404            ],
3405        })
3406    }
3407
3408    fn sample_results(root: &Path) -> AnalysisResults {
3409        let source = root.join("src/page.ts");
3410        let package_json = root.join("package.json");
3411        let mut results = AnalysisResults::default();
3412        results
3413            .unused_files
3414            .push(unused_file(&root.join("src/dead.ts")));
3415        results.unused_exports.push(export(&source, "loader"));
3416        results
3417            .unused_dependencies
3418            .push(dependency(&package_json, "left-pad"));
3419        results
3420            .unresolved_imports
3421            .push(unresolved(&source, "./missing"));
3422        results.unlisted_dependencies.push(unlisted(&source, "zod"));
3423        results.duplicate_exports.push(duplicate_export(root));
3424        results
3425    }
3426
3427    #[test]
3428    fn relative_key_path_strips_root_and_normalizes_separators() {
3429        let path = Path::new("/repo/src\\feature\\index.ts");
3430        assert_eq!(
3431            relative_key_path(path, Path::new("/repo")),
3432            "src/feature/index.ts"
3433        );
3434    }
3435
3436    #[test]
3437    fn dead_code_keys_are_stable_for_unsorted_and_duplicate_locations() {
3438        let root = root();
3439        let keys = dead_code_keys(&sample_results(&root), &root);
3440
3441        assert!(keys.contains("unused-file:src/dead.ts"));
3442        assert!(keys.contains("unused-export:src/page.ts:loader"));
3443        assert!(keys.contains("unused-dependency:package.json:left-pad"));
3444        assert!(keys.contains("unresolved-import:src/page.ts:./missing"));
3445        assert!(keys.contains("unlisted-dependency:zod:src/page.ts:9:2"));
3446        assert!(keys.contains("duplicate-export:Button:src/a.ts|src/b.ts"));
3447    }
3448
3449    #[test]
3450    fn dead_code_keys_cover_type_member_and_dependency_variants() {
3451        let root = root();
3452        let source = root.join("src/types.ts");
3453        let package_json = root.join("package.json");
3454        let mut results = AnalysisResults::default();
3455        results
3456            .unused_types
3457            .push(UnusedTypeFinding::with_actions(UnusedExport {
3458                path: source.clone(),
3459                export_name: "UnusedType".to_string(),
3460                is_type_only: true,
3461                line: 3,
3462                col: 0,
3463                span_start: 12,
3464                is_re_export: false,
3465            }));
3466        results
3467            .private_type_leaks
3468            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
3469                path: source.clone(),
3470                export_name: "makePublic".to_string(),
3471                type_name: "PrivateShape".to_string(),
3472                line: 7,
3473                col: 12,
3474                span_start: 64,
3475                semantic: None,
3476            }));
3477        results
3478            .unused_dev_dependencies
3479            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3480                package_name: "vite".to_string(),
3481                location: DependencyLocation::DevDependencies,
3482                path: package_json.clone(),
3483                line: 10,
3484                used_in_workspaces: Vec::new(),
3485            }));
3486        results
3487            .unused_optional_dependencies
3488            .push(UnusedOptionalDependencyFinding::with_actions(
3489                UnusedDependency {
3490                    package_name: "fsevents".to_string(),
3491                    location: DependencyLocation::OptionalDependencies,
3492                    path: package_json.clone(),
3493                    line: 11,
3494                    used_in_workspaces: Vec::new(),
3495                },
3496            ));
3497        results
3498            .unused_enum_members
3499            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3500                path: source.clone(),
3501                parent_name: "Status".to_string(),
3502                member_name: "Idle".to_string(),
3503                kind: MemberKind::EnumMember,
3504                line: 15,
3505                col: 2,
3506            }));
3507        results
3508            .unused_class_members
3509            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
3510                path: source,
3511                parent_name: "Controller".to_string(),
3512                member_name: "legacy".to_string(),
3513                kind: MemberKind::ClassMethod,
3514                line: 21,
3515                col: 2,
3516            }));
3517        results
3518            .type_only_dependencies
3519            .push(TypeOnlyDependencyFinding::with_actions(
3520                TypeOnlyDependency {
3521                    package_name: "zod".to_string(),
3522                    path: package_json.clone(),
3523                    line: 12,
3524                },
3525            ));
3526        results
3527            .test_only_dependencies
3528            .push(TestOnlyDependencyFinding::with_actions(
3529                TestOnlyDependency {
3530                    package_name: "vitest".to_string(),
3531                    path: package_json,
3532                    line: 13,
3533                },
3534            ));
3535
3536        let keys = dead_code_keys(&results, &root);
3537
3538        assert!(keys.contains("unused-type:src/types.ts:UnusedType"));
3539        assert!(keys.contains("private-type-leak:src/types.ts:makePublic:PrivateShape"));
3540        assert!(keys.contains("unused-dev-dependency:package.json:vite"));
3541        assert!(keys.contains("unused-optional-dependency:package.json:fsevents"));
3542        assert!(keys.contains("unused-enum-member:src/types.ts:Status:Idle"));
3543        assert!(keys.contains("unused-class-member:src/types.ts:Controller:legacy"));
3544        assert!(keys.contains("type-only-dependency:package.json:zod"));
3545        assert!(keys.contains("test-only-dependency:package.json:vitest"));
3546    }
3547
3548    #[expect(
3549        clippy::too_many_lines,
3550        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3551    )]
3552    fn graph_boundary_catalog_override_results(root: &std::path::Path) -> AnalysisResults {
3553        let source = root.join("src/app.ts");
3554        let other = root.join("src/other.ts");
3555        let workspace = root.join("pnpm-workspace.yaml");
3556        let mut results = AnalysisResults::default();
3557        results
3558            .circular_dependencies
3559            .push(CircularDependencyFinding::with_actions(
3560                CircularDependency {
3561                    files: vec![other.clone(), source.clone()],
3562                    length: 2,
3563                    line: 4,
3564                    col: 0,
3565                    edges: Vec::new(),
3566                    is_cross_package: false,
3567                },
3568            ));
3569        results
3570            .re_export_cycles
3571            .push(ReExportCycleFinding::with_actions(ReExportCycle {
3572                files: vec![source.clone()],
3573                kind: ReExportCycleKind::SelfLoop,
3574            }));
3575        results
3576            .boundary_violations
3577            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3578                from_path: source.clone(),
3579                to_path: other,
3580                from_zone: "ui".to_string(),
3581                to_zone: "server".to_string(),
3582                import_specifier: "../other".to_string(),
3583                line: 1,
3584                col: 0,
3585            }));
3586        results
3587            .boundary_coverage_violations
3588            .push(BoundaryCoverageViolationFinding::with_actions(
3589                BoundaryCoverageViolation {
3590                    path: root.join("src/unmatched.ts"),
3591                    line: 1,
3592                    col: 0,
3593                },
3594            ));
3595        results
3596            .boundary_call_violations
3597            .push(BoundaryCallViolationFinding::with_actions(
3598                BoundaryCallViolation {
3599                    path: source.clone(),
3600                    line: 12,
3601                    col: 4,
3602                    zone: "ui".to_string(),
3603                    callee: "child_process.exec".to_string(),
3604                    pattern: "child_process.*".to_string(),
3605                },
3606            ));
3607        results.stale_suppressions.push(StaleSuppression {
3608            path: source,
3609            line: 2,
3610            col: 0,
3611            origin: SuppressionOrigin::Comment {
3612                issue_kind: Some("unused-export".to_string()),
3613                reason: None,
3614                is_file_level: false,
3615                kind_known: true,
3616            },
3617            missing_reason: false,
3618            actions: StaleSuppression::actions_for(false),
3619        });
3620        results.stale_suppressions.push(StaleSuppression {
3621            path: root.join("src/app.ts"),
3622            line: 2,
3623            col: 0,
3624            origin: SuppressionOrigin::Comment {
3625                issue_kind: Some("unused-export".to_string()),
3626                reason: None,
3627                is_file_level: false,
3628                kind_known: true,
3629            },
3630            missing_reason: true,
3631            actions: StaleSuppression::actions_for(true),
3632        });
3633        results.unresolved_catalog_references.push(
3634            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
3635                entry_name: "react".to_string(),
3636                catalog_name: "default".to_string(),
3637                path: root.join("packages/app/package.json"),
3638                line: 9,
3639                available_in_catalogs: vec!["react18".to_string()],
3640            }),
3641        );
3642        results
3643            .unused_catalog_entries
3644            .push(UnusedCatalogEntryFinding::with_actions(
3645                UnusedCatalogEntry {
3646                    entry_name: "lodash".to_string(),
3647                    catalog_name: "default".to_string(),
3648                    path: workspace.clone(),
3649                    line: 3,
3650                    hardcoded_consumers: Vec::new(),
3651                },
3652            ));
3653        results
3654            .empty_catalog_groups
3655            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
3656                catalog_name: "react17".to_string(),
3657                path: workspace.clone(),
3658                line: 7,
3659            }));
3660        results
3661            .unused_dependency_overrides
3662            .push(UnusedDependencyOverrideFinding::with_actions(
3663                UnusedDependencyOverride {
3664                    raw_key: "left-pad".to_string(),
3665                    target_package: "left-pad".to_string(),
3666                    parent_package: None,
3667                    version_constraint: None,
3668                    version_range: "^1.3.0".to_string(),
3669                    source: DependencyOverrideSource::PnpmWorkspaceYaml,
3670                    path: workspace.clone(),
3671                    line: 11,
3672                    hint: None,
3673                },
3674            ));
3675        results.misconfigured_dependency_overrides.push(
3676            MisconfiguredDependencyOverrideFinding::with_actions(MisconfiguredDependencyOverride {
3677                raw_key: ">".to_string(),
3678                target_package: None,
3679                raw_value: String::new(),
3680                reason: DependencyOverrideMisconfigReason::UnparsableKey,
3681                source: DependencyOverrideSource::PnpmWorkspaceYaml,
3682                path: workspace,
3683                line: 12,
3684            }),
3685        );
3686        results
3687    }
3688
3689    #[test]
3690    fn dead_code_keys_cover_graph_boundary_catalog_and_override_variants() {
3691        let root = root();
3692        let results = graph_boundary_catalog_override_results(&root);
3693
3694        let keys = dead_code_keys(&results, &root);
3695
3696        assert!(keys.contains("circular-dependency:src/app.ts|src/other.ts"));
3697        assert!(keys.contains("re-export-cycle:self-loop:src/app.ts"));
3698        assert!(keys.contains("boundary-violation:src/app.ts:src/other.ts:../other"));
3699        assert!(keys.contains("boundary-coverage:src/unmatched.ts"));
3700        assert!(keys.contains("boundary-call:src/app.ts:child_process.exec"));
3701        assert!(
3702            keys.contains("stale-suppression:src/app.ts:// fallow-ignore-next-line unused-export")
3703        );
3704        assert!(keys.contains(
3705            "missing-suppression-reason:src/app.ts:// fallow-ignore-next-line unused-export"
3706        ));
3707        assert!(
3708            keys.contains("unresolved-catalog-reference:packages/app/package.json:9:default:react")
3709        );
3710        assert!(keys.contains("unused-catalog-entry:pnpm-workspace.yaml:3:default:lodash"));
3711        assert!(keys.contains("empty-catalog-group:pnpm-workspace.yaml:7:react17"));
3712        assert!(keys.contains("unused-dependency-override:pnpm-workspace.yaml:11:left-pad"));
3713        assert!(keys.contains("misconfigured-dependency-override:pnpm-workspace.yaml:12:>"));
3714    }
3715
3716    #[test]
3717    fn retain_introduced_dead_code_keeps_only_findings_absent_from_base() {
3718        let root = root();
3719        let mut results = sample_results(&root);
3720        let base = FxHashSet::from_iter([
3721            "unused-file:src/dead.ts".to_string(),
3722            "unused-dependency:package.json:left-pad".to_string(),
3723            "unresolved-import:src/page.ts:./missing".to_string(),
3724        ]);
3725
3726        retain_introduced_dead_code(&mut results, &root, Some(&base));
3727
3728        assert!(results.unused_files.is_empty());
3729        assert!(results.unused_dependencies.is_empty());
3730        assert!(results.unresolved_imports.is_empty());
3731        assert_eq!(results.unused_exports.len(), 1);
3732        assert_eq!(results.unlisted_dependencies.len(), 1);
3733        assert_eq!(results.duplicate_exports.len(), 1);
3734    }
3735
3736    #[test]
3737    fn annotate_dead_code_json_marks_introduced_status_by_matching_key_order() {
3738        let root = root();
3739        let results = sample_results(&root);
3740        let base = FxHashSet::from_iter([
3741            "unused-file:src/dead.ts".to_string(),
3742            "unlisted-dependency:zod:src/page.ts:9:2".to_string(),
3743        ]);
3744        let mut json = json!({
3745            "unused_files": [{}],
3746            "unused_exports": [{}],
3747            "unused_dependencies": [{}],
3748            "unresolved_imports": [{}],
3749            "unlisted_dependencies": [{}],
3750            "duplicate_exports": [{}],
3751        });
3752
3753        annotate_dead_code_json(&mut json, &results, &root, &base);
3754
3755        assert_eq!(json["unused_files"][0]["introduced"], false);
3756        assert_eq!(json["unused_exports"][0]["introduced"], true);
3757        assert_eq!(json["unused_dependencies"][0]["introduced"], true);
3758        assert_eq!(json["unresolved_imports"][0]["introduced"], true);
3759        assert_eq!(json["unlisted_dependencies"][0]["introduced"], false);
3760        assert_eq!(json["duplicate_exports"][0]["introduced"], true);
3761    }
3762
3763    // --- key-building coverage for lines 68-177 (framework-specific key fns) ---
3764
3765    #[test]
3766    fn dead_code_keys_cover_framework_inject_and_render_variants() {
3767        let root = root();
3768        let src = root.join("src/App.vue");
3769        let mut results = AnalysisResults::default();
3770        results
3771            .unprovided_injects
3772            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3773                path: src.clone(),
3774                key_name: "userStore".to_string(),
3775                framework: "vue".to_string(),
3776                line: 5,
3777                col: 0,
3778            }));
3779        results
3780            .unrendered_components
3781            .push(UnrenderedComponentFinding::with_actions(
3782                UnrenderedComponent {
3783                    path: src.clone(),
3784                    component_name: "MyModal".to_string(),
3785                    framework: "vue".to_string(),
3786                    reachable_via: None,
3787                    line: 1,
3788                    col: 0,
3789                },
3790            ));
3791        results
3792            .unused_component_props
3793            .push(UnusedComponentPropFinding::with_actions(
3794                UnusedComponentProp {
3795                    path: src.clone(),
3796                    component_name: "MyModal".to_string(),
3797                    prop_name: "title".to_string(),
3798                    line: 3,
3799                    col: 2,
3800                },
3801            ));
3802        results
3803            .unused_component_emits
3804            .push(UnusedComponentEmitFinding::with_actions(
3805                UnusedComponentEmit {
3806                    path: src,
3807                    component_name: "MyModal".to_string(),
3808                    emit_name: "close".to_string(),
3809                    line: 4,
3810                    col: 2,
3811                },
3812            ));
3813        results
3814            .unused_svelte_events
3815            .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
3816                path: root.join("src/Counter.svelte"),
3817                component_name: "Counter".to_string(),
3818                event_name: "increment".to_string(),
3819                line: 8,
3820                col: 0,
3821            }));
3822
3823        let keys = dead_code_keys(&results, &root);
3824
3825        assert!(keys.contains("unprovided-inject:src/App.vue:userStore"));
3826        assert!(keys.contains("unrendered-component:src/App.vue:MyModal"));
3827        assert!(keys.contains("unused-component-prop:src/App.vue:title"));
3828        assert!(keys.contains("unused-component-emit:src/App.vue:close"));
3829        assert!(keys.contains("unused-svelte-event:src/Counter.svelte:increment"));
3830    }
3831
3832    #[test]
3833    fn dead_code_keys_cover_server_action_load_data_and_route_variants() {
3834        let root = root();
3835        let actions_file = root.join("src/actions/submit.ts");
3836        let page_file = root.join("src/routes/blog/+page.server.ts");
3837        let route_file = root.join("app/(auth)/login/page.tsx");
3838        let route_file2 = root.join("app/login/page.tsx");
3839        let mut results = AnalysisResults::default();
3840        results
3841            .unused_server_actions
3842            .push(UnusedServerActionFinding::with_actions(
3843                UnusedServerAction {
3844                    path: actions_file,
3845                    action_name: "submitForm".to_string(),
3846                    line: 2,
3847                    col: 0,
3848                },
3849            ));
3850        results
3851            .unused_load_data_keys
3852            .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
3853                path: page_file,
3854                key_name: "posts".to_string(),
3855                line: 10,
3856                col: 4,
3857                route_dir: None,
3858            }));
3859        results
3860            .route_collisions
3861            .push(RouteCollisionFinding::with_actions(RouteCollision {
3862                path: route_file.clone(),
3863                url: "/login".to_string(),
3864                conflicting_paths: vec![route_file2.clone()],
3865                line: 1,
3866                col: 0,
3867            }));
3868        results.dynamic_segment_name_conflicts.push(
3869            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
3870                path: route_file,
3871                position: "/shop".to_string(),
3872                conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
3873                conflicting_paths: vec![route_file2],
3874                line: 1,
3875                col: 0,
3876            }),
3877        );
3878
3879        let keys = dead_code_keys(&results, &root);
3880
3881        assert!(keys.contains("unused-server-action:src/actions/submit.ts:submitForm"));
3882        assert!(keys.contains("unused-load-data-key:src/routes/blog/+page.server.ts:posts"));
3883        assert!(keys.contains("route-collision:app/(auth)/login/page.tsx:/login"));
3884        assert!(keys.contains("dynamic-segment-name-conflict:app/(auth)/login/page.tsx:/shop"));
3885    }
3886
3887    #[test]
3888    fn dead_code_keys_cover_angular_input_output_and_policy_variants() {
3889        let root = root();
3890        let component = root.join("src/app/card.component.ts");
3891        let src = root.join("src/utils.ts");
3892        let mut results = AnalysisResults::default();
3893        results
3894            .unused_component_inputs
3895            .push(UnusedComponentInputFinding::with_actions(
3896                UnusedComponentInput {
3897                    path: component.clone(),
3898                    component_name: "CardComponent".to_string(),
3899                    input_name: "label".to_string(),
3900                    line: 12,
3901                    col: 4,
3902                },
3903            ));
3904        results
3905            .unused_component_outputs
3906            .push(UnusedComponentOutputFinding::with_actions(
3907                UnusedComponentOutput {
3908                    path: component,
3909                    component_name: "CardComponent".to_string(),
3910                    output_name: "clicked".to_string(),
3911                    line: 13,
3912                    col: 4,
3913                },
3914            ));
3915        results
3916            .policy_violations
3917            .push(PolicyViolationFinding::with_actions(PolicyViolation {
3918                path: src,
3919                line: 7,
3920                col: 0,
3921                pack: "security".to_string(),
3922                rule_id: "no-eval".to_string(),
3923                kind: PolicyRuleKind::BannedCall,
3924                matched: "eval".to_string(),
3925                severity: PolicyViolationSeverity::Error,
3926                message: None,
3927            }));
3928
3929        let keys = dead_code_keys(&results, &root);
3930
3931        assert!(keys.contains("unused-component-input:src/app/card.component.ts:label"));
3932        assert!(keys.contains("unused-component-output:src/app/card.component.ts:clicked"));
3933        assert!(keys.contains("policy-violation:src/utils.ts:security/no-eval:eval"));
3934    }
3935
3936    #[test]
3937    fn dead_code_keys_cover_re_export_cycle_multi_node_variant() {
3938        let root = root();
3939        let a = root.join("src/a.ts");
3940        let b = root.join("src/b.ts");
3941        let mut results = AnalysisResults::default();
3942        results
3943            .re_export_cycles
3944            .push(ReExportCycleFinding::with_actions(ReExportCycle {
3945                files: vec![b, a],
3946                kind: ReExportCycleKind::MultiNode,
3947            }));
3948
3949        let keys = dead_code_keys(&results, &root);
3950
3951        // multi-node variant hits line 275; files are sorted
3952        assert!(keys.contains("re-export-cycle:multi-node:src/a.ts|src/b.ts"));
3953    }
3954
3955    #[test]
3956    fn dead_code_keys_cover_unused_store_member() {
3957        let root = root();
3958        let src = root.join("src/store.ts");
3959        let mut results = AnalysisResults::default();
3960        results
3961            .unused_store_members
3962            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
3963                path: src,
3964                parent_name: "useAuthStore".to_string(),
3965                member_name: "resetPassword".to_string(),
3966                kind: MemberKind::ClassMethod,
3967                line: 42,
3968                col: 2,
3969            }));
3970
3971        let keys = dead_code_keys(&results, &root);
3972
3973        assert!(keys.contains("unused-store-member:src/store.ts:useAuthStore:resetPassword"));
3974    }
3975
3976    // --- annotate_dead_code_json for framework / component / render / policy arrays ---
3977
3978    #[test]
3979    fn annotate_dead_code_json_marks_framework_keys_correctly() {
3980        let root = root();
3981        let src = root.join("src/App.vue");
3982        let mut results = AnalysisResults::default();
3983        results
3984            .unprovided_injects
3985            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3986                path: src.clone(),
3987                key_name: "theme".to_string(),
3988                framework: "vue".to_string(),
3989                line: 3,
3990                col: 0,
3991            }));
3992        results
3993            .unrendered_components
3994            .push(UnrenderedComponentFinding::with_actions(
3995                UnrenderedComponent {
3996                    path: src.clone(),
3997                    component_name: "Dialog".to_string(),
3998                    framework: "vue".to_string(),
3999                    reachable_via: None,
4000                    line: 1,
4001                    col: 0,
4002                },
4003            ));
4004        results
4005            .unused_component_props
4006            .push(UnusedComponentPropFinding::with_actions(
4007                UnusedComponentProp {
4008                    path: src.clone(),
4009                    component_name: "Dialog".to_string(),
4010                    prop_name: "open".to_string(),
4011                    line: 5,
4012                    col: 2,
4013                },
4014            ));
4015        results
4016            .unused_component_emits
4017            .push(UnusedComponentEmitFinding::with_actions(
4018                UnusedComponentEmit {
4019                    path: src,
4020                    component_name: "Dialog".to_string(),
4021                    emit_name: "dismiss".to_string(),
4022                    line: 6,
4023                    col: 2,
4024                },
4025            ));
4026
4027        // Only the inject is in the base; the rest are new.
4028        let base = FxHashSet::from_iter(["unprovided-inject:src/App.vue:theme".to_string()]);
4029        let mut json_val = json!({
4030            "unprovided_injects": [{}],
4031            "unrendered_components": [{}],
4032            "unused_component_props": [{}],
4033            "unused_component_emits": [{}],
4034        });
4035
4036        annotate_dead_code_json(&mut json_val, &results, &root, &base);
4037
4038        assert_eq!(json_val["unprovided_injects"][0]["introduced"], false);
4039        assert_eq!(json_val["unrendered_components"][0]["introduced"], true);
4040        assert_eq!(json_val["unused_component_props"][0]["introduced"], true);
4041        assert_eq!(json_val["unused_component_emits"][0]["introduced"], true);
4042    }
4043
4044    #[test]
4045    fn annotate_dead_code_json_marks_component_io_and_route_keys_correctly() {
4046        let root = root();
4047        let component = root.join("src/card.component.ts");
4048        let svelte_file = root.join("src/Counter.svelte");
4049        let page_file = root.join("src/routes/+page.server.ts");
4050        let route_file = root.join("app/about/page.tsx");
4051        let route_file2 = root.join("app/(info)/about/page.tsx");
4052        let mut results = AnalysisResults::default();
4053        results
4054            .unused_component_inputs
4055            .push(UnusedComponentInputFinding::with_actions(
4056                UnusedComponentInput {
4057                    path: component.clone(),
4058                    component_name: "CardComponent".to_string(),
4059                    input_name: "size".to_string(),
4060                    line: 8,
4061                    col: 2,
4062                },
4063            ));
4064        results
4065            .unused_component_outputs
4066            .push(UnusedComponentOutputFinding::with_actions(
4067                UnusedComponentOutput {
4068                    path: component,
4069                    component_name: "CardComponent".to_string(),
4070                    output_name: "hovered".to_string(),
4071                    line: 9,
4072                    col: 2,
4073                },
4074            ));
4075        results
4076            .unused_svelte_events
4077            .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
4078                path: svelte_file,
4079                component_name: "Counter".to_string(),
4080                event_name: "reset".to_string(),
4081                line: 12,
4082                col: 0,
4083            }));
4084        results
4085            .unused_server_actions
4086            .push(UnusedServerActionFinding::with_actions(
4087                UnusedServerAction {
4088                    path: page_file,
4089                    action_name: "deletePost".to_string(),
4090                    line: 3,
4091                    col: 0,
4092                },
4093            ));
4094        results
4095            .route_collisions
4096            .push(RouteCollisionFinding::with_actions(RouteCollision {
4097                path: route_file.clone(),
4098                url: "/about".to_string(),
4099                conflicting_paths: vec![route_file2.clone()],
4100                line: 1,
4101                col: 0,
4102            }));
4103        results.dynamic_segment_name_conflicts.push(
4104            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
4105                path: route_file,
4106                position: "/".to_string(),
4107                conflicting_segments: vec!["[id]".to_string()],
4108                conflicting_paths: vec![route_file2],
4109                line: 1,
4110                col: 0,
4111            }),
4112        );
4113
4114        // Nothing is in base: all are introduced.
4115        let base = FxHashSet::default();
4116        let mut json_val = json!({
4117            "unused_component_inputs": [{}],
4118            "unused_component_outputs": [{}],
4119            "unused_svelte_events": [{}],
4120            "unused_server_actions": [{}],
4121            "route_collisions": [{}],
4122            "dynamic_segment_name_conflicts": [{}],
4123        });
4124
4125        annotate_dead_code_json(&mut json_val, &results, &root, &base);
4126
4127        assert_eq!(json_val["unused_component_inputs"][0]["introduced"], true);
4128        assert_eq!(json_val["unused_component_outputs"][0]["introduced"], true);
4129        assert_eq!(json_val["unused_svelte_events"][0]["introduced"], true);
4130        assert_eq!(json_val["unused_server_actions"][0]["introduced"], true);
4131        assert_eq!(json_val["route_collisions"][0]["introduced"], true);
4132        assert_eq!(
4133            json_val["dynamic_segment_name_conflicts"][0]["introduced"],
4134            true
4135        );
4136    }
4137
4138    #[test]
4139    fn annotate_dead_code_json_marks_members_and_dependencies_correctly() {
4140        let root = root();
4141        let src = root.join("src/types.ts");
4142        let pkg = root.join("package.json");
4143        let mut results = AnalysisResults::default();
4144        results
4145            .unused_enum_members
4146            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
4147                path: src.clone(),
4148                parent_name: "Color".to_string(),
4149                member_name: "Blue".to_string(),
4150                kind: MemberKind::EnumMember,
4151                line: 5,
4152                col: 2,
4153            }));
4154        results
4155            .unused_class_members
4156            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
4157                path: src.clone(),
4158                parent_name: "Service".to_string(),
4159                member_name: "reset".to_string(),
4160                kind: MemberKind::ClassMethod,
4161                line: 20,
4162                col: 2,
4163            }));
4164        results
4165            .unused_store_members
4166            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4167                path: src,
4168                parent_name: "useStore".to_string(),
4169                member_name: "logout".to_string(),
4170                kind: MemberKind::ClassMethod,
4171                line: 30,
4172                col: 2,
4173            }));
4174        results
4175            .unused_dev_dependencies
4176            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
4177                package_name: "typescript".to_string(),
4178                location: DependencyLocation::DevDependencies,
4179                path: pkg.clone(),
4180                line: 8,
4181                used_in_workspaces: Vec::new(),
4182            }));
4183        results
4184            .type_only_dependencies
4185            .push(TypeOnlyDependencyFinding::with_actions(
4186                TypeOnlyDependency {
4187                    package_name: "zod".to_string(),
4188                    path: pkg.clone(),
4189                    line: 9,
4190                },
4191            ));
4192        results
4193            .test_only_dependencies
4194            .push(TestOnlyDependencyFinding::with_actions(
4195                TestOnlyDependency {
4196                    package_name: "vitest".to_string(),
4197                    path: pkg,
4198                    line: 10,
4199                },
4200            ));
4201
4202        // Enum member and dev-dep are in base; class member, store member, type-only
4203        // dep, and test-only dep are new.
4204        let base = FxHashSet::from_iter([
4205            "unused-enum-member:src/types.ts:Color:Blue".to_string(),
4206            "unused-dev-dependency:package.json:typescript".to_string(),
4207        ]);
4208        let mut json_val = json!({
4209            "unused_enum_members": [{}],
4210            "unused_class_members": [{}],
4211            "unused_store_members": [{}],
4212            "unused_dev_dependencies": [{}],
4213            "type_only_dependencies": [{}],
4214            "test_only_dependencies": [{}],
4215        });
4216
4217        annotate_dead_code_json(&mut json_val, &results, &root, &base);
4218
4219        assert_eq!(json_val["unused_enum_members"][0]["introduced"], false);
4220        assert_eq!(json_val["unused_class_members"][0]["introduced"], true);
4221        assert_eq!(json_val["unused_store_members"][0]["introduced"], true);
4222        assert_eq!(json_val["unused_dev_dependencies"][0]["introduced"], false);
4223        assert_eq!(json_val["type_only_dependencies"][0]["introduced"], true);
4224        assert_eq!(json_val["test_only_dependencies"][0]["introduced"], true);
4225    }
4226
4227    #[test]
4228    fn annotate_dead_code_json_handles_missing_json_key_gracefully() {
4229        // annotate_issue_array is a no-op when the key is absent; this covers
4230        // the early-return branch inside annotate_issue_array (lines 1477-1479).
4231        let root = root();
4232        let results = sample_results(&root);
4233        let base = FxHashSet::default();
4234        let mut json_val = json!({"other_key": []});
4235
4236        // Must not panic when the expected arrays are absent.
4237        annotate_dead_code_json(&mut json_val, &results, &root, &base);
4238    }
4239
4240    // --- annotate_health_json ---
4241
4242    fn make_violation(path: &Path, name: &str) -> ComplexityViolation {
4243        ComplexityViolation {
4244            path: path.to_path_buf(),
4245            name: name.to_string(),
4246            line: 1,
4247            col: 0,
4248            cyclomatic: 20,
4249            cognitive: 5,
4250            line_count: 30,
4251            param_count: 2,
4252            react_hook_count: 0,
4253            react_jsx_max_depth: 0,
4254            react_prop_count: 0,
4255            react_hook_profile: None,
4256            exceeded: ExceededThreshold::Cyclomatic,
4257            severity: FindingSeverity::High,
4258            crap: None,
4259            coverage_pct: None,
4260            coverage_tier: None,
4261            coverage_source: None,
4262            inherited_from: None,
4263            component_rollup: None,
4264            contributions: Vec::new(),
4265            effective_thresholds: None,
4266            threshold_source: None,
4267        }
4268    }
4269
4270    fn make_health_report(paths_and_names: &[(&Path, &str)]) -> HealthReport {
4271        let findings = paths_and_names
4272            .iter()
4273            .map(|(path, name)| HealthFinding::from(make_violation(path, name)))
4274            .collect();
4275        HealthReport {
4276            findings,
4277            ..HealthReport::default()
4278        }
4279    }
4280
4281    #[test]
4282    fn health_keys_produces_stable_key_per_finding() {
4283        let root = root();
4284        let path = root.join("src/heavy.ts");
4285        let report = make_health_report(&[(&path, "processAll")]);
4286        let keys = health_keys(&report, &root);
4287        assert!(keys.contains("complexity:src/heavy.ts:processAll:Cyclomatic"));
4288    }
4289
4290    #[test]
4291    fn health_finding_key_uses_path_name_and_exceeded() {
4292        let root = root();
4293        let path = root.join("src/heavy.ts");
4294        let violation = make_violation(&path, "render");
4295        let key = health_finding_key(&violation, &root);
4296        assert_eq!(key, "complexity:src/heavy.ts:render:Cyclomatic");
4297    }
4298
4299    #[test]
4300    fn annotate_health_json_marks_introduced_and_inherited_flags() {
4301        let root = root();
4302        let path_a = root.join("src/heavy.ts");
4303        let path_b = root.join("src/other.ts");
4304        let report = make_health_report(&[(&path_a, "doWork"), (&path_b, "render")]);
4305
4306        // Only path_b:render is in the base.
4307        let base = FxHashSet::from_iter(["complexity:src/other.ts:render:Cyclomatic".to_string()]);
4308        let mut json_val = json!({
4309            "findings": [{}, {}],
4310        });
4311
4312        annotate_health_json(&mut json_val, &report, &root, &base);
4313
4314        assert_eq!(json_val["findings"][0]["introduced"], true);
4315        assert_eq!(json_val["findings"][1]["introduced"], false);
4316    }
4317
4318    #[test]
4319    fn annotate_health_json_is_noop_when_findings_key_absent() {
4320        let root = root();
4321        let report = make_health_report(&[]);
4322        let base = FxHashSet::default();
4323        let mut json_val = json!({"summary": {}});
4324        // Must not panic.
4325        annotate_health_json(&mut json_val, &report, &root, &base);
4326    }
4327
4328    // --- annotate_dupes_json and dupe_group_key ---
4329
4330    fn make_clone_group(files: &[PathBuf], fragment: &str) -> CloneGroup {
4331        CloneGroup {
4332            instances: files
4333                .iter()
4334                .map(|f| CloneInstance {
4335                    file: f.clone(),
4336                    start_line: 1,
4337                    end_line: 5,
4338                    start_col: 0,
4339                    end_col: 80,
4340                    fragment: fragment.to_string(),
4341                })
4342                .collect(),
4343            token_count: 10,
4344            line_count: 5,
4345            similarity: None,
4346        }
4347    }
4348
4349    fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
4350        DuplicationReport {
4351            clone_groups: groups,
4352            clone_families: Vec::new(),
4353            mirrored_directories: Vec::new(),
4354            stats: fallow_types::duplicates::DuplicationStats::default(),
4355        }
4356    }
4357
4358    #[test]
4359    fn dupe_group_key_is_stable_for_sorted_deduplicated_files() {
4360        let root = root();
4361        let a = root.join("src/a.ts");
4362        let b = root.join("src/b.ts");
4363        // Build two groups with same fragment but different file order.
4364        let group_ab = make_clone_group(&[a.clone(), b.clone()], "const x = 1;");
4365        let group_ba = make_clone_group(&[b, a], "const x = 1;");
4366        let key_ab = dupe_group_key(&group_ab, &root);
4367        let key_ba = dupe_group_key(&group_ba, &root);
4368        // File list is sorted so both keys share the same file prefix.
4369        assert!(key_ab.starts_with("dupe:src/a.ts|src/b.ts:"));
4370        assert!(key_ba.starts_with("dupe:src/a.ts|src/b.ts:"));
4371        // Same fragment => same hash.
4372        assert_eq!(key_ab, key_ba);
4373    }
4374
4375    #[test]
4376    fn preexisting_dupe_group_keys_collects_only_groups_outside_added_lines() {
4377        let root = root();
4378        let a = root.join("src/a.ts");
4379        let b = root.join("src/b.ts");
4380        let c = root.join("src/c.ts");
4381        // Instances span lines 1..=5. The diff adds line 3 in src/c.ts only.
4382        let untouched = make_clone_group(&[a.clone(), b], "shared scaffolding");
4383        let touched = make_clone_group(&[a, c], "pasted block");
4384        let diff = fallow_output::DiffIndex::from_unified_diff(
4385            "diff --git a/src/c.ts b/src/c.ts\n--- a/src/c.ts\n+++ b/src/c.ts\n@@ -3,0 +3,1 @@\n+const added = 1;\n",
4386        );
4387
4388        let demote = super::preexisting_dupe_group_keys([&untouched, &touched], &root, &diff);
4389
4390        assert!(demote.contains(&dupe_group_key(&untouched, &root)));
4391        assert!(!demote.contains(&dupe_group_key(&touched, &root)));
4392        assert_eq!(demote.len(), 1);
4393    }
4394
4395    #[test]
4396    fn demote_introductions_moves_introduced_keys_to_inherited() {
4397        let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
4398        let mut ledger = AuditDomainLedger::compare(
4399            ["dupe:kept".to_string(), "dupe:demoted".to_string()],
4400            Some(&base),
4401        );
4402        assert_eq!(ledger.introduced_count(), 2);
4403
4404        let demote: FxHashSet<String> = std::iter::once("dupe:demoted".to_string()).collect();
4405        ledger.demote_introductions(&demote);
4406
4407        assert_eq!(ledger.introduced_count(), 1);
4408        assert_eq!(ledger.inherited_count(), 1);
4409        let introduced: Vec<bool> = ledger.introduced().collect();
4410        assert_eq!(introduced, vec![true, false]);
4411    }
4412
4413    #[test]
4414    fn dupes_keys_produces_one_key_per_clone_group() {
4415        let root = root();
4416        let a = root.join("src/a.ts");
4417        let b = root.join("src/b.ts");
4418        let groups = vec![
4419            make_clone_group(&[a.clone(), b.clone()], "block one"),
4420            make_clone_group(&[a, b], "block two"),
4421        ];
4422        let report = make_duplication_report(groups);
4423        let keys = dupes_keys(&report, &root);
4424        assert_eq!(keys.len(), 2);
4425    }
4426
4427    #[test]
4428    fn annotate_dupes_json_marks_introduced_and_inherited_flags() {
4429        let root = root();
4430        let a = root.join("src/a.ts");
4431        let b = root.join("src/b.ts");
4432        let group_new = make_clone_group(&[a.clone(), b.clone()], "new block");
4433        let group_old = make_clone_group(&[a, b], "old block");
4434        let old_key = dupe_group_key(&group_old, &root);
4435        let base = FxHashSet::from_iter([old_key]);
4436        let report = make_duplication_report(vec![group_new, group_old]);
4437        let mut json_val = json!({
4438            "clone_groups": [{}, {}],
4439        });
4440
4441        annotate_dupes_json(&mut json_val, &report, &root, &base);
4442
4443        assert_eq!(json_val["clone_groups"][0]["introduced"], true);
4444        assert_eq!(json_val["clone_groups"][1]["introduced"], false);
4445    }
4446
4447    #[test]
4448    fn annotate_dupes_json_is_noop_when_clone_groups_key_absent() {
4449        let root = root();
4450        let report = make_duplication_report(Vec::new());
4451        let base = FxHashSet::default();
4452        let mut json_val = json!({"stats": {}});
4453        // Must not panic.
4454        annotate_dupes_json(&mut json_val, &report, &root, &base);
4455    }
4456
4457    // --- retain_introduced_dead_code with None base (no-op path, line 1127) ---
4458
4459    #[test]
4460    fn retain_introduced_dead_code_is_noop_when_base_is_none() {
4461        let root = root();
4462        let mut results = sample_results(&root);
4463        let original_file_count = results.unused_files.len();
4464        let original_export_count = results.unused_exports.len();
4465
4466        retain_introduced_dead_code(&mut results, &root, None);
4467
4468        // Nothing should be filtered when base is None.
4469        assert_eq!(results.unused_files.len(), original_file_count);
4470        assert_eq!(results.unused_exports.len(), original_export_count);
4471    }
4472
4473    // --- retain_introduced_dead_code covers framework / component / graph findings ---
4474
4475    #[test]
4476    fn retain_introduced_dead_code_filters_framework_findings() {
4477        let root = root();
4478        let src = root.join("src/App.vue");
4479        let mut results = AnalysisResults::default();
4480        results
4481            .unprovided_injects
4482            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
4483                path: src.clone(),
4484                key_name: "existing".to_string(),
4485                framework: "vue".to_string(),
4486                line: 1,
4487                col: 0,
4488            }));
4489        results
4490            .unprovided_injects
4491            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
4492                path: src.clone(),
4493                key_name: "new".to_string(),
4494                framework: "vue".to_string(),
4495                line: 2,
4496                col: 0,
4497            }));
4498        results
4499            .unrendered_components
4500            .push(UnrenderedComponentFinding::with_actions(
4501                UnrenderedComponent {
4502                    path: src,
4503                    component_name: "OldWidget".to_string(),
4504                    framework: "vue".to_string(),
4505                    reachable_via: None,
4506                    line: 1,
4507                    col: 0,
4508                },
4509            ));
4510
4511        let base = FxHashSet::from_iter([
4512            "unprovided-inject:src/App.vue:existing".to_string(),
4513            "unrendered-component:src/App.vue:OldWidget".to_string(),
4514        ]);
4515
4516        retain_introduced_dead_code(&mut results, &root, Some(&base));
4517
4518        // Only "new" inject survives; OldWidget is filtered.
4519        assert_eq!(results.unprovided_injects.len(), 1);
4520        assert_eq!(results.unprovided_injects[0].inject.key_name, "new");
4521        assert!(results.unrendered_components.is_empty());
4522    }
4523
4524    #[test]
4525    fn retain_introduced_dead_code_filters_graph_findings() {
4526        let root = root();
4527        let a = root.join("src/a.ts");
4528        let b = root.join("src/b.ts");
4529        let mut results = AnalysisResults::default();
4530        results
4531            .circular_dependencies
4532            .push(CircularDependencyFinding::with_actions(
4533                CircularDependency {
4534                    files: vec![a.clone(), b],
4535                    length: 2,
4536                    line: 1,
4537                    col: 0,
4538                    edges: Vec::new(),
4539                    is_cross_package: false,
4540                },
4541            ));
4542        results
4543            .re_export_cycles
4544            .push(ReExportCycleFinding::with_actions(ReExportCycle {
4545                files: vec![a],
4546                kind: ReExportCycleKind::SelfLoop,
4547            }));
4548
4549        // The circular dep is in the base; the re-export cycle is new.
4550        let base = FxHashSet::from_iter(["circular-dependency:src/a.ts|src/b.ts".to_string()]);
4551
4552        retain_introduced_dead_code(&mut results, &root, Some(&base));
4553
4554        assert!(results.circular_dependencies.is_empty());
4555        assert_eq!(results.re_export_cycles.len(), 1);
4556    }
4557
4558    #[test]
4559    fn audit_ledger_routes_override_severity_and_persists_introduced_flags() {
4560        let root = root();
4561        let config: FallowConfig = serde_json::from_value(json!({
4562            "rules": { "unused-exports": "warn" },
4563            "overrides": [{
4564                "files": ["src/generated/**"],
4565                "rules": { "unused-exports": "error" }
4566            }]
4567        }))
4568        .expect("config");
4569        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4570        let mut results = AnalysisResults::default();
4571        results
4572            .unused_exports
4573            .push(export(&root.join("src/base.ts"), "baseExport"));
4574        results
4575            .unused_exports
4576            .push(export(&root.join("src/generated/new.ts"), "newExport"));
4577        let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
4578
4579        let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
4580
4581        assert_eq!(ledger.classification_count(), 2);
4582        assert_eq!(ledger.introduced_count(), 1);
4583        assert_eq!(ledger.inherited_count(), 1);
4584        assert!(ledger.has_introduced_errors());
4585        assert!(!ledger.has_introduced_warnings());
4586        assert_eq!(ledger.records()[0].effective_severity, Severity::Warn);
4587        assert_eq!(ledger.records()[1].effective_severity, Severity::Error);
4588
4589        ledger.annotate_results(&mut results);
4590        assert_eq!(ledger.classification_count(), 2);
4591        assert_eq!(
4592            results.unused_exports[0].introduced,
4593            Some(AuditIntroduced(false))
4594        );
4595        assert_eq!(
4596            results.unused_exports[1].introduced,
4597            Some(AuditIntroduced(true))
4598        );
4599    }
4600
4601    #[test]
4602    fn audit_ledger_demotes_semantic_only_introductions_but_keeps_syntactic_new_findings() {
4603        let root = root();
4604        let config: FallowConfig = serde_json::from_value(json!({
4605            "rules": { "unused-exports": "error" }
4606        }))
4607        .expect("config");
4608        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4609        let mut results = AnalysisResults::default();
4610        // A pre-existing finding, a genuinely new syntactic finding, and a
4611        // semantic-only finding with no syntactic counterpart on head.
4612        results
4613            .unused_exports
4614            .push(export(&root.join("src/base.ts"), "baseExport"));
4615        results
4616            .unused_exports
4617            .push(export(&root.join("src/new.ts"), "newExport"));
4618        results
4619            .unused_exports
4620            .push(export(&root.join("src/semantic.ts"), "semanticOnly"));
4621        let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
4622        let head_syntactic = FxHashSet::from_iter([
4623            "unused-export:src/base.ts:baseExport".to_string(),
4624            "unused-export:src/new.ts:newExport".to_string(),
4625        ]);
4626
4627        let mut ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
4628        assert_eq!(ledger.introduced_count(), 2);
4629
4630        ledger.demote_unattributable_introductions(&head_syntactic);
4631
4632        // Control: the genuinely new syntactic finding still gates.
4633        assert_eq!(ledger.introduced_count(), 1);
4634        assert!(ledger.has_introduced_errors());
4635        assert_eq!(ledger.inherited_count(), 2);
4636        ledger.annotate_results(&mut results);
4637        assert_eq!(
4638            results.unused_exports[0].introduced,
4639            Some(AuditIntroduced(false))
4640        );
4641        assert_eq!(
4642            results.unused_exports[1].introduced,
4643            Some(AuditIntroduced(true))
4644        );
4645        assert_eq!(
4646            results.unused_exports[2].introduced,
4647            Some(AuditIntroduced(false))
4648        );
4649    }
4650
4651    #[test]
4652    fn audit_ledger_counts_colliding_dead_code_keys_once_but_annotates_each_record() {
4653        let root = root();
4654        let config: FallowConfig = serde_json::from_value(json!({
4655            "rules": { "unused-exports": "error" }
4656        }))
4657        .expect("config");
4658        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4659        let mut results = AnalysisResults::default();
4660        results
4661            .unused_exports
4662            .push(export(&root.join("src/collision.ts"), "sameExport"));
4663        results
4664            .unused_exports
4665            .push(export(&root.join("src/collision.ts"), "sameExport"));
4666
4667        let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&FxHashSet::default()));
4668
4669        assert_eq!(ledger.classification_count(), 2);
4670        assert_eq!(ledger.records().len(), 2);
4671        assert_eq!(ledger.introduced_count(), 1);
4672        assert_eq!(ledger.inherited_count(), 0);
4673
4674        ledger.annotate_results(&mut results);
4675        assert!(
4676            results
4677                .unused_exports
4678                .iter()
4679                .all(|finding| { finding.introduced == Some(AuditIntroduced(true)) })
4680        );
4681    }
4682
4683    #[test]
4684    fn audit_domain_ledger_counts_colliding_keys_once_but_preserves_record_membership() {
4685        let introduced = AuditDomainLedger::compare(
4686            ["same-key".to_string(), "same-key".to_string()],
4687            Some(&FxHashSet::default()),
4688        );
4689        assert_eq!(introduced.introduced_count(), 1);
4690        assert_eq!(introduced.inherited_count(), 0);
4691        assert_eq!(introduced.introduced().collect::<Vec<_>>(), [true, true]);
4692
4693        let base = FxHashSet::from_iter(["same-key".to_string()]);
4694        let inherited = AuditDomainLedger::compare(
4695            ["same-key".to_string(), "same-key".to_string()],
4696            Some(&base),
4697        );
4698        assert_eq!(inherited.introduced_count(), 0);
4699        assert_eq!(inherited.inherited_count(), 1);
4700        assert_eq!(inherited.introduced().collect::<Vec<_>>(), [false, false]);
4701    }
4702
4703    #[test]
4704    fn stale_suppression_fallback_preserves_introduced_annotation() {
4705        let root = root();
4706        let mut results = AnalysisResults::default();
4707        results.stale_suppressions.push(StaleSuppression {
4708            path: root.join("src/new.ts"),
4709            line: 2,
4710            col: 0,
4711            origin: SuppressionOrigin::Comment {
4712                issue_kind: Some("unused-export".to_string()),
4713                reason: None,
4714                is_file_level: false,
4715                kind_known: true,
4716            },
4717            missing_reason: false,
4718            actions: StaleSuppression::actions_for(false),
4719        });
4720        let mut json = serde_json::to_value(&results).expect("json");
4721
4722        annotate_stale_suppressions_json(&mut json, &results, &root, &FxHashSet::default());
4723
4724        assert_eq!(json["stale_suppressions"][0]["introduced"], true);
4725    }
4726
4727    #[test]
4728    fn remap_keys_for_renames_relocates_path_segments() {
4729        use rustc_hash::FxHashMap;
4730
4731        let mut renames = FxHashMap::default();
4732        renames.insert(
4733            "src/old/impl.ts".to_string(),
4734            "src/renamed/impl.ts".to_string(),
4735        );
4736
4737        let keys: FxHashSet<String> = [
4738            "complexity:src/old/impl.ts:complexFn:[Cyclomatic]".to_string(),
4739            "unused-export:src/old/impl.ts:unusedHelper".to_string(),
4740            "unused-file:src/old/impl.ts".to_string(),
4741            "unused-export:src/other.ts:src/old/impl.ts-unrelated".to_string(),
4742        ]
4743        .into_iter()
4744        .collect();
4745
4746        let remapped = remap_keys_for_renames(&keys, &renames);
4747
4748        assert!(remapped.contains("complexity:src/renamed/impl.ts:complexFn:[Cyclomatic]"));
4749        assert!(remapped.contains("unused-export:src/renamed/impl.ts:unusedHelper"));
4750        assert!(remapped.contains("unused-file:src/renamed/impl.ts"));
4751        assert!(
4752            remapped.contains("unused-export:src/other.ts:src/old/impl.ts-unrelated"),
4753            "non-exact segment matches must pass through untouched"
4754        );
4755    }
4756
4757    #[test]
4758    fn remap_keys_for_renames_recanonicalizes_sorted_path_lists() {
4759        use rustc_hash::FxHashMap;
4760
4761        let mut renames = FxHashMap::default();
4762        renames.insert("src/a.ts".to_string(), "src/z.ts".to_string());
4763
4764        let keys: FxHashSet<String> = [
4765            // The base list was sorted as [src/a.ts, src/m.ts]; after the
4766            // rename the head list sorts as [src/m.ts, src/z.ts].
4767            "dupe:src/a.ts|src/m.ts:120:12:deadbeef".to_string(),
4768            "circular-dependency:src/a.ts|src/m.ts".to_string(),
4769        ]
4770        .into_iter()
4771        .collect();
4772
4773        let remapped = remap_keys_for_renames(&keys, &renames);
4774
4775        assert!(
4776            remapped.contains("dupe:src/m.ts|src/z.ts:120:12:deadbeef"),
4777            "duplicate-group file lists must be re-sorted: {remapped:#?}"
4778        );
4779        assert!(
4780            remapped.contains("circular-dependency:src/m.ts|src/z.ts"),
4781            "cycle file lists must be re-sorted: {remapped:#?}"
4782        );
4783    }
4784
4785    #[test]
4786    fn remap_keys_for_renames_without_matches_is_identity() {
4787        use rustc_hash::FxHashMap;
4788
4789        let mut renames = FxHashMap::default();
4790        renames.insert("src/elsewhere.ts".to_string(), "src/moved.ts".to_string());
4791
4792        let keys: FxHashSet<String> =
4793            std::iter::once("unused-export:src/utils.ts:helper".to_string()).collect();
4794
4795        assert_eq!(remap_keys_for_renames(&keys, &renames), keys);
4796    }
4797}