Skip to main content

fallow_api/
audit_keys.rs

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