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