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            crap: None,
3239            mutation: None,
3240            coverage_pct: None,
3241            coverage_tier: None,
3242            coverage_source: None,
3243            inherited_from: None,
3244            component_rollup: None,
3245            contributions: Vec::new(),
3246            effective_thresholds: None,
3247            threshold_source: None,
3248        }
3249    }
3250
3251    fn make_health_report(paths_and_names: &[(&Path, &str)]) -> HealthReport {
3252        let findings = paths_and_names
3253            .iter()
3254            .map(|(path, name)| HealthFinding::from(make_violation(path, name)))
3255            .collect();
3256        HealthReport {
3257            findings,
3258            ..HealthReport::default()
3259        }
3260    }
3261
3262    #[test]
3263    fn health_keys_produces_stable_key_per_finding() {
3264        let root = root();
3265        let path = root.join("src/heavy.ts");
3266        let report = make_health_report(&[(&path, "processAll")]);
3267        let keys = health_keys(&report, &root);
3268        assert!(keys.contains("complexity:src/heavy.ts:processAll"));
3269    }
3270
3271    #[test]
3272    fn health_finding_key_uses_path_and_name() {
3273        let root = root();
3274        let path = root.join("src/heavy.ts");
3275        let violation = make_violation(&path, "render");
3276        let key = health_finding_key(&violation, &root);
3277        assert_eq!(key, "complexity:src/heavy.ts:render");
3278    }
3279
3280    #[test]
3281    fn annotate_health_json_marks_introduced_and_inherited_flags() {
3282        let root = root();
3283        let path_a = root.join("src/heavy.ts");
3284        let path_b = root.join("src/other.ts");
3285        let report = make_health_report(&[(&path_a, "doWork"), (&path_b, "render")]);
3286
3287        // Only path_b:render is in the base.
3288        let base = FxHashSet::from_iter(["complexity:src/other.ts:render".to_string()]);
3289        let mut json_val = json!({
3290            "findings": [{}, {}],
3291        });
3292
3293        annotate_health_json(&mut json_val, &report, &root, &base);
3294
3295        assert_eq!(json_val["findings"][0]["introduced"], true);
3296        assert_eq!(json_val["findings"][1]["introduced"], false);
3297    }
3298
3299    #[test]
3300    fn annotate_health_json_keeps_the_typed_complexity_flag() {
3301        let root = root();
3302        let path = root.join("src/other.ts");
3303        let mut report = make_health_report(&[(&path, "render")]);
3304        // The metric comparison found a worse value for an inherited key.
3305        report.findings[0].introduced = Some(true);
3306        let base = FxHashSet::from_iter(["complexity:src/other.ts:render".to_string()]);
3307        let mut json_val = json!({ "findings": [{}] });
3308
3309        annotate_health_json(&mut json_val, &report, &root, &base);
3310
3311        assert_eq!(json_val["findings"][0]["introduced"], true);
3312    }
3313
3314    #[test]
3315    fn annotate_health_json_is_noop_when_findings_key_absent() {
3316        let root = root();
3317        let report = make_health_report(&[]);
3318        let base = FxHashSet::default();
3319        let mut json_val = json!({"summary": {}});
3320        let original = json_val.clone();
3321
3322        annotate_health_json(&mut json_val, &report, &root, &base);
3323
3324        assert_eq!(json_val, original);
3325    }
3326
3327    // --- annotate_dupes_json and dupe_group_key ---
3328
3329    fn make_clone_group(files: &[PathBuf], fragment: &str) -> CloneGroup {
3330        CloneGroup {
3331            instances: files
3332                .iter()
3333                .map(|f| CloneInstance {
3334                    is_symlink: false,
3335                    file: f.clone(),
3336                    start_line: 1,
3337                    end_line: 5,
3338                    start_col: 0,
3339                    end_col: 80,
3340                    fragment: fragment.to_string(),
3341                })
3342                .collect(),
3343            token_count: 10,
3344            line_count: 5,
3345            similarity: None,
3346        }
3347    }
3348
3349    fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3350        DuplicationReport {
3351            clone_groups: groups,
3352            clone_families: Vec::new(),
3353            mirrored_directories: Vec::new(),
3354            stats: fallow_types::duplicates::DuplicationStats::default(),
3355        }
3356    }
3357
3358    #[test]
3359    fn dupe_group_key_is_stable_for_sorted_deduplicated_files() {
3360        let root = root();
3361        let a = root.join("src/a.ts");
3362        let b = root.join("src/b.ts");
3363        // Build two groups with same fragment but different file order.
3364        let group_ab = make_clone_group(&[a.clone(), b.clone()], "const x = 1;");
3365        let group_ba = make_clone_group(&[b, a], "const x = 1;");
3366        let key_ab = dupe_group_key(&group_ab, &root);
3367        let key_ba = dupe_group_key(&group_ba, &root);
3368        // File list is sorted so both keys share the same file prefix.
3369        assert!(key_ab.starts_with("dupe:src/a.ts|src/b.ts:"));
3370        assert!(key_ba.starts_with("dupe:src/a.ts|src/b.ts:"));
3371        // Same fragment => same hash.
3372        assert_eq!(key_ab, key_ba);
3373    }
3374
3375    #[test]
3376    fn preexisting_dupe_group_keys_collects_only_groups_outside_added_lines() {
3377        let root = root();
3378        let a = root.join("src/a.ts");
3379        let b = root.join("src/b.ts");
3380        let c = root.join("src/c.ts");
3381        // Instances span lines 1..=5. The diff adds line 3 in src/c.ts only.
3382        let untouched = make_clone_group(&[a.clone(), b], "shared scaffolding");
3383        let touched = make_clone_group(&[a, c], "pasted block");
3384        let diff = fallow_output::DiffIndex::from_unified_diff(
3385            "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",
3386        );
3387
3388        let demote = super::preexisting_dupe_group_keys([&untouched, &touched], &root, &diff);
3389
3390        assert!(demote.contains(&dupe_group_key(&untouched, &root)));
3391        assert!(!demote.contains(&dupe_group_key(&touched, &root)));
3392        assert_eq!(demote.len(), 1);
3393    }
3394
3395    #[test]
3396    fn demote_introductions_moves_introduced_keys_to_inherited() {
3397        let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
3398        let mut ledger = AuditDomainLedger::compare(
3399            ["dupe:kept".to_string(), "dupe:demoted".to_string()],
3400            Some(&base),
3401        );
3402        assert_eq!(ledger.introduced_count(), 2);
3403
3404        let demote: FxHashSet<String> = std::iter::once("dupe:demoted".to_string()).collect();
3405        ledger.demote_introductions(&demote);
3406
3407        assert_eq!(ledger.introduced_count(), 1);
3408        assert_eq!(ledger.inherited_count(), 1);
3409        let introduced: Vec<bool> = ledger.introduced().collect();
3410        assert_eq!(introduced, vec![true, false]);
3411        assert_eq!(ledger.demoted_count(), 1);
3412        assert!(ledger.demoted_count() <= ledger.inherited_count());
3413        assert!(ledger.demoted_keys().contains("dupe:demoted"));
3414        let demoted: Vec<bool> = ledger.demoted().collect();
3415        assert_eq!(demoted, vec![false, true]);
3416    }
3417
3418    #[test]
3419    fn demote_introductions_ignores_keys_that_were_not_introduced() {
3420        let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
3421        let mut ledger = AuditDomainLedger::compare(
3422            ["dupe:old".to_string(), "dupe:new".to_string()],
3423            Some(&base),
3424        );
3425
3426        let demote: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
3427        ledger.demote_introductions(&demote);
3428
3429        assert_eq!(ledger.demoted_count(), 0);
3430        let demoted: Vec<bool> = ledger.demoted().collect();
3431        assert_eq!(demoted, vec![false, false]);
3432    }
3433
3434    #[test]
3435    fn annotate_domain_demotions_json_marks_only_demoted_entries() {
3436        let mut json_val = json!({
3437            "clone_groups": [{}, {}],
3438        });
3439
3440        super::annotate_domain_demotions_json(
3441            &mut json_val,
3442            "clone_groups",
3443            [false, true],
3444            crate::CloneDemotionReason::NoAddedLines,
3445        );
3446
3447        assert!(json_val["clone_groups"][0].get("demotion_reason").is_none());
3448        assert_eq!(
3449            json_val["clone_groups"][1]["demotion_reason"],
3450            "no-added-lines"
3451        );
3452    }
3453
3454    #[test]
3455    fn dupes_keys_produces_one_key_per_clone_group() {
3456        let root = root();
3457        let a = root.join("src/a.ts");
3458        let b = root.join("src/b.ts");
3459        let groups = vec![
3460            make_clone_group(&[a.clone(), b.clone()], "block one"),
3461            make_clone_group(&[a, b], "block two"),
3462        ];
3463        let report = make_duplication_report(groups);
3464        let keys = dupes_keys(&report, &root);
3465        assert_eq!(keys.len(), 2);
3466    }
3467
3468    #[test]
3469    fn annotate_dupes_json_marks_introduced_and_inherited_flags() {
3470        let root = root();
3471        let a = root.join("src/a.ts");
3472        let b = root.join("src/b.ts");
3473        let group_new = make_clone_group(&[a.clone(), b.clone()], "new block");
3474        let group_old = make_clone_group(&[a, b], "old block");
3475        let old_key = dupe_group_key(&group_old, &root);
3476        let base = FxHashSet::from_iter([old_key]);
3477        let report = make_duplication_report(vec![group_new, group_old]);
3478        let mut json_val = json!({
3479            "clone_groups": [{}, {}],
3480        });
3481
3482        annotate_dupes_json(&mut json_val, &report, &root, &base);
3483
3484        assert_eq!(json_val["clone_groups"][0]["introduced"], true);
3485        assert_eq!(json_val["clone_groups"][1]["introduced"], false);
3486    }
3487
3488    #[test]
3489    fn annotate_dupes_json_is_noop_when_clone_groups_key_absent() {
3490        let root = root();
3491        let report = make_duplication_report(Vec::new());
3492        let base = FxHashSet::default();
3493        let mut json_val = json!({"stats": {}});
3494        let original = json_val.clone();
3495
3496        annotate_dupes_json(&mut json_val, &report, &root, &base);
3497
3498        assert_eq!(json_val, original);
3499    }
3500
3501    // --- retain_introduced_dead_code with None base (no-op path, line 1127) ---
3502
3503    #[test]
3504    fn retain_introduced_dead_code_is_noop_when_base_is_none() {
3505        let root = root();
3506        let mut results = sample_results(&root);
3507        let original_file_count = results.unused_files.len();
3508        let original_export_count = results.unused_exports.len();
3509
3510        retain_introduced_dead_code(&mut results, &root, None);
3511
3512        // Nothing should be filtered when base is None.
3513        assert_eq!(results.unused_files.len(), original_file_count);
3514        assert_eq!(results.unused_exports.len(), original_export_count);
3515    }
3516
3517    // --- retain_introduced_dead_code covers framework / component / graph findings ---
3518
3519    #[test]
3520    fn retain_introduced_dead_code_filters_framework_findings() {
3521        let root = root();
3522        let src = root.join("src/App.vue");
3523        let mut results = AnalysisResults::default();
3524        results
3525            .unprovided_injects
3526            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3527                path: src.clone(),
3528                key_name: "existing".to_string(),
3529                framework: "vue".to_string(),
3530                line: 1,
3531                col: 0,
3532            }));
3533        results
3534            .unprovided_injects
3535            .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3536                path: src.clone(),
3537                key_name: "new".to_string(),
3538                framework: "vue".to_string(),
3539                line: 2,
3540                col: 0,
3541            }));
3542        results
3543            .unrendered_components
3544            .push(UnrenderedComponentFinding::with_actions(
3545                UnrenderedComponent {
3546                    path: src,
3547                    component_name: "OldWidget".to_string(),
3548                    framework: "vue".to_string(),
3549                    reachable_via: None,
3550                    line: 1,
3551                    col: 0,
3552                },
3553            ));
3554
3555        let base = FxHashSet::from_iter([
3556            "unprovided-inject:src/App.vue:existing".to_string(),
3557            "unrendered-component:src/App.vue:OldWidget".to_string(),
3558        ]);
3559
3560        retain_introduced_dead_code(&mut results, &root, Some(&base));
3561
3562        // Only "new" inject survives; OldWidget is filtered.
3563        assert_eq!(results.unprovided_injects.len(), 1);
3564        assert_eq!(results.unprovided_injects[0].inject.key_name, "new");
3565        assert!(results.unrendered_components.is_empty());
3566    }
3567
3568    #[test]
3569    fn retain_introduced_dead_code_filters_graph_findings() {
3570        let root = root();
3571        let a = root.join("src/a.ts");
3572        let b = root.join("src/b.ts");
3573        let mut results = AnalysisResults::default();
3574        results
3575            .circular_dependencies
3576            .push(CircularDependencyFinding::with_actions(
3577                CircularDependency {
3578                    files: vec![a.clone(), b],
3579                    length: 2,
3580                    line: 1,
3581                    col: 0,
3582                    edges: Vec::new(),
3583                    is_cross_package: false,
3584                },
3585            ));
3586        results
3587            .re_export_cycles
3588            .push(ReExportCycleFinding::with_actions(ReExportCycle {
3589                files: vec![a],
3590                kind: ReExportCycleKind::SelfLoop,
3591            }));
3592
3593        // The circular dep is in the base; the re-export cycle is new.
3594        let base = FxHashSet::from_iter(["circular-dependency:src/a.ts|src/b.ts".to_string()]);
3595
3596        retain_introduced_dead_code(&mut results, &root, Some(&base));
3597
3598        assert!(results.circular_dependencies.is_empty());
3599        assert_eq!(results.re_export_cycles.len(), 1);
3600    }
3601
3602    #[test]
3603    fn audit_ledger_routes_override_severity_and_persists_introduced_flags() {
3604        let root = root();
3605        let config: FallowConfig = serde_json::from_value(json!({
3606            "rules": { "unused-exports": "warn" },
3607            "overrides": [{
3608                "files": ["src/generated/**"],
3609                "rules": { "unused-exports": "error" }
3610            }]
3611        }))
3612        .expect("config");
3613        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3614        let mut results = AnalysisResults::default();
3615        results
3616            .unused_exports
3617            .push(export(&root.join("src/base.ts"), "baseExport"));
3618        results
3619            .unused_exports
3620            .push(export(&root.join("src/generated/new.ts"), "newExport"));
3621        let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
3622
3623        let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
3624
3625        assert_eq!(ledger.classification_count(), 2);
3626        assert_eq!(ledger.introduced_count(), 1);
3627        assert_eq!(ledger.inherited_count(), 1);
3628        assert!(ledger.has_introduced_errors());
3629        assert!(!ledger.has_introduced_warnings());
3630        assert_eq!(ledger.records()[0].effective_severity, Severity::Warn);
3631        assert_eq!(ledger.records()[1].effective_severity, Severity::Error);
3632
3633        ledger.annotate_results(&mut results);
3634        assert_eq!(ledger.classification_count(), 2);
3635        assert_eq!(
3636            results.unused_exports[0].introduced,
3637            Some(AuditIntroduced(false))
3638        );
3639        assert_eq!(
3640            results.unused_exports[1].introduced,
3641            Some(AuditIntroduced(true))
3642        );
3643    }
3644
3645    #[test]
3646    fn audit_ledger_demotes_semantic_only_introductions_but_keeps_syntactic_new_findings() {
3647        let root = root();
3648        let config: FallowConfig = serde_json::from_value(json!({
3649            "rules": { "unused-exports": "error" }
3650        }))
3651        .expect("config");
3652        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3653        let mut results = AnalysisResults::default();
3654        // A pre-existing finding, a genuinely new syntactic finding, and a
3655        // semantic-only finding with no syntactic counterpart on head.
3656        results
3657            .unused_exports
3658            .push(export(&root.join("src/base.ts"), "baseExport"));
3659        results
3660            .unused_exports
3661            .push(export(&root.join("src/new.ts"), "newExport"));
3662        results
3663            .unused_exports
3664            .push(export(&root.join("src/semantic.ts"), "semanticOnly"));
3665        let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
3666        let head_syntactic = FxHashSet::from_iter([
3667            "unused-export:src/base.ts:baseExport".to_string(),
3668            "unused-export:src/new.ts:newExport".to_string(),
3669        ]);
3670
3671        let mut ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
3672        assert_eq!(ledger.introduced_count(), 2);
3673
3674        ledger.demote_unattributable_introductions(&head_syntactic);
3675
3676        // Control: the genuinely new syntactic finding still gates.
3677        assert_eq!(ledger.introduced_count(), 1);
3678        assert!(ledger.has_introduced_errors());
3679        assert_eq!(ledger.inherited_count(), 2);
3680        ledger.annotate_results(&mut results);
3681        assert_eq!(
3682            results.unused_exports[0].introduced,
3683            Some(AuditIntroduced(false))
3684        );
3685        assert_eq!(
3686            results.unused_exports[1].introduced,
3687            Some(AuditIntroduced(true))
3688        );
3689        assert_eq!(
3690            results.unused_exports[2].introduced,
3691            Some(AuditIntroduced(false))
3692        );
3693    }
3694
3695    #[test]
3696    fn audit_ledger_counts_each_occurrence_of_a_repeated_key() {
3697        let root = root();
3698        let config: FallowConfig = serde_json::from_value(json!({
3699            "rules": { "unused-exports": "error" }
3700        }))
3701        .expect("config");
3702        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3703        let mut results = AnalysisResults::default();
3704        results
3705            .unused_exports
3706            .push(export(&root.join("src/collision.ts"), "sameExport"));
3707        results
3708            .unused_exports
3709            .push(export(&root.join("src/collision.ts"), "sameExport"));
3710
3711        let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&FxHashSet::default()));
3712
3713        assert_eq!(ledger.classification_count(), 2);
3714        assert_eq!(ledger.records().len(), 2);
3715        assert_eq!(ledger.introduced_count(), 2);
3716        assert_eq!(ledger.inherited_count(), 0);
3717
3718        ledger.annotate_results(&mut results);
3719        assert!(
3720            results
3721                .unused_exports
3722                .iter()
3723                .all(|finding| { finding.introduced == Some(AuditIntroduced(true)) })
3724        );
3725    }
3726
3727    #[test]
3728    fn audit_domain_ledger_counts_colliding_keys_once_but_preserves_record_membership() {
3729        let introduced = AuditDomainLedger::compare(
3730            ["same-key".to_string(), "same-key".to_string()],
3731            Some(&FxHashSet::default()),
3732        );
3733        assert_eq!(introduced.introduced_count(), 1);
3734        assert_eq!(introduced.inherited_count(), 0);
3735        assert_eq!(introduced.introduced().collect::<Vec<_>>(), [true, true]);
3736
3737        let base = FxHashSet::from_iter(["same-key".to_string()]);
3738        let inherited = AuditDomainLedger::compare(
3739            ["same-key".to_string(), "same-key".to_string()],
3740            Some(&base),
3741        );
3742        assert_eq!(inherited.introduced_count(), 0);
3743        assert_eq!(inherited.inherited_count(), 1);
3744        assert_eq!(inherited.introduced().collect::<Vec<_>>(), [false, false]);
3745    }
3746
3747    #[test]
3748    fn stale_suppression_fallback_preserves_introduced_annotation() {
3749        let root = root();
3750        let mut results = AnalysisResults::default();
3751        results.stale_suppressions.push(StaleSuppression {
3752            finding_id: None,
3753            path: root.join("src/new.ts"),
3754            line: 2,
3755            col: 0,
3756            origin: SuppressionOrigin::Comment {
3757                issue_kind: Some("unused-export".to_string()),
3758                reason: None,
3759                is_file_level: false,
3760                kind_known: true,
3761            },
3762            missing_reason: false,
3763            actions: StaleSuppression::actions_for(false),
3764            effective_severity: None,
3765        });
3766        let mut json = serde_json::to_value(&results).expect("json");
3767
3768        annotate_stale_suppressions_json(&mut json, &results, &root, &FxHashSet::default());
3769
3770        assert_eq!(json["stale_suppressions"][0]["introduced"], true);
3771    }
3772
3773    #[test]
3774    fn remap_keys_for_renames_relocates_path_segments() {
3775        use rustc_hash::FxHashMap;
3776
3777        let mut renames = FxHashMap::default();
3778        renames.insert(
3779            "src/old/impl.ts".to_string(),
3780            "src/renamed/impl.ts".to_string(),
3781        );
3782
3783        let keys: FxHashSet<String> = [
3784            "complexity:src/old/impl.ts:complexFn:[Cyclomatic]".to_string(),
3785            "unused-export:src/old/impl.ts:unusedHelper".to_string(),
3786            "unused-file:src/old/impl.ts".to_string(),
3787            "unused-export:src/other.ts:src/old/impl.ts-unrelated".to_string(),
3788        ]
3789        .into_iter()
3790        .collect();
3791
3792        let remapped = remap_keys_for_renames(&keys, &renames);
3793
3794        assert!(remapped.contains("complexity:src/renamed/impl.ts:complexFn:[Cyclomatic]"));
3795        assert!(remapped.contains("unused-export:src/renamed/impl.ts:unusedHelper"));
3796        assert!(remapped.contains("unused-file:src/renamed/impl.ts"));
3797        assert!(
3798            remapped.contains("unused-export:src/other.ts:src/old/impl.ts-unrelated"),
3799            "non-exact segment matches must pass through untouched"
3800        );
3801    }
3802
3803    #[test]
3804    fn remap_keys_for_renames_recanonicalizes_sorted_path_lists() {
3805        use rustc_hash::FxHashMap;
3806
3807        let mut renames = FxHashMap::default();
3808        renames.insert("src/a.ts".to_string(), "src/z.ts".to_string());
3809
3810        let keys: FxHashSet<String> = [
3811            // The base list was sorted as [src/a.ts, src/m.ts]; after the
3812            // rename the head list sorts as [src/m.ts, src/z.ts].
3813            "dupe:src/a.ts|src/m.ts:120:12:deadbeef".to_string(),
3814            "circular-dependency:src/a.ts|src/m.ts".to_string(),
3815        ]
3816        .into_iter()
3817        .collect();
3818
3819        let remapped = remap_keys_for_renames(&keys, &renames);
3820
3821        assert!(
3822            remapped.contains("dupe:src/m.ts|src/z.ts:120:12:deadbeef"),
3823            "duplicate-group file lists must be re-sorted: {remapped:#?}"
3824        );
3825        assert!(
3826            remapped.contains("circular-dependency:src/m.ts|src/z.ts"),
3827            "cycle file lists must be re-sorted: {remapped:#?}"
3828        );
3829    }
3830
3831    #[test]
3832    fn remap_keys_for_renames_without_matches_is_identity() {
3833        use rustc_hash::FxHashMap;
3834
3835        let mut renames = FxHashMap::default();
3836        renames.insert("src/elsewhere.ts".to_string(), "src/moved.ts".to_string());
3837
3838        let keys: FxHashSet<String> =
3839            std::iter::once("unused-export:src/utils.ts:helper".to_string()).collect();
3840
3841        assert_eq!(remap_keys_for_renames(&keys, &renames), keys);
3842    }
3843
3844    /// One finding in each collection that no other fixture fills.
3845    fn production_and_directive_results(root: &Path) -> AnalysisResults {
3846        let page = root.join("src/app.ts");
3847        let mut results = AnalysisResults::default();
3848        results.dev_dependencies_in_production.push(
3849            DevDependencyInProductionFinding::with_actions(DevDependencyInProduction {
3850                package_name: "vite".to_string(),
3851                path: root.join("package.json"),
3852                line: 14,
3853            }),
3854        );
3855        results
3856            .invalid_client_exports
3857            .push(InvalidClientExportFinding::with_actions(
3858                InvalidClientExport {
3859                    path: page.clone(),
3860                    export_name: "config".to_string(),
3861                    directive: "use client".to_string(),
3862                    line: 3,
3863                    col: 0,
3864                },
3865            ));
3866        results
3867            .mixed_client_server_barrels
3868            .push(MixedClientServerBarrelFinding::with_actions(
3869                MixedClientServerBarrel {
3870                    path: page.clone(),
3871                    client_origin: "./client".to_string(),
3872                    server_origin: "./server".to_string(),
3873                    line: 1,
3874                    col: 0,
3875                },
3876            ));
3877        results
3878            .misplaced_directives
3879            .push(MisplacedDirectiveFinding::with_actions(
3880                MisplacedDirective {
3881                    path: page,
3882                    directive: "use server".to_string(),
3883                    line: 5,
3884                    col: 2,
3885                },
3886            ));
3887        results
3888    }
3889
3890    /// Every finding of the fixtures, each as its own result set, so a
3891    /// disagreement names one collection.
3892    fn isolated_findings(fixtures: &[AnalysisResults]) -> Vec<(String, AnalysisResults)> {
3893        let mut isolated = Vec::new();
3894        for fixture in fixtures {
3895            let value = serde_json::to_value(fixture).expect("results serialize");
3896            let object = value.as_object().expect("results serialize as an object");
3897            for (collection, items) in object {
3898                let Some(items) = items.as_array() else {
3899                    continue;
3900                };
3901                for item in items {
3902                    let mut single = serde_json::to_value(AnalysisResults::default())
3903                        .expect("empty results serialize");
3904                    single[collection.as_str()] = serde_json::Value::Array(vec![item.clone()]);
3905                    let results = serde_json::from_value(single)
3906                        .unwrap_or_else(|error| panic!("{collection} round-trips: {error}"));
3907                    isolated.push((collection.clone(), results));
3908                }
3909            }
3910        }
3911        isolated
3912    }
3913
3914    fn absent_component_prop_results(root: &Path) -> AnalysisResults {
3915        let mut results = AnalysisResults::default();
3916        results
3917            .absent_component_props
3918            .push(AbsentComponentPropFinding::with_actions(
3919                AbsentComponentProp {
3920                    path: root.join("src/App.vue"),
3921                    component_name: "MyModal".into(),
3922                    framework: "vue".into(),
3923                    prop_name: "highlight".into(),
3924                    line: 3,
3925                    col: 2,
3926                    has_default: true,
3927                    inspected_call_sites: vec![ComponentPropCallSite {
3928                        path: root.join("src/page.ts"),
3929                        line: 8,
3930                        col: 0,
3931                    }],
3932                    explanation: "Review known callers and API intent.".into(),
3933                },
3934            ));
3935        results
3936    }
3937
3938    /// A config that sets every rule by `base` and, for the files of the
3939    /// fixtures that an `overrides` entry matches, by `scoped`. Each pair
3940    /// gives the severity of the rules at an even and at an odd position, so
3941    /// a finding that reads the rule of another kind shows as a difference.
3942    fn every_rule_config(
3943        base: [&str; 2],
3944        scoped: Option<[&str; 2]>,
3945    ) -> fallow_config::ResolvedConfig {
3946        let every_rule = |severities: [&str; 2]| {
3947            let rules = serde_json::to_value(fallow_config::RulesConfig::default())
3948                .expect("rules serialize");
3949            let names = rules
3950                .as_object()
3951                .expect("rules serialize as an object")
3952                .keys();
3953            serde_json::Value::Object(
3954                names
3955                    .enumerate()
3956                    .map(|(index, name)| (name.clone(), json!(severities[index % 2])))
3957                    .collect(),
3958            )
3959        };
3960        let mut config = json!({ "rules": every_rule(base) });
3961        if let Some(scoped) = scoped {
3962            config["overrides"] = json!([{
3963                "files": [
3964                    "package.json",
3965                    "pnpm-workspace.yaml",
3966                    "src/app.ts",
3967                    "src/page.ts",
3968                    "src/types.ts",
3969                    "src/App.vue",
3970                    "src/actions/**",
3971                    "app/**",
3972                    "src/app/**",
3973                    "src/store.ts"
3974                ],
3975                "rules": every_rule(scoped)
3976            }]);
3977        }
3978        let config: FallowConfig = serde_json::from_value(config).expect("config");
3979        config.resolve(root(), OutputFormat::Json, 1, false, true, None)
3980    }
3981
3982    /// The audit ledger and the exit-code rule resolve severity in two
3983    /// places. With every finding introduced, the ledger must report an
3984    /// error exactly when `has_error_severity_issues` does, for every
3985    /// collection, with and without `overrides`.
3986    #[test]
3987    fn audit_ledger_errors_match_the_exit_code_rule_for_every_collection() {
3988        let root = root();
3989        // Two arrays keep each stack array under the large-array lint limit.
3990        let fixtures: Vec<AnalysisResults> = [
3991            sample_results(&root),
3992            graph_boundary_catalog_override_results(&root),
3993            type_member_and_dependency_results(&root),
3994            framework_inject_and_render_results(&root),
3995            absent_component_prop_results(&root),
3996        ]
3997        .into_iter()
3998        .chain([
3999            server_action_load_data_and_route_results(&root),
4000            angular_input_output_and_policy_results(&root),
4001            unused_store_member_results(&root),
4002            production_and_directive_results(&root),
4003            AnalysisResults {
4004                prop_drilling_chains: vec![PropDrillingChainFinding::with_actions(
4005                    PropDrillingChain {
4006                        prop: "user".to_owned(),
4007                        depth: 3,
4008                        hops: vec![PropDrillHop {
4009                            file: root.join("src/page.ts"),
4010                            line: 4,
4011                            component: "Page".to_owned(),
4012                        }],
4013                    },
4014                )],
4015                ..effect_results(&root)
4016            },
4017        ])
4018        .collect();
4019        let findings = isolated_findings(&fixtures);
4020        let empty_base = FxHashSet::default();
4021        let mut covered = std::collections::BTreeSet::new();
4022        let mut mismatches = Vec::new();
4023        for (base, scoped) in [
4024            (["error", "error"], None),
4025            (["warn", "warn"], None),
4026            (["error", "warn"], None),
4027            (["error", "error"], Some(["warn", "warn"])),
4028            (["warn", "warn"], Some(["error", "error"])),
4029            (["off", "off"], Some(["error", "error"])),
4030            (["error", "warn"], Some(["warn", "error"])),
4031            (["warn", "error"], Some(["error", "warn"])),
4032        ] {
4033            let config = every_rule_config(base, scoped);
4034            for (collection, results) in &findings {
4035                let ledger = dead_code_audit_ledger(results, &root, &config, Some(&empty_base));
4036                covered.extend(ledger.records().iter().map(|record| record.collection));
4037                let exit_rule = fallow_engine::error_severity::has_error_severity_issues(
4038                    results,
4039                    &config.rules,
4040                    Some(&config),
4041                    false,
4042                );
4043                if ledger.has_introduced_errors() != exit_rule {
4044                    mismatches.push(format!(
4045                        "{collection} (rules {base:?}, overrides {scoped:?}): ledger {}, exit rule {exit_rule}",
4046                        ledger.has_introduced_errors()
4047                    ));
4048                }
4049            }
4050        }
4051        assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
4052        let missing: Vec<&str> = super::AuditCollection::ALL
4053            .iter()
4054            .map(|collection| collection.json_key())
4055            .filter(|key| !covered.contains(key))
4056            .collect();
4057        assert!(
4058            missing.is_empty(),
4059            "the fixtures hold no finding in {missing:?}"
4060        );
4061    }
4062
4063    // Line-free, count-aware audit keys.
4064
4065    fn audit_suppression(root: &Path, line: u32, reason: Option<&str>) -> StaleSuppression {
4066        StaleSuppression {
4067            finding_id: None,
4068            path: root.join("src/flags.ts"),
4069            line,
4070            col: 0,
4071            origin: SuppressionOrigin::Comment {
4072                issue_kind: Some("unused-export".to_string()),
4073                reason: reason.map(str::to_owned),
4074                is_file_level: false,
4075                kind_known: true,
4076            },
4077            missing_reason: false,
4078            actions: StaleSuppression::actions_for(false),
4079            effective_severity: None,
4080        }
4081    }
4082
4083    /// Add `by` to the line of every finding whose old audit key held a line.
4084    fn shift_lines(results: &mut AnalysisResults, by: u32) {
4085        for item in &mut results.unlisted_dependencies {
4086            for site in &mut item.dep.imported_from {
4087                site.line += by;
4088            }
4089        }
4090        for item in &mut results.stale_suppressions {
4091            item.line += by;
4092        }
4093        for item in &mut results.unused_catalog_entries {
4094            item.entry.line += by;
4095        }
4096        for item in &mut results.empty_catalog_groups {
4097            item.group.line += by;
4098        }
4099        for item in &mut results.unresolved_catalog_references {
4100            item.reference.line += by;
4101        }
4102        for item in &mut results.unused_dependency_overrides {
4103            item.entry.line += by;
4104        }
4105        for item in &mut results.misconfigured_dependency_overrides {
4106            item.entry.line += by;
4107        }
4108        for item in &mut results.misplaced_directives {
4109            item.directive_site.line += by;
4110        }
4111        for item in &mut results.unused_exports {
4112            item.export.line += by;
4113        }
4114    }
4115
4116    fn line_sensitive_results(root: &Path) -> AnalysisResults {
4117        let mut results = graph_boundary_catalog_override_results(root);
4118        let sample = sample_results(root);
4119        results.unlisted_dependencies = sample.unlisted_dependencies;
4120        results.unused_exports = sample.unused_exports;
4121        results
4122            .misplaced_directives
4123            .push(MisplacedDirectiveFinding::with_actions(
4124                MisplacedDirective {
4125                    path: root.join("src/action.ts"),
4126                    directive: "use server".to_string(),
4127                    line: 4,
4128                    col: 0,
4129                },
4130            ));
4131        results
4132    }
4133
4134    #[test]
4135    fn dead_code_keys_do_not_change_when_lines_shift() {
4136        let root = root();
4137        let before = line_sensitive_results(&root);
4138        let mut after = before.clone();
4139        shift_lines(&mut after, 20);
4140
4141        let before_keys = dead_code_keys(&before, &root);
4142        let after_keys = dead_code_keys(&after, &root);
4143
4144        let mut changed: Vec<&String> = after_keys.difference(&before_keys).collect();
4145        changed.sort();
4146        assert!(changed.is_empty(), "keys that hold a line: {changed:?}");
4147    }
4148
4149    #[test]
4150    fn stale_suppression_keys_ignore_the_reason_text() {
4151        let root = root();
4152        let with_reason = |reason: &str| AnalysisResults {
4153            stale_suppressions: vec![audit_suppression(&root, 3, Some(reason))],
4154            ..AnalysisResults::default()
4155        };
4156
4157        assert_eq!(
4158            dead_code_keys(&with_reason("kept for the plugin API"), &root),
4159            dead_code_keys(&with_reason("removed in v3"), &root)
4160        );
4161    }
4162
4163    #[test]
4164    fn unlisted_dependency_keys_ignore_the_import_sites() {
4165        let root = root();
4166        let base = sample_results(&root);
4167        let mut head = base.clone();
4168        let item = &mut head.unlisted_dependencies[0].dep;
4169        item.imported_from[0].line = 40;
4170        item.imported_from.push(ImportSite {
4171            path: root.join("src/other.ts"),
4172            line: 1,
4173            col: 0,
4174        });
4175
4176        assert_eq!(dead_code_keys(&base, &root), dead_code_keys(&head, &root));
4177    }
4178
4179    #[test]
4180    fn a_second_occurrence_of_an_inherited_key_is_introduced() {
4181        let root = root();
4182        let config: FallowConfig = serde_json::from_value(json!({
4183            "rules": { "stale-suppressions": "error" }
4184        }))
4185        .expect("config");
4186        let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4187        let base_results = AnalysisResults {
4188            stale_suppressions: vec![audit_suppression(&root, 3, None)],
4189            ..AnalysisResults::default()
4190        };
4191        let base = dead_code_keys(&base_results, &root);
4192        let mut head = AnalysisResults {
4193            stale_suppressions: vec![
4194                audit_suppression(&root, 13, None),
4195                audit_suppression(&root, 17, None),
4196            ],
4197            ..AnalysisResults::default()
4198        };
4199
4200        let ledger = dead_code_audit_ledger(&head, &root, &config, Some(&base));
4201        assert_eq!(ledger.introduced_count(), 1);
4202        assert_eq!(ledger.inherited_count(), 1);
4203        assert!(!ledger.records()[0].introduced);
4204        assert!(ledger.records()[1].introduced);
4205
4206        retain_introduced_dead_code(&mut head, &root, Some(&base));
4207        assert_eq!(head.stale_suppressions.len(), 1);
4208        assert_eq!(head.stale_suppressions[0].line, 17);
4209    }
4210
4211    #[test]
4212    fn a_renamed_file_keeps_its_findings_and_occurrences_inherited() {
4213        let root = root();
4214        let finding = |path: &str, line: u32| {
4215            let mut suppression = audit_suppression(&root, line, None);
4216            suppression.path = root.join(path);
4217            suppression
4218        };
4219        let base_results = AnalysisResults {
4220            stale_suppressions: vec![finding("src/old.ts", 3), finding("src/old.ts", 9)],
4221            ..AnalysisResults::default()
4222        };
4223        let head = AnalysisResults {
4224            stale_suppressions: vec![finding("src/new.ts", 4), finding("src/new.ts", 10)],
4225            ..AnalysisResults::default()
4226        };
4227        let renames = rustc_hash::FxHashMap::from_iter([(
4228            "src/old.ts".to_string(),
4229            "src/new.ts".to_string(),
4230        )]);
4231
4232        let base = remap_keys_for_renames(&dead_code_keys(&base_results, &root), &renames);
4233
4234        assert_eq!(base, dead_code_keys(&head, &root));
4235    }
4236}