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