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