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