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