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