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