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