Skip to main content

fallow_engine/
baseline.rs

1use rustc_hash::{FxHashMap, FxHashSet};
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use crate::duplicates::DuplicationReport;
6
7/// Strip the project root from a path to produce a portable relative key.
8///
9/// Both `path` and `root` must be in the same form (both canonicalized or both
10/// not) for `strip_prefix` to succeed. The analysis pipeline keeps all paths
11/// non-canonicalized, so this invariant holds in practice.
12fn relative_path(path: &Path, root: &Path) -> String {
13    match path.strip_prefix(root) {
14        Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
15        Err(_) => {
16            tracing::debug!(
17                path = %path.display(),
18                root = %root.display(),
19                "baseline key: path is not under project root, using absolute path as key"
20            );
21            path.to_string_lossy().replace('\\', "/")
22        }
23    }
24}
25
26fn package_json_dependency_key(package_name: &str, path: &Path, root: &Path) -> String {
27    format!("{}:{package_name}", relative_path(path, root))
28}
29
30fn baseline_contains_dependency(
31    baseline_keys: &FxHashSet<&str>,
32    package_name: &str,
33    path_key: &str,
34) -> bool {
35    baseline_keys.contains(path_key) || baseline_keys.contains(package_name)
36}
37
38fn retain_new_by_keys<T>(
39    items: &mut Vec<T>,
40    baseline_keys: &[String],
41    root: &Path,
42    key_builder: fn(&[T], &Path) -> Vec<String>,
43) {
44    let baseline_keys: FxHashSet<&str> = baseline_keys.iter().map(String::as_str).collect();
45    let item_keys = key_builder(items, root);
46    let mut key_iter = item_keys.into_iter();
47    items.retain(|_| match key_iter.next() {
48        Some(key) => !baseline_keys.contains(key.as_str()),
49        None => true,
50    });
51}
52
53/// Baseline data for comparison.
54#[derive(serde::Serialize, serde::Deserialize)]
55pub struct BaselineData {
56    /// Compatibility identity for the analysis that produced this baseline.
57    /// Legacy baselines deserialize as syntactic and are never silently
58    /// compared with type-aware output.
59    #[serde(default)]
60    analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
61    unused_files: Vec<String>,
62    unused_exports: Vec<String>,
63    unused_types: Vec<String>,
64    #[serde(default)]
65    private_type_leaks: Vec<String>,
66    /// Unused dependencies, keyed by `package.json:package_name`. Legacy
67    /// bare `package_name` keys are still matched for back-compat with
68    /// baselines saved by older fallow versions.
69    unused_dependencies: Vec<String>,
70    /// Unused dev dependencies, keyed by `package.json:package_name`. Legacy
71    /// bare `package_name` keys are still matched for back-compat with
72    /// baselines saved by older fallow versions.
73    unused_dev_dependencies: Vec<String>,
74    /// Circular dependency chains, keyed by sorted file paths joined with `->`.
75    #[serde(default)]
76    circular_dependencies: Vec<String>,
77    /// Re-export cycles, keyed by `kind:sorted_file_paths_joined_with_<->`
78    /// (where `kind` is `multi-node` or `self-loop`). The kind prefix keeps
79    /// self-loops from keyspace-colliding with future single-file multi-node
80    /// shapes.
81    #[serde(default)]
82    re_export_cycles: Vec<String>,
83    /// Unused optional dependencies, keyed by `package.json:package_name`.
84    /// Legacy bare `package_name` keys are still matched for back-compat
85    /// with baselines saved by older fallow versions.
86    #[serde(default)]
87    unused_optional_dependencies: Vec<String>,
88    /// Unused enum members, keyed by `file:parent.member`.
89    #[serde(default)]
90    unused_enum_members: Vec<String>,
91    /// Unused class members, keyed by `file:parent.member`.
92    #[serde(default)]
93    unused_class_members: Vec<String>,
94    /// Unused store members, keyed by `file:parent.member`.
95    #[serde(default)]
96    unused_store_members: Vec<String>,
97    /// Unprovided injects, keyed by `file:key_name`.
98    #[serde(default)]
99    unprovided_injects: Vec<String>,
100    /// Unrendered components, keyed by `file:component_name`.
101    #[serde(default)]
102    unrendered_components: Vec<String>,
103    /// Unused component props, keyed by `file:prop_name`.
104    #[serde(default)]
105    unused_component_props: Vec<String>,
106    /// Unused component emits, keyed by `file:emit_name`.
107    #[serde(default)]
108    unused_component_emits: Vec<String>,
109    /// Unused component inputs, keyed by `file:input_name`.
110    #[serde(default)]
111    unused_component_inputs: Vec<String>,
112    /// Unused component outputs, keyed by `file:output_name`.
113    #[serde(default)]
114    unused_component_outputs: Vec<String>,
115    /// Unused Svelte dispatched events, keyed by `file:event_name`.
116    #[serde(default)]
117    unused_svelte_events: Vec<String>,
118    /// Unused server actions, keyed by `file:action_name`.
119    #[serde(default)]
120    unused_server_actions: Vec<String>,
121    /// Unused SvelteKit load() data keys, keyed by `file:key_name`.
122    #[serde(default)]
123    unused_load_data_keys: Vec<String>,
124    /// Unresolved imports, keyed by `file:specifier`.
125    #[serde(default)]
126    unresolved_imports: Vec<String>,
127    /// Unlisted dependencies, keyed by package name.
128    #[serde(default)]
129    unlisted_dependencies: Vec<String>,
130    /// Duplicate exports, keyed by export name.
131    #[serde(default)]
132    duplicate_exports: Vec<String>,
133    /// Type-only dependencies, keyed by `package.json:package_name`. Legacy
134    /// bare `package_name` keys are still matched for back-compat with
135    /// baselines saved by older fallow versions.
136    #[serde(default)]
137    type_only_dependencies: Vec<String>,
138    /// Test-only dependencies, keyed by `package.json:package_name`. Legacy
139    /// bare `package_name` keys are still matched for back-compat with
140    /// baselines saved by older fallow versions.
141    #[serde(default)]
142    test_only_dependencies: Vec<String>,
143    /// Dev dependencies used in production, keyed by `package.json:package_name`.
144    #[serde(default)]
145    dev_dependencies_in_production: Vec<String>,
146    /// Boundary violations, keyed by `from_path->to_path`.
147    #[serde(default)]
148    boundary_violations: Vec<String>,
149    /// Boundary coverage violations, keyed by `path`.
150    #[serde(default)]
151    boundary_coverage_violations: Vec<String>,
152    /// Boundary call violations, keyed by `path:callee`.
153    #[serde(default)]
154    boundary_call_violations: Vec<String>,
155    /// Rule-pack policy violations, keyed by `path:pack/rule_id:matched`.
156    #[serde(default)]
157    policy_violations: Vec<String>,
158    /// Stale suppressions, keyed by `file:line`.
159    #[serde(default)]
160    stale_suppressions: Vec<String>,
161    /// Unused pnpm catalog entries, keyed by `catalog_name:entry_name`.
162    #[serde(default)]
163    unused_catalog_entries: Vec<String>,
164    /// Empty pnpm catalog groups, keyed by `catalog_name`.
165    #[serde(default)]
166    empty_catalog_groups: Vec<String>,
167    /// Unresolved catalog references, keyed by `path:line:catalog_name:entry_name`.
168    #[serde(default)]
169    unresolved_catalog_references: Vec<String>,
170    /// Unused pnpm dependency overrides, keyed by `source:raw_key`.
171    #[serde(default)]
172    unused_dependency_overrides: Vec<String>,
173    /// Misconfigured pnpm dependency overrides, keyed by `source:raw_key`.
174    #[serde(default)]
175    misconfigured_dependency_overrides: Vec<String>,
176    /// Invalid `"use client"` exports, keyed by `path:export_name`.
177    #[serde(default)]
178    invalid_client_exports: Vec<String>,
179    /// Mixed client/server barrels, keyed by `path:client_origin:server_origin`.
180    #[serde(default)]
181    mixed_client_server_barrels: Vec<String>,
182    /// Misplaced `"use client"` / `"use server"` directives, keyed by
183    /// `path:line:directive`.
184    #[serde(default)]
185    misplaced_directives: Vec<String>,
186    /// Next.js route collisions, keyed by `path:url`.
187    #[serde(default)]
188    route_collisions: Vec<String>,
189    /// Next.js dynamic-segment name conflicts, keyed by `path:position`.
190    #[serde(default)]
191    dynamic_segment_name_conflicts: Vec<String>,
192}
193
194impl BaselineData {
195    pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
196        Self::from_results_with_identity(
197            results,
198            root,
199            fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
200        )
201    }
202
203    pub fn from_results_with_identity(
204        results: &crate::results::AnalysisResults,
205        root: &Path,
206        analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
207    ) -> Self {
208        let file_exports = baseline_file_export_keys(results, root);
209        let member_imports = baseline_member_import_keys(results, root);
210        let dependencies = baseline_dependency_keys(results, root);
211        let graph = baseline_graph_keys(results, root);
212        let catalog = baseline_catalog_keys(results, root);
213
214        Self {
215            analysis_identity,
216            unused_files: file_exports.unused_files,
217            unused_exports: file_exports.unused_exports,
218            unused_types: file_exports.unused_types,
219            private_type_leaks: file_exports.private_type_leaks,
220            unused_dependencies: dependencies.unused,
221            unused_dev_dependencies: dependencies.unused_dev,
222            circular_dependencies: graph.circular_dependencies,
223            re_export_cycles: graph.re_export_cycles,
224            unused_optional_dependencies: dependencies.unused_optional,
225            unused_enum_members: member_imports.unused_enum_members,
226            unused_class_members: member_imports.unused_class_members,
227            unused_store_members: member_imports.unused_store_members,
228            unprovided_injects: member_imports.unprovided_injects,
229            unrendered_components: member_imports.unrendered_components,
230            unused_component_props: member_imports.unused_component_props,
231            unused_component_emits: member_imports.unused_component_emits,
232            unused_component_inputs: member_imports.unused_component_inputs,
233            unused_component_outputs: member_imports.unused_component_outputs,
234            unused_svelte_events: member_imports.unused_svelte_events,
235            unused_server_actions: member_imports.unused_server_actions,
236            unused_load_data_keys: member_imports.unused_load_data_keys,
237            unresolved_imports: member_imports.unresolved_imports,
238            unlisted_dependencies: dependencies.unlisted,
239            duplicate_exports: member_imports.duplicate_exports,
240            type_only_dependencies: dependencies.type_only,
241            test_only_dependencies: dependencies.test_only,
242            dev_dependencies_in_production: dependencies.dev_in_prod,
243            boundary_violations: graph.boundary_violations,
244            boundary_coverage_violations: graph.boundary_coverage_violations,
245            boundary_call_violations: graph.boundary_call_violations,
246            policy_violations: graph.policy_violations,
247            stale_suppressions: member_imports.stale_suppressions,
248            unused_catalog_entries: catalog.unused_catalog_entries,
249            empty_catalog_groups: catalog.empty_catalog_groups,
250            unresolved_catalog_references: catalog.unresolved_catalog_references,
251            unused_dependency_overrides: catalog.unused_dependency_overrides,
252            misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
253            invalid_client_exports: file_exports.invalid_client_exports,
254            mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
255            misplaced_directives: file_exports.misplaced_directives,
256            route_collisions: file_exports.route_collisions,
257            dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
258        }
259    }
260
261    #[must_use]
262    pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
263        &self.analysis_identity
264    }
265
266    /// Total number of entries across all categories.
267    pub fn total_entries(&self) -> usize {
268        self.unused_files.len()
269            + self.unused_exports.len()
270            + self.unused_types.len()
271            + self.private_type_leaks.len()
272            + self.unused_dependencies.len()
273            + self.unused_dev_dependencies.len()
274            + self.circular_dependencies.len()
275            + self.re_export_cycles.len()
276            + self.unused_optional_dependencies.len()
277            + self.unused_enum_members.len()
278            + self.unused_class_members.len()
279            + self.unused_store_members.len()
280            + self.unprovided_injects.len()
281            + self.unrendered_components.len()
282            + self.unused_component_props.len()
283            + self.unused_component_emits.len()
284            + self.unused_component_inputs.len()
285            + self.unused_component_outputs.len()
286            + self.unused_svelte_events.len()
287            + self.unused_server_actions.len()
288            + self.unused_load_data_keys.len()
289            + self.unresolved_imports.len()
290            + self.unlisted_dependencies.len()
291            + self.duplicate_exports.len()
292            + self.type_only_dependencies.len()
293            + self.test_only_dependencies.len()
294            + self.dev_dependencies_in_production.len()
295            + self.boundary_violations.len()
296            + self.boundary_coverage_violations.len()
297            + self.boundary_call_violations.len()
298            + self.policy_violations.len()
299            + self.stale_suppressions.len()
300            + self.unused_catalog_entries.len()
301            + self.empty_catalog_groups.len()
302            + self.unresolved_catalog_references.len()
303            + self.unused_dependency_overrides.len()
304            + self.misconfigured_dependency_overrides.len()
305            + self.invalid_client_exports.len()
306            + self.mixed_client_server_barrels.len()
307            + self.misplaced_directives.len()
308            + self.route_collisions.len()
309            + self.dynamic_segment_name_conflicts.len()
310    }
311}
312
313struct BaselineFileExportKeys {
314    unused_files: Vec<String>,
315    unused_exports: Vec<String>,
316    unused_types: Vec<String>,
317    private_type_leaks: Vec<String>,
318    invalid_client_exports: Vec<String>,
319    mixed_client_server_barrels: Vec<String>,
320    misplaced_directives: Vec<String>,
321    route_collisions: Vec<String>,
322    dynamic_segment_name_conflicts: Vec<String>,
323}
324
325fn baseline_file_export_keys(
326    results: &crate::results::AnalysisResults,
327    root: &Path,
328) -> BaselineFileExportKeys {
329    BaselineFileExportKeys {
330        unused_files: results
331            .unused_files
332            .iter()
333            .map(|f| relative_path(&f.file.path, root))
334            .collect(),
335        unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
336        unused_types: unused_type_baseline_keys(&results.unused_types, root),
337        private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
338        invalid_client_exports: invalid_client_export_baseline_keys(
339            &results.invalid_client_exports,
340            root,
341        ),
342        mixed_client_server_barrels: barrel_baseline_keys(
343            &results.mixed_client_server_barrels,
344            root,
345        ),
346        misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
347        route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
348        dynamic_segment_name_conflicts: results
349            .dynamic_segment_name_conflicts
350            .iter()
351            .map(|c| {
352                format!(
353                    "{}:{}",
354                    relative_path(&c.conflict.path, root),
355                    c.conflict.position
356                )
357            })
358            .collect(),
359    }
360}
361
362fn unused_export_baseline_keys(
363    items: &[crate::results::UnusedExportFinding],
364    root: &Path,
365) -> Vec<String> {
366    items
367        .iter()
368        .map(|e| {
369            format!(
370                "{}:{}",
371                relative_path(&e.export.path, root),
372                e.export.export_name
373            )
374        })
375        .collect()
376}
377
378fn unused_type_baseline_keys(
379    items: &[crate::results::UnusedTypeFinding],
380    root: &Path,
381) -> Vec<String> {
382    items
383        .iter()
384        .map(|e| {
385            format!(
386                "{}:{}",
387                relative_path(&e.export.path, root),
388                e.export.export_name
389            )
390        })
391        .collect()
392}
393
394fn invalid_client_export_baseline_keys(
395    items: &[crate::results::InvalidClientExportFinding],
396    root: &Path,
397) -> Vec<String> {
398    items
399        .iter()
400        .map(|e| {
401            format!(
402                "{}:{}",
403                relative_path(&e.export.path, root),
404                e.export.export_name
405            )
406        })
407        .collect()
408}
409
410fn private_type_leak_baseline_keys(
411    items: &[crate::results::PrivateTypeLeakFinding],
412    root: &Path,
413) -> Vec<String> {
414    items
415        .iter()
416        .map(|e| {
417            format!(
418                "{}:{}->{}",
419                relative_path(&e.leak.path, root),
420                e.leak.export_name,
421                e.leak.type_name
422            )
423        })
424        .collect()
425}
426
427fn barrel_baseline_keys(
428    items: &[crate::results::MixedClientServerBarrelFinding],
429    root: &Path,
430) -> Vec<String> {
431    items
432        .iter()
433        .map(|b| {
434            format!(
435                "{}:{}:{}",
436                relative_path(&b.barrel.path, root),
437                b.barrel.client_origin,
438                b.barrel.server_origin
439            )
440        })
441        .collect()
442}
443
444fn directive_baseline_keys(
445    items: &[crate::results::MisplacedDirectiveFinding],
446    root: &Path,
447) -> Vec<String> {
448    items
449        .iter()
450        .map(|d| {
451            format!(
452                "{}:{}:{}",
453                relative_path(&d.directive_site.path, root),
454                d.directive_site.line,
455                d.directive_site.directive
456            )
457        })
458        .collect()
459}
460
461fn route_collision_baseline_keys(
462    items: &[crate::results::RouteCollisionFinding],
463    root: &Path,
464) -> Vec<String> {
465    items
466        .iter()
467        .map(|c| {
468            format!(
469                "{}:{}",
470                relative_path(&c.collision.path, root),
471                c.collision.url
472            )
473        })
474        .collect()
475}
476
477struct BaselineMemberImportKeys {
478    unused_enum_members: Vec<String>,
479    unused_class_members: Vec<String>,
480    unused_store_members: Vec<String>,
481    unprovided_injects: Vec<String>,
482    unrendered_components: Vec<String>,
483    unused_component_props: Vec<String>,
484    unused_component_emits: Vec<String>,
485    unused_component_inputs: Vec<String>,
486    unused_component_outputs: Vec<String>,
487    unused_svelte_events: Vec<String>,
488    unused_server_actions: Vec<String>,
489    unused_load_data_keys: Vec<String>,
490    unresolved_imports: Vec<String>,
491    duplicate_exports: Vec<String>,
492    stale_suppressions: Vec<String>,
493}
494
495fn baseline_member_import_keys(
496    results: &crate::results::AnalysisResults,
497    root: &Path,
498) -> BaselineMemberImportKeys {
499    BaselineMemberImportKeys {
500        unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
501        unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
502        unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
503        unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
504        unrendered_components: component_baseline_keys(&results.unrendered_components, root),
505        unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
506        unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
507        unused_component_inputs: component_input_baseline_keys(
508            &results.unused_component_inputs,
509            root,
510        ),
511        unused_component_outputs: component_output_baseline_keys(
512            &results.unused_component_outputs,
513            root,
514        ),
515        unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
516        unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
517        unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
518        unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
519        duplicate_exports: results
520            .duplicate_exports
521            .iter()
522            .map(|d| duplicate_export_key(&d.export, root))
523            .collect(),
524        stale_suppressions: results
525            .stale_suppressions
526            .iter()
527            .map(|s| stale_suppression_baseline_key(s, root))
528            .collect(),
529    }
530}
531
532fn stale_suppression_baseline_key(
533    suppression: &crate::results::StaleSuppression,
534    root: &Path,
535) -> String {
536    let rule_id = if suppression.missing_reason {
537        "missing-suppression-reason"
538    } else {
539        "stale-suppression"
540    };
541    format!(
542        "{rule_id}:{}:{}",
543        relative_path(&suppression.path, root),
544        suppression.line
545    )
546}
547
548fn enum_member_baseline_keys(
549    items: &[crate::results::UnusedEnumMemberFinding],
550    root: &Path,
551) -> Vec<String> {
552    items
553        .iter()
554        .map(|m| unused_member_baseline_key(&m.member, root))
555        .collect()
556}
557
558fn class_member_baseline_keys(
559    items: &[crate::results::UnusedClassMemberFinding],
560    root: &Path,
561) -> Vec<String> {
562    items
563        .iter()
564        .map(|m| unused_member_baseline_key(&m.member, root))
565        .collect()
566}
567
568fn store_member_baseline_keys(
569    items: &[crate::results::UnusedStoreMemberFinding],
570    root: &Path,
571) -> Vec<String> {
572    items
573        .iter()
574        .map(|m| unused_member_baseline_key(&m.member, root))
575        .collect()
576}
577
578fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
579    format!(
580        "{}:{}.{}",
581        relative_path(&member.path, root),
582        member.parent_name,
583        member.member_name
584    )
585}
586
587fn inject_baseline_keys(
588    items: &[crate::results::UnprovidedInjectFinding],
589    root: &Path,
590) -> Vec<String> {
591    items
592        .iter()
593        .map(|f| {
594            format!(
595                "{}:{}",
596                relative_path(&f.inject.path, root),
597                f.inject.key_name
598            )
599        })
600        .collect()
601}
602
603fn component_baseline_keys(
604    items: &[crate::results::UnrenderedComponentFinding],
605    root: &Path,
606) -> Vec<String> {
607    items
608        .iter()
609        .map(|c| {
610            format!(
611                "{}:{}",
612                relative_path(&c.component.path, root),
613                c.component.component_name
614            )
615        })
616        .collect()
617}
618
619fn component_prop_baseline_keys(
620    items: &[crate::results::UnusedComponentPropFinding],
621    root: &Path,
622) -> Vec<String> {
623    items
624        .iter()
625        .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
626        .collect()
627}
628
629fn component_emit_baseline_keys(
630    items: &[crate::results::UnusedComponentEmitFinding],
631    root: &Path,
632) -> Vec<String> {
633    items
634        .iter()
635        .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
636        .collect()
637}
638
639fn component_input_baseline_keys(
640    items: &[crate::results::UnusedComponentInputFinding],
641    root: &Path,
642) -> Vec<String> {
643    items
644        .iter()
645        .map(|i| {
646            format!(
647                "{}:{}",
648                relative_path(&i.input.path, root),
649                i.input.input_name
650            )
651        })
652        .collect()
653}
654
655fn component_output_baseline_keys(
656    items: &[crate::results::UnusedComponentOutputFinding],
657    root: &Path,
658) -> Vec<String> {
659    items
660        .iter()
661        .map(|o| {
662            format!(
663                "{}:{}",
664                relative_path(&o.output.path, root),
665                o.output.output_name
666            )
667        })
668        .collect()
669}
670
671fn svelte_event_baseline_keys(
672    items: &[crate::results::UnusedSvelteEventFinding],
673    root: &Path,
674) -> Vec<String> {
675    items
676        .iter()
677        .map(|e| {
678            format!(
679                "{}:{}",
680                relative_path(&e.event.path, root),
681                e.event.event_name
682            )
683        })
684        .collect()
685}
686
687fn server_action_baseline_keys(
688    items: &[crate::results::UnusedServerActionFinding],
689    root: &Path,
690) -> Vec<String> {
691    items
692        .iter()
693        .map(|a| {
694            format!(
695                "{}:{}",
696                relative_path(&a.action.path, root),
697                a.action.action_name
698            )
699        })
700        .collect()
701}
702
703fn load_data_key_baseline_keys(
704    items: &[crate::results::UnusedLoadDataKeyFinding],
705    root: &Path,
706) -> Vec<String> {
707    items
708        .iter()
709        .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
710        .collect()
711}
712
713fn unresolved_import_baseline_keys(
714    items: &[crate::results::UnresolvedImportFinding],
715    root: &Path,
716) -> Vec<String> {
717    items
718        .iter()
719        .map(|i| {
720            format!(
721                "{}:{}",
722                relative_path(&i.import.path, root),
723                i.import.specifier
724            )
725        })
726        .collect()
727}
728
729struct BaselineDependencyKeys {
730    unused: Vec<String>,
731    unused_dev: Vec<String>,
732    unused_optional: Vec<String>,
733    unlisted: Vec<String>,
734    type_only: Vec<String>,
735    test_only: Vec<String>,
736    dev_in_prod: Vec<String>,
737}
738
739fn baseline_dependency_keys(
740    results: &crate::results::AnalysisResults,
741    root: &Path,
742) -> BaselineDependencyKeys {
743    BaselineDependencyKeys {
744        unused: results
745            .unused_dependencies
746            .iter()
747            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
748            .collect(),
749        unused_dev: results
750            .unused_dev_dependencies
751            .iter()
752            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
753            .collect(),
754        unused_optional: results
755            .unused_optional_dependencies
756            .iter()
757            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
758            .collect(),
759        unlisted: results
760            .unlisted_dependencies
761            .iter()
762            .map(|d| d.dep.package_name.clone())
763            .collect(),
764        type_only: results
765            .type_only_dependencies
766            .iter()
767            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
768            .collect(),
769        test_only: results
770            .test_only_dependencies
771            .iter()
772            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
773            .collect(),
774        dev_in_prod: results
775            .dev_dependencies_in_production
776            .iter()
777            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
778            .collect(),
779    }
780}
781
782struct BaselineGraphKeys {
783    circular_dependencies: Vec<String>,
784    re_export_cycles: Vec<String>,
785    boundary_violations: Vec<String>,
786    boundary_coverage_violations: Vec<String>,
787    boundary_call_violations: Vec<String>,
788    policy_violations: Vec<String>,
789}
790
791fn baseline_graph_keys(
792    results: &crate::results::AnalysisResults,
793    root: &Path,
794) -> BaselineGraphKeys {
795    BaselineGraphKeys {
796        circular_dependencies: results
797            .circular_dependencies
798            .iter()
799            .map(|c| circular_dep_key(&c.cycle, root))
800            .collect(),
801        re_export_cycles: results
802            .re_export_cycles
803            .iter()
804            .map(|c| re_export_cycle_key(&c.cycle, root))
805            .collect(),
806        boundary_violations: results
807            .boundary_violations
808            .iter()
809            .map(|v| boundary_violation_key(&v.violation, root))
810            .collect(),
811        boundary_coverage_violations: results
812            .boundary_coverage_violations
813            .iter()
814            .map(|v| relative_path(&v.violation.path, root))
815            .collect(),
816        boundary_call_violations: results
817            .boundary_call_violations
818            .iter()
819            .map(|v| boundary_call_violation_key(&v.violation, root))
820            .collect(),
821        policy_violations: results
822            .policy_violations
823            .iter()
824            .map(|v| policy_violation_key(&v.violation, root))
825            .collect(),
826    }
827}
828
829struct BaselineCatalogKeys {
830    unused_catalog_entries: Vec<String>,
831    empty_catalog_groups: Vec<String>,
832    unresolved_catalog_references: Vec<String>,
833    unused_dependency_overrides: Vec<String>,
834    misconfigured_dependency_overrides: Vec<String>,
835}
836
837fn baseline_catalog_keys(
838    results: &crate::results::AnalysisResults,
839    root: &Path,
840) -> BaselineCatalogKeys {
841    BaselineCatalogKeys {
842        unused_catalog_entries: results
843            .unused_catalog_entries
844            .iter()
845            .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
846            .collect(),
847        empty_catalog_groups: results
848            .empty_catalog_groups
849            .iter()
850            .map(|g| g.group.catalog_name.clone())
851            .collect(),
852        unresolved_catalog_references: results
853            .unresolved_catalog_references
854            .iter()
855            .map(|r| {
856                format!(
857                    "{}:{}:{}:{}",
858                    relative_path(&r.reference.path, root),
859                    r.reference.line,
860                    r.reference.catalog_name,
861                    r.reference.entry_name,
862                )
863            })
864            .collect(),
865        unused_dependency_overrides: results
866            .unused_dependency_overrides
867            .iter()
868            .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
869            .collect(),
870        misconfigured_dependency_overrides: results
871            .misconfigured_dependency_overrides
872            .iter()
873            .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
874            .collect(),
875    }
876}
877
878/// Generate a stable key for a boundary violation: `from_path->to_path`.
879fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
880    format!(
881        "{}->{}",
882        relative_path(&v.from_path, root),
883        relative_path(&v.to_path, root),
884    )
885}
886
887/// Generate a stable key for a boundary call violation: `path:callee`.
888fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
889    format!("{}:{}", relative_path(&v.path, root), v.callee)
890}
891
892/// Generate a stable key for a rule-pack policy violation:
893/// `path:pack/rule_id:matched`. Line numbers are deliberately excluded so a
894/// baselined finding survives unrelated edits above it.
895fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
896    format!(
897        "{}:{}/{}:{}",
898        relative_path(&v.path, root),
899        v.pack,
900        v.rule_id,
901        v.matched
902    )
903}
904
905/// Generate a stable key for a duplicate export: `name|sorted_paths`.
906fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
907    let mut locs: Vec<String> = dup
908        .locations
909        .iter()
910        .map(|l| relative_path(&l.path, root))
911        .collect();
912    locs.sort();
913    format!("{}|{}", dup.export_name, locs.join("|"))
914}
915
916/// Generate a stable key for a circular dependency based on sorted file paths.
917fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
918    let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
919    paths.sort();
920    paths.join("->")
921}
922
923/// Generate a stable key for a re-export cycle based on its discriminator
924/// kind plus sorted member paths. The `kind` prefix is mandatory: without
925/// it a self-loop on `src/foo.ts` would keyspace-collide with any future
926/// single-file multi-node shape, and the `--baseline new` filter would
927/// silently drop the new one as already-seen (panel catch #7).
928fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
929    let kind = match cycle.kind {
930        crate::results::ReExportCycleKind::MultiNode => "multi-node",
931        crate::results::ReExportCycleKind::SelfLoop => "self-loop",
932    };
933    let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
934    paths.sort();
935    format!("{kind}:{}", paths.join("<->"))
936}
937
938fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
939    format!(
940        "{}:{}->{}",
941        relative_path(&leak.path, root),
942        leak.export_name,
943        leak.type_name
944    )
945}
946
947fn filter_private_type_leaks(
948    leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
949    baseline_keys: &[String],
950    root: &Path,
951) {
952    let baseline_private_type_leaks: FxHashSet<&str> =
953        baseline_keys.iter().map(String::as_str).collect();
954    leaks.retain(|entry| {
955        let key = private_type_leak_key(&entry.leak, root);
956        !baseline_private_type_leaks.contains(key.as_str())
957    });
958}
959
960struct BaselineFilterContext<'a> {
961    baseline: &'a BaselineData,
962    root: &'a Path,
963}
964
965impl BaselineFilterContext<'_> {
966    fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
967        let baseline_circular: FxHashSet<&str> = self
968            .baseline
969            .circular_dependencies
970            .iter()
971            .map(String::as_str)
972            .collect();
973        results.circular_dependencies.retain(|cycle| {
974            let key = circular_dep_key(&cycle.cycle, self.root);
975            !baseline_circular.contains(key.as_str())
976        });
977
978        let baseline_re_export_cycles: FxHashSet<&str> = self
979            .baseline
980            .re_export_cycles
981            .iter()
982            .map(String::as_str)
983            .collect();
984        results.re_export_cycles.retain(|cycle| {
985            let key = re_export_cycle_key(&cycle.cycle, self.root);
986            !baseline_re_export_cycles.contains(key.as_str())
987        });
988
989        self.filter_unused_members(results);
990        self.filter_unresolved_and_exports(results);
991    }
992
993    fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
994        self.filter_enum_class_store_members(results);
995        self.filter_component_surface_members(results);
996        self.filter_route_action_members(results);
997    }
998
999    fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
1000        let baseline_enum_members: FxHashSet<&str> = self
1001            .baseline
1002            .unused_enum_members
1003            .iter()
1004            .map(String::as_str)
1005            .collect();
1006        results.unused_enum_members.retain(|member| {
1007            let key = format!(
1008                "{}:{}.{}",
1009                relative_path(&member.member.path, self.root),
1010                member.member.parent_name,
1011                member.member.member_name
1012            );
1013            !baseline_enum_members.contains(key.as_str())
1014        });
1015
1016        let baseline_class_members: FxHashSet<&str> = self
1017            .baseline
1018            .unused_class_members
1019            .iter()
1020            .map(String::as_str)
1021            .collect();
1022        results.unused_class_members.retain(|member| {
1023            let key = format!(
1024                "{}:{}.{}",
1025                relative_path(&member.member.path, self.root),
1026                member.member.parent_name,
1027                member.member.member_name
1028            );
1029            !baseline_class_members.contains(key.as_str())
1030        });
1031
1032        let baseline_store_members: FxHashSet<&str> = self
1033            .baseline
1034            .unused_store_members
1035            .iter()
1036            .map(String::as_str)
1037            .collect();
1038        results.unused_store_members.retain(|member| {
1039            let key = format!(
1040                "{}:{}.{}",
1041                relative_path(&member.member.path, self.root),
1042                member.member.parent_name,
1043                member.member.member_name
1044            );
1045            !baseline_store_members.contains(key.as_str())
1046        });
1047    }
1048
1049    fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1050        retain_new_by_keys(
1051            &mut results.unprovided_injects,
1052            &self.baseline.unprovided_injects,
1053            self.root,
1054            inject_baseline_keys,
1055        );
1056        retain_new_by_keys(
1057            &mut results.unrendered_components,
1058            &self.baseline.unrendered_components,
1059            self.root,
1060            component_baseline_keys,
1061        );
1062        retain_new_by_keys(
1063            &mut results.unused_component_props,
1064            &self.baseline.unused_component_props,
1065            self.root,
1066            component_prop_baseline_keys,
1067        );
1068        retain_new_by_keys(
1069            &mut results.unused_component_emits,
1070            &self.baseline.unused_component_emits,
1071            self.root,
1072            component_emit_baseline_keys,
1073        );
1074        retain_new_by_keys(
1075            &mut results.unused_component_inputs,
1076            &self.baseline.unused_component_inputs,
1077            self.root,
1078            component_input_baseline_keys,
1079        );
1080        retain_new_by_keys(
1081            &mut results.unused_component_outputs,
1082            &self.baseline.unused_component_outputs,
1083            self.root,
1084            component_output_baseline_keys,
1085        );
1086        retain_new_by_keys(
1087            &mut results.unused_svelte_events,
1088            &self.baseline.unused_svelte_events,
1089            self.root,
1090            svelte_event_baseline_keys,
1091        );
1092    }
1093
1094    fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1095        let baseline_unused_server_actions: FxHashSet<&str> = self
1096            .baseline
1097            .unused_server_actions
1098            .iter()
1099            .map(String::as_str)
1100            .collect();
1101        results.unused_server_actions.retain(|finding| {
1102            let key = format!(
1103                "{}:{}",
1104                relative_path(&finding.action.path, self.root),
1105                finding.action.action_name
1106            );
1107            !baseline_unused_server_actions.contains(key.as_str())
1108        });
1109
1110        let baseline_unused_load_data_keys: FxHashSet<&str> = self
1111            .baseline
1112            .unused_load_data_keys
1113            .iter()
1114            .map(String::as_str)
1115            .collect();
1116        results.unused_load_data_keys.retain(|finding| {
1117            let key = format!(
1118                "{}:{}",
1119                relative_path(&finding.key.path, self.root),
1120                finding.key.key_name
1121            );
1122            !baseline_unused_load_data_keys.contains(key.as_str())
1123        });
1124    }
1125
1126    fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1127        let baseline_unresolved: FxHashSet<&str> = self
1128            .baseline
1129            .unresolved_imports
1130            .iter()
1131            .map(String::as_str)
1132            .collect();
1133        results.unresolved_imports.retain(|import| {
1134            let key = format!(
1135                "{}:{}",
1136                relative_path(&import.import.path, self.root),
1137                import.import.specifier
1138            );
1139            !baseline_unresolved.contains(key.as_str())
1140        });
1141
1142        let baseline_unlisted: FxHashSet<&str> = self
1143            .baseline
1144            .unlisted_dependencies
1145            .iter()
1146            .map(String::as_str)
1147            .collect();
1148        results
1149            .unlisted_dependencies
1150            .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1151
1152        let baseline_dup_exports: FxHashSet<&str> = self
1153            .baseline
1154            .duplicate_exports
1155            .iter()
1156            .map(String::as_str)
1157            .collect();
1158        results.duplicate_exports.retain(|duplicate| {
1159            let key = duplicate_export_key(&duplicate.export, self.root);
1160            !baseline_dup_exports.contains(key.as_str())
1161        });
1162    }
1163
1164    fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1165        let baseline_optional_deps: FxHashSet<&str> = self
1166            .baseline
1167            .unused_optional_dependencies
1168            .iter()
1169            .map(String::as_str)
1170            .collect();
1171        results.unused_optional_dependencies.retain(|dep| {
1172            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1173            !baseline_contains_dependency(
1174                &baseline_optional_deps,
1175                &dep.dep.package_name,
1176                key.as_str(),
1177            )
1178        });
1179
1180        self.filter_type_and_test_only_dependencies(results);
1181    }
1182
1183    fn filter_type_and_test_only_dependencies(
1184        &self,
1185        results: &mut crate::results::AnalysisResults,
1186    ) {
1187        let baseline_type_only: FxHashSet<&str> = self
1188            .baseline
1189            .type_only_dependencies
1190            .iter()
1191            .map(String::as_str)
1192            .collect();
1193        results.type_only_dependencies.retain(|dep| {
1194            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1195            !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1196        });
1197
1198        let baseline_test_only: FxHashSet<&str> = self
1199            .baseline
1200            .test_only_dependencies
1201            .iter()
1202            .map(String::as_str)
1203            .collect();
1204        results.test_only_dependencies.retain(|dep| {
1205            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1206            !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1207        });
1208
1209        let baseline_dev_in_prod: FxHashSet<&str> = self
1210            .baseline
1211            .dev_dependencies_in_production
1212            .iter()
1213            .map(String::as_str)
1214            .collect();
1215        results.dev_dependencies_in_production.retain(|dep| {
1216            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1217            !baseline_contains_dependency(
1218                &baseline_dev_in_prod,
1219                &dep.dep.package_name,
1220                key.as_str(),
1221            )
1222        });
1223    }
1224
1225    fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1226        let baseline_boundary: FxHashSet<&str> = self
1227            .baseline
1228            .boundary_violations
1229            .iter()
1230            .map(String::as_str)
1231            .collect();
1232        results.boundary_violations.retain(|violation| {
1233            let key = boundary_violation_key(&violation.violation, self.root);
1234            !baseline_boundary.contains(key.as_str())
1235        });
1236
1237        self.filter_boundary_details(results);
1238        self.filter_stale_suppressions(results);
1239        self.filter_invalid_client_exports(results);
1240        self.filter_mixed_client_server_barrels(results);
1241        self.filter_misplaced_directives(results);
1242        self.filter_route_collisions(results);
1243        self.filter_dynamic_segment_name_conflicts(results);
1244    }
1245
1246    fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1247        let baseline_invalid: FxHashSet<&str> = self
1248            .baseline
1249            .invalid_client_exports
1250            .iter()
1251            .map(String::as_str)
1252            .collect();
1253        results.invalid_client_exports.retain(|finding| {
1254            let key = format!(
1255                "{}:{}",
1256                relative_path(&finding.export.path, self.root),
1257                finding.export.export_name
1258            );
1259            !baseline_invalid.contains(key.as_str())
1260        });
1261    }
1262
1263    fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
1264        let baseline_barrels: FxHashSet<&str> = self
1265            .baseline
1266            .mixed_client_server_barrels
1267            .iter()
1268            .map(String::as_str)
1269            .collect();
1270        results.mixed_client_server_barrels.retain(|finding| {
1271            let key = format!(
1272                "{}:{}:{}",
1273                relative_path(&finding.barrel.path, self.root),
1274                finding.barrel.client_origin,
1275                finding.barrel.server_origin
1276            );
1277            !baseline_barrels.contains(key.as_str())
1278        });
1279    }
1280
1281    fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
1282        let baseline_directives: FxHashSet<&str> = self
1283            .baseline
1284            .misplaced_directives
1285            .iter()
1286            .map(String::as_str)
1287            .collect();
1288        results.misplaced_directives.retain(|finding| {
1289            let key = format!(
1290                "{}:{}:{}",
1291                relative_path(&finding.directive_site.path, self.root),
1292                finding.directive_site.line,
1293                finding.directive_site.directive
1294            );
1295            !baseline_directives.contains(key.as_str())
1296        });
1297    }
1298
1299    fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
1300        let baseline_collisions: FxHashSet<&str> = self
1301            .baseline
1302            .route_collisions
1303            .iter()
1304            .map(String::as_str)
1305            .collect();
1306        results.route_collisions.retain(|finding| {
1307            let key = format!(
1308                "{}:{}",
1309                relative_path(&finding.collision.path, self.root),
1310                finding.collision.url
1311            );
1312            !baseline_collisions.contains(key.as_str())
1313        });
1314    }
1315
1316    fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
1317        let baseline_conflicts: FxHashSet<&str> = self
1318            .baseline
1319            .dynamic_segment_name_conflicts
1320            .iter()
1321            .map(String::as_str)
1322            .collect();
1323        results.dynamic_segment_name_conflicts.retain(|finding| {
1324            let key = format!(
1325                "{}:{}",
1326                relative_path(&finding.conflict.path, self.root),
1327                finding.conflict.position
1328            );
1329            !baseline_conflicts.contains(key.as_str())
1330        });
1331    }
1332
1333    fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
1334        let baseline_boundary_coverage: FxHashSet<&str> = self
1335            .baseline
1336            .boundary_coverage_violations
1337            .iter()
1338            .map(String::as_str)
1339            .collect();
1340        results.boundary_coverage_violations.retain(|violation| {
1341            let key = relative_path(&violation.violation.path, self.root);
1342            !baseline_boundary_coverage.contains(key.as_str())
1343        });
1344
1345        let baseline_boundary_calls: FxHashSet<&str> = self
1346            .baseline
1347            .boundary_call_violations
1348            .iter()
1349            .map(String::as_str)
1350            .collect();
1351        results.boundary_call_violations.retain(|violation| {
1352            let key = boundary_call_violation_key(&violation.violation, self.root);
1353            !baseline_boundary_calls.contains(key.as_str())
1354        });
1355    }
1356
1357    fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1358        let baseline_stale: FxHashSet<&str> = self
1359            .baseline
1360            .stale_suppressions
1361            .iter()
1362            .map(String::as_str)
1363            .collect();
1364        results.stale_suppressions.retain(|suppression| {
1365            let key = stale_suppression_baseline_key(suppression, self.root);
1366            let legacy_key = format!(
1367                "{}:{}",
1368                relative_path(&suppression.path, self.root),
1369                suppression.line
1370            );
1371            !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
1372        });
1373    }
1374
1375    fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
1376        let baseline_catalog: FxHashSet<&str> = self
1377            .baseline
1378            .unused_catalog_entries
1379            .iter()
1380            .map(String::as_str)
1381            .collect();
1382        results.unused_catalog_entries.retain(|entry| {
1383            let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
1384            !baseline_catalog.contains(key.as_str())
1385        });
1386
1387        let baseline_empty_catalog_groups: FxHashSet<&str> = self
1388            .baseline
1389            .empty_catalog_groups
1390            .iter()
1391            .map(String::as_str)
1392            .collect();
1393        results.empty_catalog_groups.retain(|group| {
1394            !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
1395        });
1396
1397        self.filter_pnpm_references_and_overrides(results);
1398    }
1399
1400    fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
1401        let baseline_unresolved: FxHashSet<&str> = self
1402            .baseline
1403            .unresolved_catalog_references
1404            .iter()
1405            .map(String::as_str)
1406            .collect();
1407        results.unresolved_catalog_references.retain(|reference| {
1408            let key = format!(
1409                "{}:{}:{}:{}",
1410                relative_path(&reference.reference.path, self.root),
1411                reference.reference.line,
1412                reference.reference.catalog_name,
1413                reference.reference.entry_name,
1414            );
1415            !baseline_unresolved.contains(key.as_str())
1416        });
1417
1418        self.filter_pnpm_overrides(results);
1419    }
1420
1421    fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
1422        let baseline_unused_overrides: FxHashSet<&str> = self
1423            .baseline
1424            .unused_dependency_overrides
1425            .iter()
1426            .map(String::as_str)
1427            .collect();
1428        results
1429            .unused_dependency_overrides
1430            .retain(|override_entry| {
1431                let key = format!(
1432                    "{}:{}",
1433                    override_entry.entry.source, override_entry.entry.raw_key
1434                );
1435                !baseline_unused_overrides.contains(key.as_str())
1436            });
1437
1438        let baseline_misconfigured_overrides: FxHashSet<&str> = self
1439            .baseline
1440            .misconfigured_dependency_overrides
1441            .iter()
1442            .map(String::as_str)
1443            .collect();
1444        results
1445            .misconfigured_dependency_overrides
1446            .retain(|override_entry| {
1447                let key = format!(
1448                    "{}:{}",
1449                    override_entry.entry.source, override_entry.entry.raw_key
1450                );
1451                !baseline_misconfigured_overrides.contains(key.as_str())
1452            });
1453    }
1454}
1455
1456/// Filter results to only include issues not present in the baseline.
1457pub fn filter_new_issues(
1458    mut results: crate::results::AnalysisResults,
1459    baseline: &BaselineData,
1460    root: &Path,
1461) -> crate::results::AnalysisResults {
1462    let baseline_files: FxHashSet<&str> =
1463        baseline.unused_files.iter().map(String::as_str).collect();
1464    let baseline_exports: FxHashSet<&str> =
1465        baseline.unused_exports.iter().map(String::as_str).collect();
1466    let baseline_types: FxHashSet<&str> =
1467        baseline.unused_types.iter().map(String::as_str).collect();
1468    let baseline_deps: FxHashSet<&str> = baseline
1469        .unused_dependencies
1470        .iter()
1471        .map(String::as_str)
1472        .collect();
1473    let baseline_dev_deps: FxHashSet<&str> = baseline
1474        .unused_dev_dependencies
1475        .iter()
1476        .map(String::as_str)
1477        .collect();
1478
1479    results
1480        .unused_files
1481        .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
1482    results.unused_exports.retain(|e| {
1483        let key = format!(
1484            "{}:{}",
1485            relative_path(&e.export.path, root),
1486            e.export.export_name
1487        );
1488        !baseline_exports.contains(key.as_str())
1489    });
1490    results.unused_types.retain(|e| {
1491        let key = format!(
1492            "{}:{}",
1493            relative_path(&e.export.path, root),
1494            e.export.export_name
1495        );
1496        !baseline_types.contains(key.as_str())
1497    });
1498    filter_private_type_leaks(
1499        &mut results.private_type_leaks,
1500        &baseline.private_type_leaks,
1501        root,
1502    );
1503    results.unused_dependencies.retain(|d| {
1504        let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1505        !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
1506    });
1507    results.unused_dev_dependencies.retain(|d| {
1508        let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1509        !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
1510    });
1511
1512    let filter = BaselineFilterContext { baseline, root };
1513    filter.filter_cycles_and_members(&mut results);
1514    filter.filter_dependency_variants(&mut results);
1515    filter.filter_boundaries_and_suppressions(&mut results);
1516    filter.filter_pnpm_entries(&mut results);
1517
1518    results
1519}
1520
1521/// Baseline data for duplication comparison.
1522///
1523/// New baselines key every clone group by `<fingerprint>:<instance count>` in
1524/// `clone_fingerprints`. The fingerprint hashes the clone's source content, so
1525/// an unrelated edit that shifts a clone down a file keeps it matched, while an
1526/// extra copy in another file changes the instance count and is reported as a
1527/// new finding.
1528///
1529/// `clone_groups` keeps the legacy location keys (sorted
1530/// `file:start_line-end_line` per group) so baselines written by older versions
1531/// still filter, and baselines written now stay readable by those versions.
1532#[derive(Default, serde::Serialize, serde::Deserialize)]
1533pub struct DuplicationBaselineData {
1534    /// Legacy clone group keys: sorted list of `file:start-end` per group.
1535    #[serde(default)]
1536    pub clone_groups: Vec<String>,
1537    /// Content keys: `<clone fingerprint>:<instance count>` per group.
1538    #[serde(default)]
1539    pub clone_fingerprints: Vec<String>,
1540}
1541
1542impl DuplicationBaselineData {
1543    /// Build a duplication baseline from the current report.
1544    pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
1545        Self {
1546            clone_groups: report
1547                .clone_groups
1548                .iter()
1549                .map(|g| clone_group_key(g, root))
1550                .collect(),
1551            clone_fingerprints: report
1552                .clone_groups
1553                .iter()
1554                .map(clone_group_fingerprint_key)
1555                .collect(),
1556        }
1557    }
1558
1559    /// Number of baseline entries actually used for comparison.
1560    #[must_use]
1561    pub fn entry_count(&self) -> usize {
1562        if self.clone_fingerprints.is_empty() {
1563            self.clone_groups.len()
1564        } else {
1565            self.clone_fingerprints.len()
1566        }
1567    }
1568}
1569
1570/// Generate a stable key for a clone group based on its instance locations.
1571fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
1572    let mut parts: Vec<String> = group
1573        .instances
1574        .iter()
1575        .map(|i| {
1576            format!(
1577                "{}:{}-{}",
1578                relative_path(&i.file, root),
1579                i.start_line,
1580                i.end_line
1581            )
1582        })
1583        .collect();
1584    parts.sort();
1585    parts.join("|")
1586}
1587
1588/// Generate a location-independent key for a clone group.
1589///
1590/// The fingerprint hashes the representative instance's source fragment, where
1591/// the representative is the instance that sorts first on `(file, start_line)`.
1592/// Renaming the file holding the representative can therefore change the key for
1593/// a group whose instances do not share a byte-identical fragment.
1594fn clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
1595    let representative = group
1596        .instances
1597        .iter()
1598        .min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
1599        .map_or("", |i| i.fragment.as_str());
1600    format!(
1601        "{}:{}",
1602        crate::duplicates::fingerprint_for_fragment(representative),
1603        group.instances.len()
1604    )
1605}
1606
1607/// Filter a duplication report to only include clone groups not present in the baseline.
1608///
1609/// Baselines carrying `clone_fingerprints` compare on content plus instance
1610/// count. Baselines without that field fall back to the legacy location keys.
1611pub fn filter_new_clone_groups(
1612    mut report: DuplicationReport,
1613    baseline: &DuplicationBaselineData,
1614    root: &Path,
1615) -> DuplicationReport {
1616    if baseline.clone_fingerprints.is_empty() {
1617        let baseline_keys: FxHashSet<&str> =
1618            baseline.clone_groups.iter().map(String::as_str).collect();
1619        report.clone_groups.retain(|g| {
1620            let key = clone_group_key(g, root);
1621            !baseline_keys.contains(key.as_str())
1622        });
1623    } else {
1624        let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
1625        for key in &baseline.clone_fingerprints {
1626            *remaining.entry(key.as_str()).or_insert(0) += 1;
1627        }
1628        report.clone_groups.retain(|g| {
1629            let key = clone_group_fingerprint_key(g);
1630            match remaining.get_mut(key.as_str()) {
1631                Some(count) if *count > 0 => {
1632                    *count -= 1;
1633                    false
1634                }
1635                _ => true,
1636            }
1637        });
1638    }
1639
1640    crate::duplicates::refresh_clone_families(&mut report, root);
1641    report.stats = recompute_stats(&report);
1642
1643    report
1644}
1645
1646/// Recompute duplication statistics after filtering (baseline or `--changed-since`).
1647///
1648/// Uses per-file line deduplication (matching `compute_stats` in `detect.rs`)
1649/// so overlapping clone instances don't inflate the duplicated line count.
1650pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
1651    crate::duplicates::recompute_stats(report)
1652}
1653
1654/// Baseline data for health (complexity) comparison.
1655///
1656/// New baselines store count-per-category-per-file data in `finding_counts` so
1657/// line shifts do not leak pre-existing findings. Legacy baselines with
1658/// `findings: ["path:name:line"]` still load so users can refresh them in
1659/// place with `--save-baseline`.
1660///
1661/// `identity_finding_counts` carries the same counts bucketed per function
1662/// identity instead of per file, for the stricter [`HealthBaselineMode::Identity`]
1663/// comparison. It is written only when the baseline is saved in identity mode,
1664/// so default baselines keep their count-only shape. Identity baselines still
1665/// carry `finding_counts`, so they also work in count mode and with older
1666/// binaries.
1667#[derive(Default, serde::Serialize, serde::Deserialize)]
1668pub struct HealthBaselineData {
1669    /// Legacy health baseline keys: `relative_path:function_name:line`.
1670    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1671    pub(crate) findings: Vec<String>,
1672    /// Count-per-category-per-file baseline buckets.
1673    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1674    pub(crate) finding_counts: HealthFindingCountMap,
1675    /// Count-per-category buckets keyed by `relative_path\0function_name`.
1676    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1677    pub(crate) identity_finding_counts: HealthFindingCountMap,
1678    /// Stable runtime-coverage finding IDs from the sidecar.
1679    #[serde(default)]
1680    pub(crate) runtime_coverage_findings: Vec<String>,
1681    /// Line-move-tolerant runtime-coverage suppression keys of the form
1682    /// `path\0name\0source_hash`. Unlike `runtime_coverage_findings` (whose
1683    /// keys hash the start line and so churn when a function moves), the
1684    /// `source_hash` component is the content digest of the function body, so a
1685    /// moved-but-unedited function keeps the same key and stays suppressed.
1686    /// Only findings whose `source_hash` is present contribute an entry.
1687    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1688    pub(crate) runtime_coverage_source_hashes: Vec<String>,
1689    /// Refactoring target keys: `relative_path:category`.
1690    #[serde(default)]
1691    pub(crate) target_keys: Vec<String>,
1692}
1693
1694#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1695pub struct HealthBaselineCount {
1696    count: usize,
1697}
1698
1699type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
1700
1701/// How a saved health baseline is matched against current findings.
1702#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1703pub enum HealthBaselineMode {
1704    /// Match per file and finding category. Resilient to renames and line
1705    /// shifts, but a replacement hotspot consumes the allowance of the hotspot
1706    /// it replaced.
1707    ///
1708    /// Count buckets do not survive file moves: the bucket key is the path and
1709    /// the payload is category tallies alone, so a bucket whose file moved has
1710    /// no surviving identity component to re-match on. Guessing a new path
1711    /// from count shapes could silently transfer allowance between unrelated
1712    /// files, so no move tolerance is attempted in this mode.
1713    #[default]
1714    Count,
1715    /// Match per function identity (path plus function name) and finding
1716    /// category. A hotspot that replaces another hotspot in the same file is
1717    /// reported, while line shifts and in-place edits stay suppressed.
1718    ///
1719    /// Identity buckets tolerate file moves conservatively: a bucket whose
1720    /// path no longer exists on disk follows its function name to a new path
1721    /// when exactly one unclaimed current bucket carries that name, resolved
1722    /// by `moved_identity_bucket_remaps`.
1723    Identity,
1724}
1725
1726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1727enum HealthFindingDimension {
1728    Complexity,
1729    Crap,
1730}
1731
1732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1733struct HealthFindingCategory {
1734    dimension: HealthFindingDimension,
1735    severity: fallow_output::FindingSeverity,
1736}
1737
1738impl HealthFindingCategory {
1739    const fn key(self) -> &'static str {
1740        match (self.dimension, self.severity) {
1741            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
1742                "complexity_moderate"
1743            }
1744            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
1745                "complexity_high"
1746            }
1747            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
1748                "complexity_critical"
1749            }
1750            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
1751                "crap_moderate"
1752            }
1753            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
1754            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
1755                "crap_critical"
1756            }
1757        }
1758    }
1759}
1760
1761const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
1762    HealthFindingDimension::Complexity,
1763    HealthFindingDimension::Crap,
1764];
1765
1766impl HealthBaselineData {
1767    /// Build a health baseline from findings and targets.
1768    pub(crate) fn from_findings(
1769        findings: &[fallow_output::ComplexityViolation],
1770        runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
1771        targets: &[fallow_output::RefactoringTarget],
1772        root: &Path,
1773    ) -> Self {
1774        Self {
1775            findings: Vec::new(),
1776            finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
1777            identity_finding_counts: HealthFindingCountMap::new(),
1778            runtime_coverage_findings: runtime_coverage_findings
1779                .iter()
1780                .map(|f| runtime_coverage_finding_key(f, root))
1781                .collect(),
1782            runtime_coverage_source_hashes: runtime_coverage_findings
1783                .iter()
1784                .filter_map(|f| runtime_coverage_source_hash_key(f, root))
1785                .collect(),
1786            target_keys: targets
1787                .iter()
1788                .map(|t| target_baseline_key(t, root))
1789                .collect(),
1790        }
1791    }
1792
1793    pub(crate) fn finding_entry_count(&self) -> usize {
1794        if !self.finding_counts.is_empty() {
1795            self.finding_counts
1796                .values()
1797                .flat_map(BTreeMap::values)
1798                .map(|entry| entry.count)
1799                .sum()
1800        } else {
1801            self.findings.len()
1802        }
1803    }
1804
1805    /// Add per-function identity buckets to a saved baseline.
1806    ///
1807    /// Only [`HealthBaselineMode::Identity`] saves record these, so a default
1808    /// baseline keeps carrying counts alone and stays free of function names.
1809    #[must_use]
1810    pub(crate) fn with_identity(
1811        mut self,
1812        findings: &[fallow_output::ComplexityViolation],
1813        root: &Path,
1814    ) -> Self {
1815        self.identity_finding_counts =
1816            health_finding_counts(findings, root, HealthBaselineMode::Identity);
1817        self
1818    }
1819
1820    /// `true` when identity matching would silently degrade because the saved
1821    /// baseline predates `identity_finding_counts` yet does carry findings.
1822    pub(crate) fn lacks_identity_data(&self) -> bool {
1823        self.identity_finding_counts.is_empty()
1824            && (!self.finding_counts.is_empty() || !self.findings.is_empty())
1825    }
1826
1827    fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
1828        match mode {
1829            HealthBaselineMode::Count => &self.finding_counts,
1830            HealthBaselineMode::Identity => &self.identity_finding_counts,
1831        }
1832    }
1833
1834    pub(crate) fn overlap_entries(
1835        &self,
1836        findings: &[fallow_output::ComplexityViolation],
1837        root: &Path,
1838        mode: HealthBaselineMode,
1839    ) -> HealthBaselineOverlap {
1840        let baseline_counts = self.counts_for(mode);
1841        if !baseline_counts.is_empty() {
1842            let current_counts = health_finding_counts(findings, root, mode);
1843            let direct = health_overlap_entry_count(&current_counts, baseline_counts);
1844            let remapped = (mode == HealthBaselineMode::Identity)
1845                .then(|| {
1846                    identity_counts_with_move_tolerance(baseline_counts, &current_counts, root)
1847                })
1848                .flatten();
1849            match remapped {
1850                Some(remapped_counts) => {
1851                    let matched = health_overlap_entry_count(&current_counts, &remapped_counts);
1852                    HealthBaselineOverlap {
1853                        matched_entries: matched,
1854                        moved_entries: matched.saturating_sub(direct),
1855                    }
1856                }
1857                None => HealthBaselineOverlap {
1858                    matched_entries: direct,
1859                    moved_entries: 0,
1860                },
1861            }
1862        } else {
1863            let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
1864            HealthBaselineOverlap {
1865                matched_entries: findings
1866                    .iter()
1867                    .filter(|finding| {
1868                        baseline_keys.contains(health_finding_key(finding, root).as_str())
1869                    })
1870                    .count(),
1871                moved_entries: 0,
1872            }
1873        }
1874    }
1875}
1876
1877/// Baseline entry overlap for one run, split so followed file moves stay
1878/// observable in output rather than silently absorbed into the match count.
1879pub(crate) struct HealthBaselineOverlap {
1880    /// Entries that matched a current finding, including via followed moves.
1881    pub(crate) matched_entries: usize,
1882    /// Entries that matched only because a retired identity bucket was
1883    /// re-keyed to a moved file.
1884    pub(crate) moved_entries: usize,
1885}
1886
1887/// Generate a stable key for a refactoring target: `relative_path:category`.
1888fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
1889    format!(
1890        "{}:{}",
1891        relative_path(&target.path, root),
1892        target.category.label()
1893    )
1894}
1895
1896/// Generate a stable key for a health finding.
1897fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
1898    format!(
1899        "{}:{}:{}",
1900        relative_path(&finding.path, root),
1901        finding.name,
1902        finding.line
1903    )
1904}
1905
1906/// Bucket a finding belongs to for the given comparison mode.
1907///
1908/// The NUL separator keeps the identity bucket unambiguous for paths and
1909/// function names that contain `:`.
1910fn health_bucket_key(
1911    finding: &fallow_output::ComplexityViolation,
1912    root: &Path,
1913    mode: HealthBaselineMode,
1914) -> String {
1915    let path = relative_path(&finding.path, root);
1916    match mode {
1917        HealthBaselineMode::Count => path,
1918        HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
1919    }
1920}
1921
1922/// Placeholder name for functions without a resolvable name; two anonymous
1923/// functions sharing it is not evidence of identity, so move tolerance skips
1924/// such buckets entirely.
1925const ANONYMOUS_FUNCTION_NAME: &str = "<anonymous>";
1926
1927fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
1928    key.split_once('\0')
1929}
1930
1931/// Conservative file-move tolerance for identity baseline buckets.
1932///
1933/// A baseline bucket follows its function to a new path only when every one of
1934/// these holds:
1935///
1936/// - the bucket matched no current bucket at its saved path,
1937/// - the saved path no longer exists on disk under the project root, so the
1938///   file was moved or deleted rather than merely fixed,
1939/// - exactly one current bucket carries the same function name at a path the
1940///   baseline does not already cover,
1941/// - no other retired baseline bucket claims that same candidate.
1942///
1943/// The function name is the only identity component that survives a move, so
1944/// anything more permissive would risk transferring allowance between
1945/// unrelated functions. Anonymous placeholders never match. The result is
1946/// deterministic: both maps iterate in `BTreeMap` order and the
1947/// exactly-one rules make the outcome independent of iteration order.
1948fn moved_identity_bucket_remaps(
1949    baseline_counts: &HealthFindingCountMap,
1950    current_counts: &HealthFindingCountMap,
1951    root: &Path,
1952) -> Vec<(String, String)> {
1953    let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
1954    for key in current_counts.keys() {
1955        if baseline_counts.contains_key(key) {
1956            continue;
1957        }
1958        if let Some((_, name)) = identity_bucket_parts(key)
1959            && name != ANONYMOUS_FUNCTION_NAME
1960        {
1961            candidates_by_name.entry(name).or_default().push(key);
1962        }
1963    }
1964
1965    let mut proposals: Vec<(&str, &str)> = Vec::new();
1966    let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
1967    for key in baseline_counts.keys() {
1968        if current_counts.contains_key(key.as_str()) {
1969            continue;
1970        }
1971        let Some((path, name)) = identity_bucket_parts(key) else {
1972            continue;
1973        };
1974        if name == ANONYMOUS_FUNCTION_NAME || root.join(path).exists() {
1975            continue;
1976        }
1977        if let Some(candidates) = candidates_by_name.get(name)
1978            && let [only_candidate] = candidates.as_slice()
1979        {
1980            proposals.push((key.as_str(), only_candidate));
1981            *claims.entry(only_candidate).or_default() += 1;
1982        }
1983    }
1984
1985    proposals
1986        .into_iter()
1987        .filter(|(_, candidate)| claims.get(candidate) == Some(&1))
1988        .map(|(old, new)| (old.to_string(), new.to_string()))
1989        .collect()
1990}
1991
1992/// Baseline identity counts with retired buckets re-keyed to moved files.
1993///
1994/// Returns `None` when no bucket qualifies, so callers can keep borrowing the
1995/// original map.
1996fn identity_counts_with_move_tolerance(
1997    baseline_counts: &HealthFindingCountMap,
1998    current_counts: &HealthFindingCountMap,
1999    root: &Path,
2000) -> Option<HealthFindingCountMap> {
2001    let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
2002    if remaps.is_empty() {
2003        return None;
2004    }
2005    let mut remapped = baseline_counts.clone();
2006    for (old_key, new_key) in remaps {
2007        if let Some(entry) = remapped.remove(&old_key) {
2008            remapped.insert(new_key, entry);
2009        }
2010    }
2011    Some(remapped)
2012}
2013
2014fn health_finding_counts(
2015    findings: &[fallow_output::ComplexityViolation],
2016    root: &Path,
2017    mode: HealthBaselineMode,
2018) -> HealthFindingCountMap {
2019    let mut counts = BTreeMap::new();
2020    for finding in findings {
2021        let bucket = health_bucket_key(finding, root, mode);
2022        let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
2023        for category in health_finding_categories(finding).into_iter().flatten() {
2024            file_counts
2025                .entry(category.key().to_string())
2026                .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
2027                .or_insert(HealthBaselineCount { count: 1 });
2028        }
2029    }
2030    counts
2031}
2032
2033fn health_finding_categories(
2034    finding: &fallow_output::ComplexityViolation,
2035) -> [Option<HealthFindingCategory>; 2] {
2036    let complexity_category = HealthFindingCategory {
2037        dimension: HealthFindingDimension::Complexity,
2038        severity: finding.severity,
2039    };
2040    let crap_category = HealthFindingCategory {
2041        dimension: HealthFindingDimension::Crap,
2042        severity: finding.severity,
2043    };
2044    let has_complexity =
2045        finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
2046    let has_crap = finding.exceeded.includes_crap();
2047    [
2048        has_complexity.then_some(complexity_category),
2049        has_crap.then_some(crap_category),
2050    ]
2051}
2052
2053fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
2054    match severity {
2055        fallow_output::FindingSeverity::Moderate => 0,
2056        fallow_output::FindingSeverity::High => 1,
2057        fallow_output::FindingSeverity::Critical => 2,
2058    }
2059}
2060
2061fn severity_counts_for_dimension(
2062    file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
2063    dimension: HealthFindingDimension,
2064) -> [usize; 3] {
2065    let mut counts = [0; 3];
2066    for severity in [
2067        fallow_output::FindingSeverity::Moderate,
2068        fallow_output::FindingSeverity::High,
2069        fallow_output::FindingSeverity::Critical,
2070    ] {
2071        let category = HealthFindingCategory {
2072            dimension,
2073            severity,
2074        };
2075        counts[severity_index(severity)] = file_counts
2076            .and_then(|entries| entries.get(category.key()))
2077            .map_or(0, |entry| entry.count);
2078    }
2079    counts
2080}
2081
2082fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
2083    let mut available = baseline;
2084    let mut overflow = [false; 3];
2085
2086    for severity_idx in 0..3 {
2087        let compatible = available[severity_idx..].iter().sum::<usize>();
2088        overflow[severity_idx] = compatible < current[severity_idx];
2089
2090        let mut matched = current[severity_idx].min(compatible);
2091        for slot in available.iter_mut().skip(severity_idx) {
2092            let taken = matched.min(*slot);
2093            *slot -= taken;
2094            matched -= taken;
2095            if matched == 0 {
2096                break;
2097            }
2098        }
2099    }
2100
2101    overflow
2102}
2103
2104fn health_overflow_categories(
2105    current_counts: &HealthFindingCountMap,
2106    baseline_counts: &HealthFindingCountMap,
2107) -> FxHashMap<String, FxHashSet<&'static str>> {
2108    let mut overflow_by_path = FxHashMap::default();
2109
2110    for (path, current_file_counts) in current_counts {
2111        let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
2112        let baseline_file_counts = baseline_counts.get(path);
2113
2114        for dimension in HEALTH_FINDING_DIMENSIONS {
2115            let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
2116            let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
2117            let overflow = overflowing_severities(current, baseline);
2118
2119            for severity in [
2120                fallow_output::FindingSeverity::Moderate,
2121                fallow_output::FindingSeverity::High,
2122                fallow_output::FindingSeverity::Critical,
2123            ] {
2124                if overflow[severity_index(severity)] {
2125                    overflow_categories.insert(
2126                        HealthFindingCategory {
2127                            dimension,
2128                            severity,
2129                        }
2130                        .key(),
2131                    );
2132                }
2133            }
2134        }
2135
2136        if !overflow_categories.is_empty() {
2137            overflow_by_path.insert(path.clone(), overflow_categories);
2138        }
2139    }
2140
2141    overflow_by_path
2142}
2143
2144fn health_overlap_entry_count(
2145    current_counts: &HealthFindingCountMap,
2146    baseline_counts: &HealthFindingCountMap,
2147) -> usize {
2148    let mut overlap = 0;
2149
2150    for (path, baseline_file_counts) in baseline_counts {
2151        let current_file_counts = current_counts.get(path);
2152
2153        for dimension in HEALTH_FINDING_DIMENSIONS {
2154            let current_total: usize =
2155                severity_counts_for_dimension(current_file_counts, dimension)
2156                    .into_iter()
2157                    .sum();
2158            let baseline_total: usize =
2159                severity_counts_for_dimension(Some(baseline_file_counts), dimension)
2160                    .into_iter()
2161                    .sum();
2162            overlap += current_total.min(baseline_total);
2163        }
2164    }
2165
2166    overlap
2167}
2168
2169fn runtime_coverage_finding_key(
2170    finding: &fallow_output::RuntimeCoverageFinding,
2171    _root: &Path,
2172) -> String {
2173    finding
2174        .stable_id
2175        .clone()
2176        .unwrap_or_else(|| finding.id.clone())
2177}
2178
2179/// Line-move-tolerant writer key: `path\0name\0source_hash`.
2180///
2181/// Returns `None` when the finding carries no `source_hash` (e.g. a 0.5-shape
2182/// sidecar or an un-migrated producer); such findings fall back to the
2183/// line-sensitive `runtime_coverage_finding_key` for suppression. The NUL
2184/// separator avoids collisions with paths/names that contain `:`.
2185fn runtime_coverage_source_hash_key(
2186    finding: &fallow_output::RuntimeCoverageFinding,
2187    root: &Path,
2188) -> Option<String> {
2189    finding.source_hash.as_deref().map(|hash| {
2190        format!(
2191            "{}\0{}\0{}",
2192            relative_path(&finding.path, root),
2193            finding.function,
2194            hash
2195        )
2196    })
2197}
2198
2199/// Filter health findings to only include those not present in the baseline.
2200pub(crate) fn filter_new_health_findings(
2201    mut findings: Vec<fallow_output::ComplexityViolation>,
2202    baseline: &HealthBaselineData,
2203    root: &Path,
2204    mode: HealthBaselineMode,
2205) -> Vec<fallow_output::ComplexityViolation> {
2206    let baseline_counts = baseline.counts_for(mode);
2207    if !baseline_counts.is_empty() {
2208        let current_counts = health_finding_counts(&findings, root, mode);
2209        let remapped = (mode == HealthBaselineMode::Identity)
2210            .then(|| identity_counts_with_move_tolerance(baseline_counts, &current_counts, root))
2211            .flatten();
2212        let overflow_categories = health_overflow_categories(
2213            &current_counts,
2214            remapped.as_ref().unwrap_or(baseline_counts),
2215        );
2216        findings.retain(|finding| {
2217            let bucket = health_bucket_key(finding, root, mode);
2218            overflow_categories.get(&bucket).is_some_and(|categories| {
2219                health_finding_categories(finding)
2220                    .into_iter()
2221                    .flatten()
2222                    .any(|category| categories.contains(category.key()))
2223            })
2224        });
2225        return findings;
2226    }
2227
2228    let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
2229    findings.retain(|f| {
2230        let key = health_finding_key(f, root);
2231        !baseline_keys.contains(key.as_str())
2232    });
2233    findings
2234}
2235
2236pub(crate) fn filter_new_runtime_coverage_findings(
2237    mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
2238    baseline: &HealthBaselineData,
2239    root: &Path,
2240) -> Vec<fallow_output::RuntimeCoverageFinding> {
2241    let baseline_keys: FxHashSet<&str> = baseline
2242        .runtime_coverage_findings
2243        .iter()
2244        .map(String::as_str)
2245        .collect();
2246    let baseline_source_hash_keys: FxHashSet<&str> = baseline
2247        .runtime_coverage_source_hashes
2248        .iter()
2249        .map(String::as_str)
2250        .collect();
2251    findings.retain(|finding| {
2252        let suppressed_by_stable_id = finding
2253            .stable_id
2254            .as_deref()
2255            .is_some_and(|id| baseline_keys.contains(id));
2256        let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
2257        let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
2258            .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
2259        !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
2260    });
2261    findings
2262}
2263
2264/// Filter refactoring targets to only include those not present in the baseline.
2265pub(crate) fn filter_new_health_targets(
2266    mut targets: Vec<fallow_output::RefactoringTarget>,
2267    baseline: &HealthBaselineData,
2268    root: &Path,
2269) -> Vec<fallow_output::RefactoringTarget> {
2270    let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
2271    targets.retain(|t| {
2272        let key = target_baseline_key(t, root);
2273        !baseline_keys.contains(key.as_str())
2274    });
2275    targets
2276}
2277
2278/// Per-category delta between current results and a baseline.
2279#[derive(Debug, Clone, serde::Serialize)]
2280pub struct CategoryDelta {
2281    pub current: usize,
2282    pub baseline: usize,
2283    pub delta: i64,
2284}
2285
2286/// Deltas between current analysis results and a saved baseline.
2287///
2288/// Used in combined mode to show +/- counts in the failure summary and
2289/// to emit `baseline_deltas` in JSON output.
2290#[derive(Debug, Clone)]
2291pub struct BaselineDeltas {
2292    /// Net change in total issue count (positive = more issues).
2293    pub total_delta: i64,
2294    /// Per-category deltas keyed by category name.
2295    pub per_category: Vec<(String, CategoryDelta)>,
2296}
2297
2298#[cfg(test)]
2299mod tests {
2300    use super::*;
2301    use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2302    use crate::results::{
2303        AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2304        UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2305        UnusedFile,
2306    };
2307    use fallow_types::output_dead_code::{
2308        UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2309    };
2310    use std::path::PathBuf;
2311
2312    fn make_results() -> AnalysisResults {
2313        AnalysisResults {
2314            unused_files: vec![
2315                UnusedFileFinding::with_actions(UnusedFile {
2316                    path: PathBuf::from("src/old.ts"),
2317                }),
2318                UnusedFileFinding::with_actions(UnusedFile {
2319                    path: PathBuf::from("src/dead.ts"),
2320                }),
2321            ],
2322            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2323                path: PathBuf::from("src/utils.ts"),
2324                export_name: "helperA".to_string(),
2325                is_type_only: false,
2326                line: 5,
2327                col: 0,
2328                span_start: 40,
2329                is_re_export: false,
2330            })],
2331            unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2332                path: PathBuf::from("src/types.ts"),
2333                export_name: "OldType".to_string(),
2334                is_type_only: true,
2335                line: 10,
2336                col: 0,
2337                span_start: 100,
2338                is_re_export: false,
2339            })],
2340            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2341                package_name: "lodash".to_string(),
2342                location: DependencyLocation::Dependencies,
2343                path: PathBuf::from("package.json"),
2344                line: 5,
2345                used_in_workspaces: Vec::new(),
2346            })],
2347            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2348                UnusedDependency {
2349                    package_name: "jest".to_string(),
2350                    location: DependencyLocation::DevDependencies,
2351                    path: PathBuf::from("package.json"),
2352                    line: 5,
2353                    used_in_workspaces: Vec::new(),
2354                },
2355            )],
2356            ..Default::default()
2357        }
2358    }
2359
2360    #[test]
2361    fn baseline_from_results_captures_all_fields() {
2362        let results = make_results();
2363        let baseline = BaselineData::from_results(&results, Path::new(""));
2364        assert_eq!(baseline.unused_files.len(), 2);
2365        assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2366        assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2367        assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2368        assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2369        assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2370        assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2371    }
2372
2373    #[test]
2374    fn dependency_baseline_keys_include_package_json_path() {
2375        let root = Path::new("/repo");
2376        let results = AnalysisResults {
2377            unused_dependencies: vec![
2378                UnusedDependencyFinding::with_actions(UnusedDependency {
2379                    package_name: "lodash-es".to_string(),
2380                    location: DependencyLocation::Dependencies,
2381                    path: PathBuf::from("/repo/packages/app-a/package.json"),
2382                    line: 5,
2383                    used_in_workspaces: Vec::new(),
2384                }),
2385                UnusedDependencyFinding::with_actions(UnusedDependency {
2386                    package_name: "lodash-es".to_string(),
2387                    location: DependencyLocation::Dependencies,
2388                    path: PathBuf::from("/repo/packages/app-b/package.json"),
2389                    line: 5,
2390                    used_in_workspaces: Vec::new(),
2391                }),
2392            ],
2393            ..Default::default()
2394        };
2395
2396        let baseline = BaselineData::from_results(&results, root);
2397
2398        assert_eq!(
2399            baseline.unused_dependencies,
2400            vec![
2401                "packages/app-a/package.json:lodash-es",
2402                "packages/app-b/package.json:lodash-es"
2403            ]
2404        );
2405    }
2406
2407    #[test]
2408    fn dependency_baseline_filter_matches_path_before_package_name() {
2409        let root = Path::new("/repo");
2410        let results = AnalysisResults {
2411            unused_dependencies: vec![
2412                UnusedDependencyFinding::with_actions(UnusedDependency {
2413                    package_name: "lodash-es".to_string(),
2414                    location: DependencyLocation::Dependencies,
2415                    path: PathBuf::from("/repo/packages/app-a/package.json"),
2416                    line: 5,
2417                    used_in_workspaces: Vec::new(),
2418                }),
2419                UnusedDependencyFinding::with_actions(UnusedDependency {
2420                    package_name: "lodash-es".to_string(),
2421                    location: DependencyLocation::Dependencies,
2422                    path: PathBuf::from("/repo/packages/app-b/package.json"),
2423                    line: 5,
2424                    used_in_workspaces: Vec::new(),
2425                }),
2426            ],
2427            ..Default::default()
2428        };
2429        let baseline = BaselineData {
2430            unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2431            ..BaselineData::from_results(&AnalysisResults::default(), root)
2432        };
2433
2434        let filtered = filter_new_issues(results, &baseline, root);
2435
2436        assert_eq!(filtered.unused_dependencies.len(), 1);
2437        assert_eq!(
2438            filtered.unused_dependencies[0].dep.path,
2439            PathBuf::from("/repo/packages/app-b/package.json")
2440        );
2441    }
2442
2443    #[test]
2444    fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2445        let root = Path::new("/repo");
2446        let results = AnalysisResults {
2447            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2448                package_name: "lodash-es".to_string(),
2449                location: DependencyLocation::Dependencies,
2450                path: PathBuf::from("/repo/packages/app/package.json"),
2451                line: 5,
2452                used_in_workspaces: Vec::new(),
2453            })],
2454            ..Default::default()
2455        };
2456        let baseline = BaselineData {
2457            unused_dependencies: vec!["lodash-es".to_string()],
2458            ..BaselineData::from_results(&AnalysisResults::default(), root)
2459        };
2460
2461        let filtered = filter_new_issues(results, &baseline, root);
2462
2463        assert!(filtered.unused_dependencies.is_empty());
2464    }
2465
2466    #[test]
2467    fn baseline_serialization_roundtrip() {
2468        let results = make_results();
2469        let baseline = BaselineData::from_results(&results, Path::new(""));
2470        let json = serde_json::to_string(&baseline).unwrap();
2471        let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2472        assert_eq!(deserialized.unused_files, baseline.unused_files);
2473        assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2474        assert_eq!(deserialized.unused_types, baseline.unused_types);
2475        assert_eq!(
2476            deserialized.unused_dependencies,
2477            baseline.unused_dependencies
2478        );
2479        assert_eq!(
2480            deserialized.unused_dev_dependencies,
2481            baseline.unused_dev_dependencies
2482        );
2483    }
2484
2485    #[test]
2486    fn filter_removes_baseline_issues() {
2487        let results = make_results();
2488        let baseline = BaselineData::from_results(&results, Path::new(""));
2489        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2490        assert!(
2491            filtered.unused_files.is_empty(),
2492            "all files were in baseline"
2493        );
2494        assert!(
2495            filtered.unused_exports.is_empty(),
2496            "all exports were in baseline"
2497        );
2498        assert!(
2499            filtered.unused_types.is_empty(),
2500            "all types were in baseline"
2501        );
2502        assert!(
2503            filtered.unused_dependencies.is_empty(),
2504            "all deps were in baseline"
2505        );
2506        assert!(
2507            filtered.unused_dev_dependencies.is_empty(),
2508            "all dev deps were in baseline"
2509        );
2510    }
2511
2512    #[test]
2513    fn filter_keeps_new_issues_not_in_baseline() {
2514        let baseline = BaselineData {
2515            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2516            unused_files: vec!["src/old.ts".to_string()],
2517            unused_exports: vec![],
2518            unused_types: vec![],
2519            private_type_leaks: vec![],
2520            unused_dependencies: vec![],
2521            unused_dev_dependencies: vec![],
2522            circular_dependencies: vec![],
2523            re_export_cycles: vec![],
2524            unused_optional_dependencies: vec![],
2525            unused_enum_members: vec![],
2526            unused_class_members: vec![],
2527            unused_store_members: vec![],
2528            unprovided_injects: vec![],
2529            unrendered_components: vec![],
2530            unused_component_props: vec![],
2531            unused_component_emits: vec![],
2532            unused_component_inputs: vec![],
2533            unused_component_outputs: vec![],
2534            unused_svelte_events: vec![],
2535            unused_server_actions: vec![],
2536            unused_load_data_keys: vec![],
2537            unresolved_imports: vec![],
2538            unlisted_dependencies: vec![],
2539            duplicate_exports: vec![],
2540            type_only_dependencies: vec![],
2541            test_only_dependencies: vec![],
2542            dev_dependencies_in_production: vec![],
2543            boundary_violations: vec![],
2544            boundary_coverage_violations: vec![],
2545            boundary_call_violations: vec![],
2546            policy_violations: vec![],
2547            stale_suppressions: vec![],
2548            unused_catalog_entries: vec![],
2549            empty_catalog_groups: vec![],
2550            unresolved_catalog_references: vec![],
2551            unused_dependency_overrides: vec![],
2552            misconfigured_dependency_overrides: vec![],
2553            invalid_client_exports: vec![],
2554            mixed_client_server_barrels: vec![],
2555            misplaced_directives: vec![],
2556            route_collisions: vec![],
2557            dynamic_segment_name_conflicts: vec![],
2558        };
2559        let results = AnalysisResults {
2560            unused_files: vec![
2561                UnusedFileFinding::with_actions(UnusedFile {
2562                    path: PathBuf::from("src/old.ts"),
2563                }),
2564                UnusedFileFinding::with_actions(UnusedFile {
2565                    path: PathBuf::from("src/new-dead.ts"),
2566                }),
2567            ],
2568            ..Default::default()
2569        };
2570        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2571        assert_eq!(filtered.unused_files.len(), 1);
2572        assert_eq!(
2573            filtered.unused_files[0].file.path,
2574            PathBuf::from("src/new-dead.ts")
2575        );
2576    }
2577
2578    #[test]
2579    fn filter_with_empty_baseline_keeps_all() {
2580        let baseline = BaselineData {
2581            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2582            unused_files: vec![],
2583            unused_exports: vec![],
2584            unused_types: vec![],
2585            private_type_leaks: vec![],
2586            unused_dependencies: vec![],
2587            unused_dev_dependencies: vec![],
2588            circular_dependencies: vec![],
2589            re_export_cycles: vec![],
2590            unused_optional_dependencies: vec![],
2591            unused_enum_members: vec![],
2592            unused_class_members: vec![],
2593            unused_store_members: vec![],
2594            unprovided_injects: vec![],
2595            unrendered_components: vec![],
2596            unused_component_props: vec![],
2597            unused_component_emits: vec![],
2598            unused_component_inputs: vec![],
2599            unused_component_outputs: vec![],
2600            unused_svelte_events: vec![],
2601            unused_server_actions: vec![],
2602            unused_load_data_keys: vec![],
2603            unresolved_imports: vec![],
2604            unlisted_dependencies: vec![],
2605            duplicate_exports: vec![],
2606            type_only_dependencies: vec![],
2607            test_only_dependencies: vec![],
2608            dev_dependencies_in_production: vec![],
2609            boundary_violations: vec![],
2610            boundary_coverage_violations: vec![],
2611            boundary_call_violations: vec![],
2612            policy_violations: vec![],
2613            stale_suppressions: vec![],
2614            unused_catalog_entries: vec![],
2615            empty_catalog_groups: vec![],
2616            unresolved_catalog_references: vec![],
2617            unused_dependency_overrides: vec![],
2618            misconfigured_dependency_overrides: vec![],
2619            invalid_client_exports: vec![],
2620            mixed_client_server_barrels: vec![],
2621            misplaced_directives: vec![],
2622            route_collisions: vec![],
2623            dynamic_segment_name_conflicts: vec![],
2624        };
2625        let results = make_results();
2626        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2627        assert_eq!(filtered.unused_files.len(), 2);
2628        assert_eq!(filtered.unused_exports.len(), 1);
2629    }
2630
2631    #[test]
2632    fn filter_new_exports_by_file_and_name() {
2633        let baseline = BaselineData {
2634            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2635            unused_files: vec![],
2636            unused_exports: vec!["src/utils.ts:helperA".to_string()],
2637            unused_types: vec![],
2638            private_type_leaks: vec![],
2639            unused_dependencies: vec![],
2640            unused_dev_dependencies: vec![],
2641            circular_dependencies: vec![],
2642            re_export_cycles: vec![],
2643            unused_optional_dependencies: vec![],
2644            unused_enum_members: vec![],
2645            unused_class_members: vec![],
2646            unused_store_members: vec![],
2647            unprovided_injects: vec![],
2648            unrendered_components: vec![],
2649            unused_component_props: vec![],
2650            unused_component_emits: vec![],
2651            unused_component_inputs: vec![],
2652            unused_component_outputs: vec![],
2653            unused_svelte_events: vec![],
2654            unused_server_actions: vec![],
2655            unused_load_data_keys: vec![],
2656            unresolved_imports: vec![],
2657            unlisted_dependencies: vec![],
2658            duplicate_exports: vec![],
2659            type_only_dependencies: vec![],
2660            test_only_dependencies: vec![],
2661            dev_dependencies_in_production: vec![],
2662            boundary_violations: vec![],
2663            boundary_coverage_violations: vec![],
2664            boundary_call_violations: vec![],
2665            policy_violations: vec![],
2666            stale_suppressions: vec![],
2667            unused_catalog_entries: vec![],
2668            empty_catalog_groups: vec![],
2669            unresolved_catalog_references: vec![],
2670            unused_dependency_overrides: vec![],
2671            misconfigured_dependency_overrides: vec![],
2672            invalid_client_exports: vec![],
2673            mixed_client_server_barrels: vec![],
2674            misplaced_directives: vec![],
2675            route_collisions: vec![],
2676            dynamic_segment_name_conflicts: vec![],
2677        };
2678        let results = AnalysisResults {
2679            unused_exports: vec![
2680                UnusedExportFinding::with_actions(UnusedExport {
2681                    path: PathBuf::from("src/utils.ts"),
2682                    export_name: "helperA".to_string(),
2683                    is_type_only: false,
2684                    line: 5,
2685                    col: 0,
2686                    span_start: 40,
2687                    is_re_export: false,
2688                }),
2689                UnusedExportFinding::with_actions(UnusedExport {
2690                    path: PathBuf::from("src/utils.ts"),
2691                    export_name: "helperB".to_string(),
2692                    is_type_only: false,
2693                    line: 10,
2694                    col: 0,
2695                    span_start: 80,
2696                    is_re_export: false,
2697                }),
2698            ],
2699            ..Default::default()
2700        };
2701        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2702        assert_eq!(filtered.unused_exports.len(), 1);
2703        assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
2704    }
2705
2706    fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
2707        let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
2708        files.sort_unstable();
2709        let fragment = format!("shared body of {}", files.join(","));
2710        make_clone_group_with_fragment(&fragment, instances)
2711    }
2712
2713    fn make_clone_group_with_fragment(
2714        fragment: &str,
2715        instances: Vec<(&str, usize, usize)>,
2716    ) -> CloneGroup {
2717        CloneGroup {
2718            instances: instances
2719                .into_iter()
2720                .map(|(file, start, end)| CloneInstance {
2721                    file: PathBuf::from(file),
2722                    start_line: start,
2723                    end_line: end,
2724                    start_col: 0,
2725                    end_col: 0,
2726                    fragment: fragment.to_string(),
2727                })
2728                .collect(),
2729            token_count: 50,
2730            line_count: 10,
2731        }
2732    }
2733
2734    fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
2735        DuplicationReport {
2736            clone_groups: groups,
2737            clone_families: vec![],
2738            mirrored_directories: vec![],
2739            stats: DuplicationStats {
2740                total_files: 10,
2741                files_with_clones: 2,
2742                total_lines: 1000,
2743                duplicated_lines: 100,
2744                total_tokens: 5000,
2745                duplicated_tokens: 500,
2746                clone_groups: 1,
2747                clone_instances: 2,
2748                duplication_percentage: 10.0,
2749                clone_groups_below_min_occurrences: 0,
2750            },
2751        }
2752    }
2753
2754    #[test]
2755    fn clone_group_key_is_deterministic() {
2756        let root = Path::new("/project");
2757        let group = make_clone_group(vec![
2758            ("/project/src/a.ts", 1, 10),
2759            ("/project/src/b.ts", 5, 15),
2760        ]);
2761        let key1 = clone_group_key(&group, root);
2762        let key2 = clone_group_key(&group, root);
2763        assert_eq!(key1, key2);
2764    }
2765
2766    #[test]
2767    fn clone_group_key_is_sorted() {
2768        let root = Path::new("/project");
2769        let group_ab = make_clone_group(vec![
2770            ("/project/src/a.ts", 1, 10),
2771            ("/project/src/b.ts", 5, 15),
2772        ]);
2773        let group_ba = make_clone_group(vec![
2774            ("/project/src/b.ts", 5, 15),
2775            ("/project/src/a.ts", 1, 10),
2776        ]);
2777        assert_eq!(
2778            clone_group_key(&group_ab, root),
2779            clone_group_key(&group_ba, root),
2780            "key should be stable regardless of instance order"
2781        );
2782    }
2783
2784    #[test]
2785    fn duplication_baseline_roundtrip() {
2786        let root = Path::new("/project");
2787        let group = make_clone_group(vec![
2788            ("/project/src/a.ts", 1, 10),
2789            ("/project/src/b.ts", 5, 15),
2790        ]);
2791        let report = make_duplication_report(vec![group]);
2792        let baseline = DuplicationBaselineData::from_report(&report, root);
2793        let json = serde_json::to_string(&baseline).unwrap();
2794        let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
2795        assert_eq!(deserialized.clone_groups, baseline.clone_groups);
2796        assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
2797        assert_eq!(
2798            baseline.clone_fingerprints.len(),
2799            1,
2800            "a saved baseline carries a fingerprint key per clone group"
2801        );
2802    }
2803
2804    #[test]
2805    fn filter_new_clone_groups_matches_shifted_clone() {
2806        let root = Path::new("/project");
2807        let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
2808            "const total = a + b;",
2809            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2810        )]);
2811        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2812
2813        let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
2814            "const total = a + b;",
2815            vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
2816        )]);
2817        let filtered = filter_new_clone_groups(shifted, &baseline, root);
2818        assert!(
2819            filtered.clone_groups.is_empty(),
2820            "an unrelated line shift must not resurface a baselined clone"
2821        );
2822    }
2823
2824    #[test]
2825    fn filter_new_clone_groups_reports_extra_copy() {
2826        let root = Path::new("/project");
2827        let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
2828            "const total = a + b;",
2829            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2830        )]);
2831        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2832
2833        let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
2834            "const total = a + b;",
2835            vec![
2836                ("/project/src/a.ts", 10, 20),
2837                ("/project/src/b.ts", 30, 40),
2838                ("/project/src/c.ts", 5, 15),
2839            ],
2840        )]);
2841        let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
2842        assert_eq!(
2843            filtered.clone_groups.len(),
2844            1,
2845            "a fresh copy in a third file is a new finding"
2846        );
2847    }
2848
2849    #[test]
2850    fn filter_new_clone_groups_reads_legacy_baseline() {
2851        let root = Path::new("/project");
2852        let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
2853        let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
2854        assert_eq!(baseline.entry_count(), 1);
2855
2856        let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
2857            "const total = a + b;",
2858            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2859        )]);
2860        assert!(
2861            filter_new_clone_groups(unchanged, &baseline, root)
2862                .clone_groups
2863                .is_empty(),
2864            "a legacy baseline still matches on locations"
2865        );
2866
2867        let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
2868            "const total = a + b;",
2869            vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
2870        )]);
2871        assert_eq!(
2872            filter_new_clone_groups(shifted, &baseline, root)
2873                .clone_groups
2874                .len(),
2875            1,
2876            "legacy behavior is unchanged: a shift stops matching"
2877        );
2878    }
2879
2880    #[test]
2881    fn clone_group_fingerprint_key_survives_file_rename() {
2882        let before = make_clone_group_with_fragment(
2883            "const total = a + b;",
2884            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2885        );
2886        let after = make_clone_group_with_fragment(
2887            "const total = a + b;",
2888            vec![
2889                ("/project/src/renamed.ts", 10, 20),
2890                ("/project/src/b.ts", 30, 40),
2891            ],
2892        );
2893        assert_eq!(
2894            clone_group_fingerprint_key(&before),
2895            clone_group_fingerprint_key(&after),
2896            "renaming a file must not resurface a baselined clone"
2897        );
2898    }
2899
2900    #[test]
2901    fn clone_group_fingerprint_key_follows_representative_on_rename() {
2902        let mut before = make_clone_group_with_fragment(
2903            "const total = a + b;",
2904            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2905        );
2906        before.instances[1].fragment = "const total = a  +  b;".to_string();
2907
2908        let mut after = before.clone();
2909        after.instances[0].file = PathBuf::from("/project/src/z.ts");
2910
2911        assert_ne!(
2912            clone_group_fingerprint_key(&before),
2913            clone_group_fingerprint_key(&after),
2914            "the representative sorts on (file, line), so a rename past a \
2915             sibling instance rekeys a group whose fragments differ"
2916        );
2917    }
2918
2919    #[test]
2920    fn filter_new_clone_groups_removes_baseline() {
2921        let root = Path::new("/project");
2922        let group = make_clone_group(vec![
2923            ("/project/src/a.ts", 1, 10),
2924            ("/project/src/b.ts", 5, 15),
2925        ]);
2926        let report = make_duplication_report(vec![group]);
2927        let baseline = DuplicationBaselineData::from_report(&report, root);
2928        let filtered = filter_new_clone_groups(report, &baseline, root);
2929        assert!(
2930            filtered.clone_groups.is_empty(),
2931            "baseline group should be filtered out"
2932        );
2933    }
2934
2935    #[test]
2936    fn filter_new_clone_groups_keeps_new_groups() {
2937        let root = Path::new("/project");
2938        let baseline_group = make_clone_group(vec![
2939            ("/project/src/a.ts", 1, 10),
2940            ("/project/src/b.ts", 5, 15),
2941        ]);
2942        let new_group = make_clone_group(vec![
2943            ("/project/src/c.ts", 20, 30),
2944            ("/project/src/d.ts", 25, 35),
2945        ]);
2946        let baseline_report = make_duplication_report(vec![baseline_group]);
2947        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2948
2949        let report = make_duplication_report(vec![
2950            make_clone_group(vec![
2951                ("/project/src/a.ts", 1, 10),
2952                ("/project/src/b.ts", 5, 15),
2953            ]),
2954            new_group,
2955        ]);
2956        let filtered = filter_new_clone_groups(report, &baseline, root);
2957        assert_eq!(
2958            filtered.clone_groups.len(),
2959            1,
2960            "only the new group should remain"
2961        );
2962    }
2963
2964    #[test]
2965    fn recompute_stats_after_filtering() {
2966        let root = Path::new("/project");
2967        let group = make_clone_group(vec![
2968            ("/project/src/a.ts", 1, 10),
2969            ("/project/src/b.ts", 5, 15),
2970        ]);
2971        let report = make_duplication_report(vec![group]);
2972        let baseline = DuplicationBaselineData::from_report(&report, root);
2973        let filtered = filter_new_clone_groups(report, &baseline, root);
2974        assert_eq!(filtered.stats.clone_groups, 0);
2975        assert_eq!(filtered.stats.clone_instances, 0);
2976        assert_eq!(filtered.stats.duplicated_lines, 0);
2977    }
2978
2979    #[test]
2980    fn recompute_stats_zero_total_lines() {
2981        let report = DuplicationReport {
2982            clone_groups: vec![],
2983            clone_families: vec![],
2984            mirrored_directories: vec![],
2985            stats: DuplicationStats {
2986                total_files: 0,
2987                files_with_clones: 0,
2988                total_lines: 0,
2989                duplicated_lines: 0,
2990                total_tokens: 0,
2991                duplicated_tokens: 0,
2992                clone_groups: 0,
2993                clone_instances: 0,
2994                duplication_percentage: 0.0,
2995                clone_groups_below_min_occurrences: 0,
2996            },
2997        };
2998        let stats = super::recompute_stats(&report);
2999        assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
3000    }
3001
3002    /// Count-mode wrapper shadowing the mode-aware function, so the existing
3003    /// count-mode expectations stay readable. Identity-mode tests call
3004    /// `super::filter_new_health_findings` directly.
3005    fn filter_new_health_findings(
3006        findings: Vec<fallow_output::ComplexityViolation>,
3007        baseline: &HealthBaselineData,
3008        root: &Path,
3009    ) -> Vec<fallow_output::ComplexityViolation> {
3010        super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
3011    }
3012
3013    fn make_health_finding(
3014        root: &Path,
3015        name: &str,
3016        line: u32,
3017    ) -> fallow_output::ComplexityViolation {
3018        make_health_finding_with(
3019            root,
3020            name,
3021            line,
3022            fallow_output::ExceededThreshold::Both,
3023            fallow_output::FindingSeverity::High,
3024        )
3025    }
3026
3027    fn make_health_finding_with(
3028        root: &Path,
3029        name: &str,
3030        line: u32,
3031        exceeded: fallow_output::ExceededThreshold,
3032        severity: fallow_output::FindingSeverity,
3033    ) -> fallow_output::ComplexityViolation {
3034        fallow_output::ComplexityViolation {
3035            path: root.join("src/utils.ts"),
3036            name: name.to_string(),
3037            line,
3038            col: 0,
3039            cyclomatic: 25,
3040            cognitive: 30,
3041            line_count: 80,
3042            param_count: 0,
3043            react_hook_count: 0,
3044            react_jsx_max_depth: 0,
3045            react_prop_count: 0,
3046            react_hook_profile: None,
3047            exceeded,
3048            severity,
3049            crap: None,
3050            coverage_pct: None,
3051            coverage_tier: None,
3052            coverage_source: None,
3053            inherited_from: None,
3054            component_rollup: None,
3055            contributions: Vec::new(),
3056            effective_thresholds: None,
3057            threshold_source: None,
3058        }
3059    }
3060
3061    #[test]
3062    fn health_baseline_roundtrip() {
3063        let root = PathBuf::from("/project");
3064        let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3065        let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3066        let json = serde_json::to_string(&baseline).unwrap();
3067        let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
3068        assert_eq!(deserialized.findings, baseline.findings);
3069        assert_eq!(baseline.findings, Vec::<String>::new());
3070        assert_eq!(
3071            deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
3072            1
3073        );
3074        assert!(!json.contains("parseExpression"));
3075    }
3076
3077    #[test]
3078    fn health_baseline_filters_known_findings() {
3079        let root = PathBuf::from("/project");
3080        let mut findings = vec![
3081            make_health_finding(&root, "parseExpression", 42),
3082            make_health_finding(&root, "newFunction", 100),
3083        ];
3084        findings[1].path = root.join("src/other.ts");
3085        let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
3086        let filtered = filter_new_health_findings(findings, &baseline, &root);
3087        assert_eq!(filtered.len(), 1);
3088        assert_eq!(filtered[0].name, "newFunction");
3089    }
3090
3091    #[test]
3092    fn health_baseline_filters_shifted_lines_with_same_category_count() {
3093        let root = PathBuf::from("/project");
3094        let baseline = HealthBaselineData::from_findings(
3095            &[make_health_finding(&root, "parseExpression", 42)],
3096            &[],
3097            &[],
3098            &root,
3099        );
3100        let filtered = filter_new_health_findings(
3101            vec![make_health_finding(&root, "parseExpression", 43)],
3102            &baseline,
3103            &root,
3104        );
3105        assert!(filtered.is_empty());
3106    }
3107
3108    #[test]
3109    fn health_baseline_reports_full_category_when_count_increases() {
3110        let root = PathBuf::from("/project");
3111        let baseline = HealthBaselineData::from_findings(
3112            &[make_health_finding(&root, "parseExpression", 42)],
3113            &[],
3114            &[],
3115            &root,
3116        );
3117        let filtered = filter_new_health_findings(
3118            vec![
3119                make_health_finding(&root, "parseExpression", 43),
3120                make_health_finding(&root, "newFunction", 100),
3121            ],
3122            &baseline,
3123            &root,
3124        );
3125        assert_eq!(filtered.len(), 2);
3126    }
3127
3128    #[test]
3129    fn health_baseline_legacy_findings_still_load() {
3130        let root = PathBuf::from("/project");
3131        let baseline = HealthBaselineData {
3132            findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
3133            finding_counts: BTreeMap::new(),
3134            identity_finding_counts: BTreeMap::new(),
3135            target_keys: vec![],
3136            runtime_coverage_findings: vec![],
3137            runtime_coverage_source_hashes: vec![],
3138        };
3139        let filtered = filter_new_health_findings(
3140            vec![make_health_finding(&root, "parseExpression", 42)],
3141            &baseline,
3142            &root,
3143        );
3144        assert!(filtered.is_empty());
3145    }
3146
3147    #[test]
3148    fn health_baseline_keeps_crap_categories_separate_from_complexity() {
3149        let root = PathBuf::from("/project");
3150        let baseline = HealthBaselineData::from_findings(
3151            &[make_health_finding_with(
3152                &root,
3153                "parseExpression",
3154                42,
3155                fallow_output::ExceededThreshold::Crap,
3156                fallow_output::FindingSeverity::High,
3157            )],
3158            &[],
3159            &[],
3160            &root,
3161        );
3162        let filtered = filter_new_health_findings(
3163            vec![
3164                make_health_finding_with(
3165                    &root,
3166                    "parseExpression",
3167                    43,
3168                    fallow_output::ExceededThreshold::Crap,
3169                    fallow_output::FindingSeverity::High,
3170                ),
3171                make_health_finding(&root, "newComplexityOnlyFunction", 100),
3172            ],
3173            &baseline,
3174            &root,
3175        );
3176        assert_eq!(filtered.len(), 1);
3177        assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
3178    }
3179
3180    #[test]
3181    fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
3182        let root = PathBuf::from("/project");
3183        let baseline = HealthBaselineData::from_findings(
3184            &[make_health_finding_with(
3185                &root,
3186                "parseExpression",
3187                42,
3188                fallow_output::ExceededThreshold::Both,
3189                fallow_output::FindingSeverity::Critical,
3190            )],
3191            &[],
3192            &[],
3193            &root,
3194        );
3195        let filtered = filter_new_health_findings(
3196            vec![make_health_finding_with(
3197                &root,
3198                "parseExpression",
3199                42,
3200                fallow_output::ExceededThreshold::Both,
3201                fallow_output::FindingSeverity::High,
3202            )],
3203            &baseline,
3204            &root,
3205        );
3206        assert!(filtered.is_empty());
3207    }
3208
3209    #[test]
3210    fn health_baseline_still_reports_worse_current_severity_as_new() {
3211        let root = PathBuf::from("/project");
3212        let baseline = HealthBaselineData::from_findings(
3213            &[make_health_finding_with(
3214                &root,
3215                "parseExpression",
3216                42,
3217                fallow_output::ExceededThreshold::Both,
3218                fallow_output::FindingSeverity::High,
3219            )],
3220            &[],
3221            &[],
3222            &root,
3223        );
3224        let filtered = filter_new_health_findings(
3225            vec![make_health_finding_with(
3226                &root,
3227                "parseExpression",
3228                42,
3229                fallow_output::ExceededThreshold::Both,
3230                fallow_output::FindingSeverity::Critical,
3231            )],
3232            &baseline,
3233            &root,
3234        );
3235        assert_eq!(filtered.len(), 1);
3236        assert_eq!(filtered[0].name, "parseExpression");
3237        assert!(matches!(
3238            filtered[0].severity,
3239            fallow_output::FindingSeverity::Critical
3240        ));
3241    }
3242
3243    #[test]
3244    fn health_baseline_overlap_counts_partial_category_overflow() {
3245        let root = PathBuf::from("/project");
3246        let baseline = HealthBaselineData::from_findings(
3247            &[make_health_finding(&root, "parseExpression", 42)],
3248            &[],
3249            &[],
3250            &root,
3251        );
3252        let overlap = baseline.overlap_entries(
3253            &[
3254                make_health_finding(&root, "parseExpression", 42),
3255                make_health_finding(&root, "newFunction", 100),
3256            ],
3257            &root,
3258            HealthBaselineMode::Count,
3259        );
3260        assert_eq!(overlap.matched_entries, 1);
3261        assert_eq!(overlap.moved_entries, 0);
3262    }
3263
3264    /// Baseline saved the way `--baseline-mode identity` saves it.
3265    fn identity_baseline(
3266        findings: &[fallow_output::ComplexityViolation],
3267        root: &Path,
3268    ) -> HealthBaselineData {
3269        HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
3270    }
3271
3272    #[test]
3273    fn health_identity_baseline_reports_replacement_hotspot() {
3274        let root = PathBuf::from("/project");
3275        let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
3276        let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
3277
3278        assert!(
3279            filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
3280            "count mode keeps the per-file allowance and suppresses the replacement"
3281        );
3282
3283        let filtered = super::filter_new_health_findings(
3284            replacement,
3285            &baseline,
3286            &root,
3287            HealthBaselineMode::Identity,
3288        );
3289        assert_eq!(filtered.len(), 1);
3290        assert_eq!(filtered[0].name, "replacementHotspot");
3291    }
3292
3293    #[test]
3294    fn health_identity_baseline_survives_line_moves() {
3295        let root = PathBuf::from("/project");
3296        let baseline =
3297            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3298        let filtered = super::filter_new_health_findings(
3299            vec![make_health_finding(&root, "parseExpression", 512)],
3300            &baseline,
3301            &root,
3302            HealthBaselineMode::Identity,
3303        );
3304        assert!(filtered.is_empty());
3305    }
3306
3307    #[test]
3308    fn health_identity_baseline_suppresses_severity_improvement() {
3309        let root = PathBuf::from("/project");
3310        let baseline = identity_baseline(
3311            &[make_health_finding_with(
3312                &root,
3313                "parseExpression",
3314                42,
3315                fallow_output::ExceededThreshold::Both,
3316                fallow_output::FindingSeverity::Critical,
3317            )],
3318            &root,
3319        );
3320        let filtered = super::filter_new_health_findings(
3321            vec![make_health_finding_with(
3322                &root,
3323                "parseExpression",
3324                42,
3325                fallow_output::ExceededThreshold::Both,
3326                fallow_output::FindingSeverity::Moderate,
3327            )],
3328            &baseline,
3329            &root,
3330            HealthBaselineMode::Identity,
3331        );
3332        assert!(filtered.is_empty());
3333    }
3334
3335    #[test]
3336    fn health_identity_baseline_reports_added_finding_for_known_function() {
3337        let root = PathBuf::from("/project");
3338        let baseline =
3339            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3340        let filtered = super::filter_new_health_findings(
3341            vec![
3342                make_health_finding(&root, "parseExpression", 42),
3343                make_health_finding(&root, "parseStatement", 90),
3344            ],
3345            &baseline,
3346            &root,
3347            HealthBaselineMode::Identity,
3348        );
3349        assert_eq!(filtered.len(), 1);
3350        assert_eq!(filtered[0].name, "parseStatement");
3351    }
3352
3353    #[test]
3354    fn health_identity_buckets_are_written_only_in_identity_mode() {
3355        let root = PathBuf::from("/project");
3356        let findings = [make_health_finding(&root, "parseExpression", 42)];
3357        let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3358        let json = serde_json::to_string(&count_only).unwrap();
3359        assert!(!json.contains("identity_finding_counts"));
3360        assert!(count_only.lacks_identity_data());
3361
3362        let identity = identity_baseline(&findings, &root);
3363        assert!(!identity.lacks_identity_data());
3364        assert_eq!(
3365            identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
3366                .count,
3367            1
3368        );
3369        assert!(
3370            !identity.finding_counts.is_empty(),
3371            "an identity baseline stays readable in count mode"
3372        );
3373    }
3374
3375    fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
3376        let mut finding = make_health_finding(root, name, 42);
3377        finding.path = root.join(path);
3378        finding
3379    }
3380
3381    #[test]
3382    fn health_identity_baseline_follows_file_move() {
3383        let root = PathBuf::from("/project");
3384        let baseline =
3385            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3386        let moved = vec![moved_finding(
3387            &root,
3388            "src/parser/utils.ts",
3389            "parseExpression",
3390        )];
3391
3392        let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
3393        assert_eq!(
3394            overlap.matched_entries, 1,
3395            "a followed move counts as matched, not stale"
3396        );
3397        assert_eq!(
3398            overlap.moved_entries, 1,
3399            "the followed move stays observable as a moved entry"
3400        );
3401        let filtered = super::filter_new_health_findings(
3402            moved,
3403            &baseline,
3404            &root,
3405            HealthBaselineMode::Identity,
3406        );
3407        assert!(filtered.is_empty());
3408    }
3409
3410    #[test]
3411    fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
3412        let root = PathBuf::from("/project");
3413        let baseline =
3414            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3415        let filtered = super::filter_new_health_findings(
3416            vec![
3417                moved_finding(&root, "src/a.ts", "parseExpression"),
3418                moved_finding(&root, "src/b.ts", "parseExpression"),
3419            ],
3420            &baseline,
3421            &root,
3422            HealthBaselineMode::Identity,
3423        );
3424        assert_eq!(filtered.len(), 2);
3425    }
3426
3427    #[test]
3428    fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
3429        let root = PathBuf::from("/project");
3430        let baseline = identity_baseline(
3431            &[
3432                moved_finding(&root, "src/a.ts", "parseExpression"),
3433                moved_finding(&root, "src/b.ts", "parseExpression"),
3434            ],
3435            &root,
3436        );
3437        let filtered = super::filter_new_health_findings(
3438            vec![moved_finding(&root, "src/c.ts", "parseExpression")],
3439            &baseline,
3440            &root,
3441            HealthBaselineMode::Identity,
3442        );
3443        assert_eq!(filtered.len(), 1);
3444    }
3445
3446    #[test]
3447    fn health_identity_move_is_not_followed_when_old_path_still_exists() {
3448        // The manifest dir makes the saved path a file that really exists, so
3449        // the function was fixed or deleted in place rather than moved.
3450        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3451        let baseline = identity_baseline(
3452            &[moved_finding(&root, "src/baseline.rs", "parseExpression")],
3453            &root,
3454        );
3455        let filtered = super::filter_new_health_findings(
3456            vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
3457            &baseline,
3458            &root,
3459            HealthBaselineMode::Identity,
3460        );
3461        assert_eq!(filtered.len(), 1);
3462    }
3463
3464    #[test]
3465    fn health_identity_move_is_not_followed_for_anonymous_functions() {
3466        let root = PathBuf::from("/project");
3467        let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
3468        let filtered = super::filter_new_health_findings(
3469            vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
3470            &baseline,
3471            &root,
3472            HealthBaselineMode::Identity,
3473        );
3474        assert_eq!(filtered.len(), 1);
3475    }
3476
3477    #[test]
3478    fn health_count_baseline_does_not_follow_file_moves() {
3479        let root = PathBuf::from("/project");
3480        let baseline = HealthBaselineData::from_findings(
3481            &[make_health_finding(&root, "parseExpression", 42)],
3482            &[],
3483            &[],
3484            &root,
3485        );
3486        let filtered = filter_new_health_findings(
3487            vec![moved_finding(
3488                &root,
3489                "src/parser/utils.ts",
3490                "parseExpression",
3491            )],
3492            &baseline,
3493            &root,
3494        );
3495        assert_eq!(filtered.len(), 1);
3496    }
3497
3498    #[test]
3499    fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
3500        let legacy = HealthBaselineData {
3501            findings: vec!["src/utils.ts:parseExpression:42".to_string()],
3502            ..HealthBaselineData::default()
3503        };
3504        assert!(legacy.lacks_identity_data());
3505        assert!(!HealthBaselineData::default().lacks_identity_data());
3506    }
3507
3508    #[test]
3509    fn health_baseline_empty_keeps_all() {
3510        let root = PathBuf::from("/project");
3511        let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3512        let baseline = HealthBaselineData {
3513            findings: vec![],
3514            finding_counts: BTreeMap::new(),
3515            identity_finding_counts: BTreeMap::new(),
3516            target_keys: vec![],
3517            runtime_coverage_findings: vec![],
3518            runtime_coverage_source_hashes: vec![],
3519        };
3520        let filtered = filter_new_health_findings(findings, &baseline, &root);
3521        assert_eq!(filtered.len(), 1);
3522    }
3523
3524    #[test]
3525    fn circular_dep_key_is_order_independent() {
3526        use crate::results::CircularDependency;
3527
3528        let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
3529            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3530            length: 2,
3531            line: 1,
3532            col: 0,
3533            edges: Vec::new(),
3534            is_cross_package: false,
3535        });
3536        let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
3537            files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
3538            length: 2,
3539            line: 1,
3540            col: 0,
3541            edges: Vec::new(),
3542            is_cross_package: false,
3543        });
3544        assert_eq!(
3545            super::circular_dep_key(&dep_ab.cycle, Path::new("")),
3546            super::circular_dep_key(&dep_ba.cycle, Path::new("")),
3547            "same files in different order should produce identical keys"
3548        );
3549    }
3550
3551    #[test]
3552    fn circular_dep_key_different_files_different_keys() {
3553        use crate::results::CircularDependency;
3554
3555        let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
3556            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3557            length: 2,
3558            line: 1,
3559            col: 0,
3560            edges: Vec::new(),
3561            is_cross_package: false,
3562        });
3563        let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
3564            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
3565            length: 2,
3566            line: 1,
3567            col: 0,
3568            edges: Vec::new(),
3569            is_cross_package: false,
3570        });
3571        assert_ne!(
3572            super::circular_dep_key(&dep1.cycle, Path::new("")),
3573            super::circular_dep_key(&dep2.cycle, Path::new("")),
3574        );
3575    }
3576
3577    #[test]
3578    fn circular_dep_key_three_files_order_independent() {
3579        use crate::results::CircularDependency;
3580
3581        let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
3582            files: vec![
3583                PathBuf::from("src/a.ts"),
3584                PathBuf::from("src/b.ts"),
3585                PathBuf::from("src/c.ts"),
3586            ],
3587            length: 3,
3588            line: 1,
3589            col: 0,
3590            edges: Vec::new(),
3591            is_cross_package: false,
3592        });
3593        let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
3594            files: vec![
3595                PathBuf::from("src/c.ts"),
3596                PathBuf::from("src/a.ts"),
3597                PathBuf::from("src/b.ts"),
3598            ],
3599            length: 3,
3600            line: 1,
3601            col: 0,
3602            edges: Vec::new(),
3603            is_cross_package: false,
3604        });
3605        assert_eq!(
3606            super::circular_dep_key(&dep_abc.cycle, Path::new("")),
3607            super::circular_dep_key(&dep_cab.cycle, Path::new("")),
3608        );
3609    }
3610
3611    #[expect(
3612        clippy::too_many_lines,
3613        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3614    )]
3615    fn make_full_results() -> AnalysisResults {
3616        use crate::results::*;
3617        use crate::source::MemberKind;
3618
3619        let mut r = make_results();
3620        r.circular_dependencies
3621            .push(CircularDependencyFinding::with_actions(
3622                CircularDependency {
3623                    files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3624                    length: 2,
3625                    line: 1,
3626                    col: 0,
3627                    edges: Vec::new(),
3628                    is_cross_package: false,
3629                },
3630            ));
3631        r.unused_optional_dependencies
3632            .push(UnusedOptionalDependencyFinding::with_actions(
3633                UnusedDependency {
3634                    package_name: "fsevents".to_string(),
3635                    location: DependencyLocation::OptionalDependencies,
3636                    path: PathBuf::from("package.json"),
3637                    line: 15,
3638                    used_in_workspaces: Vec::new(),
3639                },
3640            ));
3641        r.unused_enum_members
3642            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3643                path: PathBuf::from("src/enums.ts"),
3644                parent_name: "Status".to_string(),
3645                member_name: "Deprecated".to_string(),
3646                kind: MemberKind::EnumMember,
3647                line: 8,
3648                col: 0,
3649            }));
3650        r.unused_class_members
3651            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
3652                path: PathBuf::from("src/service.ts"),
3653                parent_name: "UserService".to_string(),
3654                member_name: "legacy".to_string(),
3655                kind: MemberKind::ClassMethod,
3656                line: 42,
3657                col: 0,
3658            }));
3659        r.unused_store_members
3660            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
3661                path: PathBuf::from("src/store.ts"),
3662                parent_name: "useStore".to_string(),
3663                member_name: "legacyAction".to_string(),
3664                kind: MemberKind::StoreMember,
3665                line: 17,
3666                col: 0,
3667            }));
3668        r.unresolved_imports.push(
3669            fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
3670                crate::results::UnresolvedImport {
3671                    path: PathBuf::from("src/app.ts"),
3672                    specifier: "./missing".to_string(),
3673                    line: 3,
3674                    col: 0,
3675                    specifier_col: 0,
3676                },
3677            ),
3678        );
3679        r.unlisted_dependencies
3680            .push(crate::results::UnlistedDependencyFinding::with_actions(
3681                UnlistedDependency {
3682                    package_name: "chalk".to_string(),
3683                    imported_from: vec![],
3684                },
3685            ));
3686        r.duplicate_exports
3687            .push(crate::results::DuplicateExportFinding::with_actions(
3688                crate::results::DuplicateExport {
3689                    export_name: "Config".to_string(),
3690                    locations: vec![
3691                        crate::results::DuplicateLocation {
3692                            path: PathBuf::from("src/a.ts"),
3693                            line: 1,
3694                            col: 0,
3695                        },
3696                        crate::results::DuplicateLocation {
3697                            path: PathBuf::from("src/b.ts"),
3698                            line: 5,
3699                            col: 0,
3700                        },
3701                    ],
3702                },
3703            ));
3704        r.type_only_dependencies
3705            .push(crate::results::TypeOnlyDependencyFinding::with_actions(
3706                TypeOnlyDependency {
3707                    package_name: "zod".to_string(),
3708                    path: PathBuf::from("package.json"),
3709                    line: 8,
3710                },
3711            ));
3712        r.test_only_dependencies
3713            .push(crate::results::TestOnlyDependencyFinding::with_actions(
3714                TestOnlyDependency {
3715                    package_name: "vitest".to_string(),
3716                    path: PathBuf::from("package.json"),
3717                    line: 10,
3718                },
3719            ));
3720        r.boundary_violations.push(
3721            fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
3722                crate::results::BoundaryViolation {
3723                    from_path: PathBuf::from("src/ui/btn.ts"),
3724                    to_path: PathBuf::from("src/db/query.ts"),
3725                    from_zone: "ui".to_string(),
3726                    to_zone: "db".to_string(),
3727                    import_specifier: "../db/query".to_string(),
3728                    line: 1,
3729                    col: 0,
3730                },
3731            ),
3732        );
3733        r
3734    }
3735
3736    #[test]
3737    fn baseline_from_results_captures_all_extended_fields() {
3738        let results = make_full_results();
3739        let baseline = BaselineData::from_results(&results, Path::new(""));
3740        assert_eq!(baseline.circular_dependencies.len(), 1);
3741        assert_eq!(
3742            baseline.unused_optional_dependencies,
3743            vec!["package.json:fsevents"]
3744        );
3745        assert_eq!(baseline.unused_enum_members.len(), 1);
3746        assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
3747        assert_eq!(baseline.unused_class_members.len(), 1);
3748        assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
3749        assert_eq!(baseline.unused_store_members.len(), 1);
3750        assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
3751        assert_eq!(baseline.unresolved_imports.len(), 1);
3752        assert!(baseline.unresolved_imports[0].contains("./missing"));
3753        assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
3754        assert_eq!(baseline.duplicate_exports.len(), 1);
3755        assert!(baseline.duplicate_exports[0].starts_with("Config|"));
3756        assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
3757        assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
3758        assert_eq!(baseline.boundary_violations.len(), 1);
3759        assert!(baseline.boundary_violations[0].contains("->"));
3760    }
3761
3762    #[test]
3763    fn filter_removes_all_extended_baseline_issues() {
3764        let results = make_full_results();
3765        let baseline = BaselineData::from_results(&results, Path::new(""));
3766        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3767        assert!(filtered.circular_dependencies.is_empty());
3768        assert!(filtered.unused_optional_dependencies.is_empty());
3769        assert!(filtered.unused_enum_members.is_empty());
3770        assert!(filtered.unused_class_members.is_empty());
3771        assert!(filtered.unused_store_members.is_empty());
3772        assert!(filtered.unresolved_imports.is_empty());
3773        assert!(filtered.unlisted_dependencies.is_empty());
3774        assert!(filtered.duplicate_exports.is_empty());
3775        assert!(filtered.type_only_dependencies.is_empty());
3776        assert!(filtered.test_only_dependencies.is_empty());
3777        assert!(filtered.boundary_violations.is_empty());
3778    }
3779
3780    #[test]
3781    fn filter_keeps_new_circular_deps() {
3782        use crate::results::CircularDependency;
3783        let baseline = BaselineData {
3784            circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
3785            ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3786        };
3787        let mut results = AnalysisResults::default();
3788        results
3789            .circular_dependencies
3790            .push(CircularDependencyFinding::with_actions(
3791                CircularDependency {
3792                    files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3793                    length: 2,
3794                    line: 1,
3795                    col: 0,
3796                    edges: Vec::new(),
3797                    is_cross_package: false,
3798                },
3799            ));
3800        results
3801            .circular_dependencies
3802            .push(CircularDependencyFinding::with_actions(
3803                CircularDependency {
3804                    files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
3805                    length: 2,
3806                    line: 5,
3807                    col: 0,
3808                    edges: Vec::new(),
3809                    is_cross_package: false,
3810                },
3811            ));
3812        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3813        assert_eq!(filtered.circular_dependencies.len(), 1);
3814    }
3815
3816    #[test]
3817    fn filter_keeps_new_boundary_violations() {
3818        use crate::results::BoundaryViolation;
3819        let baseline = BaselineData {
3820            boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
3821            boundary_coverage_violations: vec![],
3822            boundary_call_violations: vec![],
3823            policy_violations: vec![],
3824            ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3825        };
3826        let mut results = AnalysisResults::default();
3827        results
3828            .boundary_violations
3829            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3830                from_path: PathBuf::from("src/a.ts"),
3831                to_path: PathBuf::from("src/b.ts"),
3832                from_zone: "a".to_string(),
3833                to_zone: "b".to_string(),
3834                import_specifier: "../b".to_string(),
3835                line: 1,
3836                col: 0,
3837            }));
3838        results
3839            .boundary_violations
3840            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3841                from_path: PathBuf::from("src/new.ts"),
3842                to_path: PathBuf::from("src/secret.ts"),
3843                from_zone: "new".to_string(),
3844                to_zone: "secret".to_string(),
3845                import_specifier: "../secret".to_string(),
3846                line: 1,
3847                col: 0,
3848            }));
3849        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3850        assert_eq!(filtered.boundary_violations.len(), 1);
3851    }
3852
3853    #[test]
3854    fn health_targets_baseline_filters_known() {
3855        let root = PathBuf::from("/project");
3856        let targets = vec![
3857            fallow_output::RefactoringTarget {
3858                path: root.join("src/complex.ts"),
3859                priority: 80.0,
3860                efficiency: 40.0,
3861                recommendation: "Split file".to_string(),
3862                category: fallow_output::RecommendationCategory::SplitHighImpact,
3863                effort: fallow_output::EffortEstimate::Medium,
3864                confidence: fallow_output::Confidence::Medium,
3865                factors: vec![],
3866                evidence: None,
3867            },
3868            fallow_output::RefactoringTarget {
3869                path: root.join("src/new-issue.ts"),
3870                priority: 60.0,
3871                efficiency: 30.0,
3872                recommendation: "Extract function".to_string(),
3873                category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
3874                effort: fallow_output::EffortEstimate::Low,
3875                confidence: fallow_output::Confidence::High,
3876                factors: vec![],
3877                evidence: None,
3878            },
3879        ];
3880        let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
3881        let filtered = filter_new_health_targets(targets, &baseline, &root);
3882        assert_eq!(filtered.len(), 1);
3883        assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
3884    }
3885
3886    #[test]
3887    fn duplicate_export_key_is_sorted() {
3888        use crate::results::{DuplicateExport, DuplicateLocation};
3889        let dup_ab = DuplicateExport {
3890            export_name: "foo".to_string(),
3891            locations: vec![
3892                DuplicateLocation {
3893                    path: PathBuf::from("src/a.ts"),
3894                    line: 1,
3895                    col: 0,
3896                },
3897                DuplicateLocation {
3898                    path: PathBuf::from("src/b.ts"),
3899                    line: 5,
3900                    col: 0,
3901                },
3902            ],
3903        };
3904        let dup_ba = DuplicateExport {
3905            export_name: "foo".to_string(),
3906            locations: vec![
3907                DuplicateLocation {
3908                    path: PathBuf::from("src/b.ts"),
3909                    line: 5,
3910                    col: 0,
3911                },
3912                DuplicateLocation {
3913                    path: PathBuf::from("src/a.ts"),
3914                    line: 1,
3915                    col: 0,
3916                },
3917            ],
3918        };
3919        assert_eq!(
3920            super::duplicate_export_key(&dup_ab, Path::new("")),
3921            super::duplicate_export_key(&dup_ba, Path::new("")),
3922        );
3923    }
3924
3925    #[test]
3926    fn boundary_violation_key_format() {
3927        use crate::results::BoundaryViolation;
3928        let v = BoundaryViolation {
3929            from_path: PathBuf::from("src/ui/btn.ts"),
3930            to_path: PathBuf::from("src/db/query.ts"),
3931            from_zone: "ui".to_string(),
3932            to_zone: "db".to_string(),
3933            import_specifier: "../db/query".to_string(),
3934            line: 1,
3935            col: 0,
3936        };
3937        let key = super::boundary_violation_key(&v, Path::new(""));
3938        assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
3939    }
3940
3941    /// Build results with absolute paths rooted at the given prefix.
3942    fn make_absolute_results(root: &str) -> AnalysisResults {
3943        use crate::results::*;
3944        use crate::source::MemberKind;
3945
3946        let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
3947
3948        AnalysisResults {
3949            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
3950                path: p("src/old.ts"),
3951            })],
3952            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3953                path: p("src/utils.ts"),
3954                export_name: "helper".to_string(),
3955                is_type_only: false,
3956                line: 5,
3957                col: 0,
3958                span_start: 40,
3959                is_re_export: false,
3960            })],
3961            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3962                package_name: "lodash-es".to_string(),
3963                location: DependencyLocation::Dependencies,
3964                path: p("packages/app/package.json"),
3965                line: 5,
3966                used_in_workspaces: Vec::new(),
3967            })],
3968            circular_dependencies: vec![CircularDependencyFinding::with_actions(
3969                CircularDependency {
3970                    files: vec![p("src/a.ts"), p("src/b.ts")],
3971                    length: 2,
3972                    line: 1,
3973                    col: 0,
3974                    edges: Vec::new(),
3975                    is_cross_package: false,
3976                },
3977            )],
3978            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
3979                path: p("src/enums.ts"),
3980                parent_name: "Status".to_string(),
3981                member_name: "Deprecated".to_string(),
3982                kind: MemberKind::EnumMember,
3983                line: 8,
3984                col: 0,
3985            })],
3986            unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
3987                path: p("src/service.ts"),
3988                parent_name: "UserService".to_string(),
3989                member_name: "legacy".to_string(),
3990                kind: MemberKind::ClassMethod,
3991                line: 42,
3992                col: 0,
3993            })],
3994            unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
3995                path: p("src/store.ts"),
3996                parent_name: "useStore".to_string(),
3997                member_name: "legacyAction".to_string(),
3998                kind: MemberKind::StoreMember,
3999                line: 17,
4000                col: 0,
4001            })],
4002            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
4003                path: p("src/app.ts"),
4004                specifier: "./missing".to_string(),
4005                line: 3,
4006                col: 0,
4007                specifier_col: 0,
4008            })],
4009            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
4010                export_name: "Config".to_string(),
4011                locations: vec![
4012                    DuplicateLocation {
4013                        path: p("src/a.ts"),
4014                        line: 1,
4015                        col: 0,
4016                    },
4017                    DuplicateLocation {
4018                        path: p("src/b.ts"),
4019                        line: 5,
4020                        col: 0,
4021                    },
4022                ],
4023            })],
4024            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
4025                from_path: p("src/ui/btn.ts"),
4026                to_path: p("src/db/query.ts"),
4027                from_zone: "ui".to_string(),
4028                to_zone: "db".to_string(),
4029                import_specifier: "../db/query".to_string(),
4030                line: 1,
4031                col: 0,
4032            })],
4033            ..Default::default()
4034        }
4035    }
4036
4037    /// Regression test: baseline saved on one machine (different absolute root)
4038    /// must match issues found on another machine across all path-based types.
4039    #[test]
4040    fn baseline_keys_are_relative_to_root() {
4041        let local_root = Path::new("/Users/dev/project");
4042        let results = make_absolute_results("/Users/dev/project");
4043        let baseline = BaselineData::from_results(&results, local_root);
4044
4045        assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
4046        assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
4047        assert_eq!(
4048            baseline.unused_dependencies,
4049            vec!["packages/app/package.json:lodash-es"]
4050        );
4051        assert_eq!(
4052            baseline.boundary_violations,
4053            vec!["src/ui/btn.ts->src/db/query.ts"]
4054        );
4055        assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
4056        assert_eq!(
4057            baseline.unused_enum_members,
4058            vec!["src/enums.ts:Status.Deprecated"]
4059        );
4060        assert_eq!(
4061            baseline.unused_class_members,
4062            vec!["src/service.ts:UserService.legacy"]
4063        );
4064        assert_eq!(
4065            baseline.unused_store_members,
4066            vec!["src/store.ts:useStore.legacyAction"]
4067        );
4068        assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
4069        assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
4070
4071        let ci_root = Path::new("/home/runner/work/project/project");
4072        let ci_results = make_absolute_results("/home/runner/work/project/project");
4073
4074        let filtered = filter_new_issues(ci_results, &baseline, ci_root);
4075        assert!(filtered.unused_files.is_empty(), "unused files");
4076        assert!(filtered.unused_exports.is_empty(), "unused exports");
4077        assert!(filtered.unused_dependencies.is_empty(), "unused deps");
4078        assert!(
4079            filtered.boundary_violations.is_empty(),
4080            "boundary violations"
4081        );
4082        assert!(filtered.circular_dependencies.is_empty(), "circular deps");
4083        assert!(filtered.unused_enum_members.is_empty(), "enum members");
4084        assert!(filtered.unused_class_members.is_empty(), "class members");
4085        assert!(filtered.unused_store_members.is_empty(), "store members");
4086        assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
4087        assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
4088    }
4089
4090    #[test]
4091    fn stale_suppression_baseline_keys_include_missing_reason_state() {
4092        let root = Path::new("/project");
4093        let stale = crate::results::StaleSuppression {
4094            path: root.join("src/file.ts"),
4095            line: 1,
4096            col: 0,
4097            origin: crate::results::SuppressionOrigin::Comment {
4098                issue_kind: Some("unused-export".to_string()),
4099                reason: None,
4100                is_file_level: false,
4101                kind_known: true,
4102            },
4103            missing_reason: false,
4104            actions: crate::results::StaleSuppression::actions_for(false),
4105        };
4106        let missing = crate::results::StaleSuppression {
4107            missing_reason: true,
4108            actions: crate::results::StaleSuppression::actions_for(true),
4109            ..stale.clone()
4110        };
4111        let results = AnalysisResults {
4112            stale_suppressions: vec![stale, missing],
4113            ..Default::default()
4114        };
4115        let baseline = BaselineData::from_results(&results, root);
4116
4117        assert_eq!(
4118            baseline.stale_suppressions,
4119            vec![
4120                "stale-suppression:src/file.ts:1",
4121                "missing-suppression-reason:src/file.ts:1",
4122            ]
4123        );
4124
4125        let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
4126        legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
4127        let filtered = filter_new_issues(results, &legacy_baseline, root);
4128        assert!(filtered.stale_suppressions.is_empty());
4129    }
4130
4131    fn runtime_finding(
4132        id: &str,
4133        stable_id: Option<&str>,
4134        line: u32,
4135        source_hash: Option<&str>,
4136    ) -> fallow_output::RuntimeCoverageFinding {
4137        fallow_output::RuntimeCoverageFinding {
4138            id: id.to_owned(),
4139            stable_id: stable_id.map(str::to_owned),
4140            source_hash: source_hash.map(str::to_owned),
4141            path: PathBuf::from("src/a.ts"),
4142            function: "alpha".to_owned(),
4143            line,
4144            verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
4145            invocations: Some(0),
4146            confidence: fallow_output::RuntimeCoverageConfidence::Medium,
4147            evidence: fallow_output::RuntimeCoverageEvidence {
4148                static_status: "used".to_owned(),
4149                test_coverage: "not_covered".to_owned(),
4150                v8_tracking: "tracked".to_owned(),
4151                untracked_reason: None,
4152                observation_days: 1,
4153                deployments_observed: 1,
4154            },
4155            actions: vec![],
4156            discriminators: None,
4157        }
4158    }
4159
4160    #[test]
4161    fn legacy_prod_baseline_still_suppresses_finding() {
4162        let baseline = HealthBaselineData {
4163            runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
4164            ..HealthBaselineData::default()
4165        };
4166        let findings = vec![runtime_finding(
4167            "fallow:prod:deadbeef",
4168            Some("fallow:fn:00000001"),
4169            14,
4170            None,
4171        )];
4172        let filtered =
4173            filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
4174        assert!(filtered.is_empty(), "legacy prod id must still suppress");
4175    }
4176
4177    #[test]
4178    fn source_hash_baseline_survives_line_move() {
4179        let root = Path::new("/repo");
4180        let baselined = runtime_finding(
4181            "fallow:prod:deadbeef",
4182            Some("fallow:fn:00000001"),
4183            14,
4184            Some("0123456789abcdef"),
4185        );
4186        let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
4187        assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
4188
4189        let findings = vec![runtime_finding(
4190            "fallow:prod:99999999",
4191            Some("fallow:fn:cafe0002"),
4192            40,
4193            Some("0123456789abcdef"),
4194        )];
4195        let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
4196        assert!(
4197            filtered.is_empty(),
4198            "source_hash baseline must survive a line move despite a changed stable_id and id"
4199        );
4200    }
4201
4202    #[test]
4203    fn unbaselined_finding_is_reported() {
4204        let baseline = HealthBaselineData {
4205            runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
4206            ..HealthBaselineData::default()
4207        };
4208        let findings = vec![runtime_finding(
4209            "fallow:prod:abc1234d",
4210            Some("fallow:fn:beefcafe"),
4211            7,
4212            None,
4213        )];
4214        let filtered =
4215            filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
4216        assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
4217    }
4218}