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