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