Skip to main content

fallow_api/
audit_keys.rs

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