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    report.clone_families =
1528        crate::duplicates::families::group_into_families(&report.clone_groups, root);
1529    report.mirrored_directories =
1530        crate::duplicates::families::detect_mirrored_directories(&report.clone_families, root);
1531
1532    report.stats = recompute_stats(&report);
1533
1534    report
1535}
1536
1537/// Recompute duplication statistics after filtering (baseline or `--changed-since`).
1538///
1539/// Uses per-file line deduplication (matching `compute_stats` in `detect.rs`)
1540/// so overlapping clone instances don't inflate the duplicated line count.
1541pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
1542    let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
1543    let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
1544    let mut duplicated_tokens = 0usize;
1545    let mut clone_instances = 0usize;
1546
1547    for group in &report.clone_groups {
1548        for instance in &group.instances {
1549            files_with_clones.insert(&instance.file);
1550            clone_instances += 1;
1551            let lines = file_dup_lines.entry(&instance.file).or_default();
1552            for line in instance.start_line..=instance.end_line {
1553                lines.insert(line);
1554            }
1555        }
1556        duplicated_tokens += group.token_count * group.instances.len();
1557    }
1558
1559    let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
1560
1561    crate::duplicates::DuplicationStats {
1562        total_files: report.stats.total_files,
1563        files_with_clones: files_with_clones.len(),
1564        total_lines: report.stats.total_lines,
1565        duplicated_lines,
1566        total_tokens: report.stats.total_tokens,
1567        duplicated_tokens,
1568        clone_groups: report.clone_groups.len(),
1569        clone_instances,
1570        duplication_percentage: if report.stats.total_lines > 0 {
1571            (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
1572        } else {
1573            0.0
1574        },
1575        clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
1576    }
1577}
1578
1579/// Baseline data for health (complexity) comparison.
1580///
1581/// New baselines store count-per-category-per-file data in `finding_counts` so
1582/// line shifts do not leak pre-existing findings. Legacy baselines with
1583/// `findings: ["path:name:line"]` still load so users can refresh them in
1584/// place with `--save-baseline`.
1585#[derive(Default, serde::Serialize, serde::Deserialize)]
1586pub struct HealthBaselineData {
1587    /// Legacy health baseline keys: `relative_path:function_name:line`.
1588    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1589    pub findings: Vec<String>,
1590    /// Count-per-category-per-file baseline buckets.
1591    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1592    pub finding_counts: HealthFindingCountMap,
1593    /// Stable runtime-coverage finding IDs from the sidecar.
1594    #[serde(default)]
1595    pub runtime_coverage_findings: Vec<String>,
1596    /// Line-move-tolerant runtime-coverage suppression keys of the form
1597    /// `path\0name\0source_hash`. Unlike `runtime_coverage_findings` (whose
1598    /// keys hash the start line and so churn when a function moves), the
1599    /// `source_hash` component is the content digest of the function body, so a
1600    /// moved-but-unedited function keeps the same key and stays suppressed.
1601    /// Only findings whose `source_hash` is present contribute an entry.
1602    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1603    pub runtime_coverage_source_hashes: Vec<String>,
1604    /// Refactoring target keys: `relative_path:category`.
1605    #[serde(default)]
1606    pub target_keys: Vec<String>,
1607}
1608
1609#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1610pub struct HealthBaselineCount {
1611    pub count: usize,
1612}
1613
1614type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
1615
1616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1617enum HealthFindingDimension {
1618    Complexity,
1619    Crap,
1620}
1621
1622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1623struct HealthFindingCategory {
1624    dimension: HealthFindingDimension,
1625    severity: fallow_output::FindingSeverity,
1626}
1627
1628impl HealthFindingCategory {
1629    const fn key(self) -> &'static str {
1630        match (self.dimension, self.severity) {
1631            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
1632                "complexity_moderate"
1633            }
1634            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
1635                "complexity_high"
1636            }
1637            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
1638                "complexity_critical"
1639            }
1640            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
1641                "crap_moderate"
1642            }
1643            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
1644            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
1645                "crap_critical"
1646            }
1647        }
1648    }
1649}
1650
1651const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
1652    HealthFindingDimension::Complexity,
1653    HealthFindingDimension::Crap,
1654];
1655
1656impl HealthBaselineData {
1657    /// Build a health baseline from findings and targets.
1658    pub fn from_findings(
1659        findings: &[fallow_output::ComplexityViolation],
1660        runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
1661        targets: &[fallow_output::RefactoringTarget],
1662        root: &Path,
1663    ) -> Self {
1664        Self {
1665            findings: Vec::new(),
1666            finding_counts: health_finding_counts(findings, root),
1667            runtime_coverage_findings: runtime_coverage_findings
1668                .iter()
1669                .map(|f| runtime_coverage_finding_key(f, root))
1670                .collect(),
1671            runtime_coverage_source_hashes: runtime_coverage_findings
1672                .iter()
1673                .filter_map(|f| runtime_coverage_source_hash_key(f, root))
1674                .collect(),
1675            target_keys: targets
1676                .iter()
1677                .map(|t| target_baseline_key(t, root))
1678                .collect(),
1679        }
1680    }
1681
1682    pub fn finding_entry_count(&self) -> usize {
1683        if !self.finding_counts.is_empty() {
1684            self.finding_counts
1685                .values()
1686                .flat_map(BTreeMap::values)
1687                .map(|entry| entry.count)
1688                .sum()
1689        } else {
1690            self.findings.len()
1691        }
1692    }
1693
1694    pub fn overlap_entry_count(
1695        &self,
1696        findings: &[fallow_output::ComplexityViolation],
1697        root: &Path,
1698    ) -> usize {
1699        if !self.finding_counts.is_empty() {
1700            let current_counts = health_finding_counts(findings, root);
1701            health_overlap_entry_count(&current_counts, &self.finding_counts)
1702        } else {
1703            let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
1704            findings
1705                .iter()
1706                .filter(|finding| {
1707                    baseline_keys.contains(health_finding_key(finding, root).as_str())
1708                })
1709                .count()
1710        }
1711    }
1712}
1713
1714/// Generate a stable key for a refactoring target: `relative_path:category`.
1715fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
1716    format!(
1717        "{}:{}",
1718        relative_path(&target.path, root),
1719        target.category.label()
1720    )
1721}
1722
1723/// Generate a stable key for a health finding.
1724fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
1725    format!(
1726        "{}:{}:{}",
1727        relative_path(&finding.path, root),
1728        finding.name,
1729        finding.line
1730    )
1731}
1732
1733fn health_finding_counts(
1734    findings: &[fallow_output::ComplexityViolation],
1735    root: &Path,
1736) -> HealthFindingCountMap {
1737    let mut counts = BTreeMap::new();
1738    for finding in findings {
1739        let path = relative_path(&finding.path, root);
1740        let file_counts = counts.entry(path).or_insert_with(BTreeMap::new);
1741        for category in health_finding_categories(finding).into_iter().flatten() {
1742            file_counts
1743                .entry(category.key().to_string())
1744                .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
1745                .or_insert(HealthBaselineCount { count: 1 });
1746        }
1747    }
1748    counts
1749}
1750
1751fn health_finding_categories(
1752    finding: &fallow_output::ComplexityViolation,
1753) -> [Option<HealthFindingCategory>; 2] {
1754    let complexity_category = HealthFindingCategory {
1755        dimension: HealthFindingDimension::Complexity,
1756        severity: finding.severity,
1757    };
1758    let crap_category = HealthFindingCategory {
1759        dimension: HealthFindingDimension::Crap,
1760        severity: finding.severity,
1761    };
1762    let has_complexity =
1763        finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
1764    let has_crap = finding.exceeded.includes_crap();
1765    [
1766        has_complexity.then_some(complexity_category),
1767        has_crap.then_some(crap_category),
1768    ]
1769}
1770
1771fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
1772    match severity {
1773        fallow_output::FindingSeverity::Moderate => 0,
1774        fallow_output::FindingSeverity::High => 1,
1775        fallow_output::FindingSeverity::Critical => 2,
1776    }
1777}
1778
1779fn severity_counts_for_dimension(
1780    file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
1781    dimension: HealthFindingDimension,
1782) -> [usize; 3] {
1783    let mut counts = [0; 3];
1784    for severity in [
1785        fallow_output::FindingSeverity::Moderate,
1786        fallow_output::FindingSeverity::High,
1787        fallow_output::FindingSeverity::Critical,
1788    ] {
1789        let category = HealthFindingCategory {
1790            dimension,
1791            severity,
1792        };
1793        counts[severity_index(severity)] = file_counts
1794            .and_then(|entries| entries.get(category.key()))
1795            .map_or(0, |entry| entry.count);
1796    }
1797    counts
1798}
1799
1800fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
1801    let mut available = baseline;
1802    let mut overflow = [false; 3];
1803
1804    for severity_idx in 0..3 {
1805        let compatible = available[severity_idx..].iter().sum::<usize>();
1806        overflow[severity_idx] = compatible < current[severity_idx];
1807
1808        let mut matched = current[severity_idx].min(compatible);
1809        for slot in available.iter_mut().skip(severity_idx) {
1810            let taken = matched.min(*slot);
1811            *slot -= taken;
1812            matched -= taken;
1813            if matched == 0 {
1814                break;
1815            }
1816        }
1817    }
1818
1819    overflow
1820}
1821
1822fn health_overflow_categories(
1823    current_counts: &HealthFindingCountMap,
1824    baseline_counts: &HealthFindingCountMap,
1825) -> FxHashMap<String, FxHashSet<&'static str>> {
1826    let mut overflow_by_path = FxHashMap::default();
1827
1828    for (path, current_file_counts) in current_counts {
1829        let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
1830        let baseline_file_counts = baseline_counts.get(path);
1831
1832        for dimension in HEALTH_FINDING_DIMENSIONS {
1833            let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
1834            let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
1835            let overflow = overflowing_severities(current, baseline);
1836
1837            for severity in [
1838                fallow_output::FindingSeverity::Moderate,
1839                fallow_output::FindingSeverity::High,
1840                fallow_output::FindingSeverity::Critical,
1841            ] {
1842                if overflow[severity_index(severity)] {
1843                    overflow_categories.insert(
1844                        HealthFindingCategory {
1845                            dimension,
1846                            severity,
1847                        }
1848                        .key(),
1849                    );
1850                }
1851            }
1852        }
1853
1854        if !overflow_categories.is_empty() {
1855            overflow_by_path.insert(path.clone(), overflow_categories);
1856        }
1857    }
1858
1859    overflow_by_path
1860}
1861
1862fn health_overlap_entry_count(
1863    current_counts: &HealthFindingCountMap,
1864    baseline_counts: &HealthFindingCountMap,
1865) -> usize {
1866    let mut overlap = 0;
1867
1868    for (path, baseline_file_counts) in baseline_counts {
1869        let current_file_counts = current_counts.get(path);
1870
1871        for dimension in HEALTH_FINDING_DIMENSIONS {
1872            let current_total: usize =
1873                severity_counts_for_dimension(current_file_counts, dimension)
1874                    .into_iter()
1875                    .sum();
1876            let baseline_total: usize =
1877                severity_counts_for_dimension(Some(baseline_file_counts), dimension)
1878                    .into_iter()
1879                    .sum();
1880            overlap += current_total.min(baseline_total);
1881        }
1882    }
1883
1884    overlap
1885}
1886
1887fn runtime_coverage_finding_key(
1888    finding: &fallow_output::RuntimeCoverageFinding,
1889    _root: &Path,
1890) -> String {
1891    finding
1892        .stable_id
1893        .clone()
1894        .unwrap_or_else(|| finding.id.clone())
1895}
1896
1897/// Line-move-tolerant writer key: `path\0name\0source_hash`.
1898///
1899/// Returns `None` when the finding carries no `source_hash` (e.g. a 0.5-shape
1900/// sidecar or an un-migrated producer); such findings fall back to the
1901/// line-sensitive `runtime_coverage_finding_key` for suppression. The NUL
1902/// separator avoids collisions with paths/names that contain `:`.
1903fn runtime_coverage_source_hash_key(
1904    finding: &fallow_output::RuntimeCoverageFinding,
1905    root: &Path,
1906) -> Option<String> {
1907    finding.source_hash.as_deref().map(|hash| {
1908        format!(
1909            "{}\0{}\0{}",
1910            relative_path(&finding.path, root),
1911            finding.function,
1912            hash
1913        )
1914    })
1915}
1916
1917/// Filter health findings to only include those not present in the baseline.
1918pub fn filter_new_health_findings(
1919    mut findings: Vec<fallow_output::ComplexityViolation>,
1920    baseline: &HealthBaselineData,
1921    root: &Path,
1922) -> Vec<fallow_output::ComplexityViolation> {
1923    if !baseline.finding_counts.is_empty() {
1924        let current_counts = health_finding_counts(&findings, root);
1925        let overflow_categories =
1926            health_overflow_categories(&current_counts, &baseline.finding_counts);
1927        findings.retain(|finding| {
1928            let path = relative_path(&finding.path, root);
1929            overflow_categories.get(&path).is_some_and(|categories| {
1930                health_finding_categories(finding)
1931                    .into_iter()
1932                    .flatten()
1933                    .any(|category| categories.contains(category.key()))
1934            })
1935        });
1936        return findings;
1937    }
1938
1939    let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
1940    findings.retain(|f| {
1941        let key = health_finding_key(f, root);
1942        !baseline_keys.contains(key.as_str())
1943    });
1944    findings
1945}
1946
1947pub fn filter_new_runtime_coverage_findings(
1948    mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
1949    baseline: &HealthBaselineData,
1950    root: &Path,
1951) -> Vec<fallow_output::RuntimeCoverageFinding> {
1952    let baseline_keys: FxHashSet<&str> = baseline
1953        .runtime_coverage_findings
1954        .iter()
1955        .map(String::as_str)
1956        .collect();
1957    let baseline_source_hash_keys: FxHashSet<&str> = baseline
1958        .runtime_coverage_source_hashes
1959        .iter()
1960        .map(String::as_str)
1961        .collect();
1962    findings.retain(|finding| {
1963        let suppressed_by_stable_id = finding
1964            .stable_id
1965            .as_deref()
1966            .is_some_and(|id| baseline_keys.contains(id));
1967        let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
1968        let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
1969            .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
1970        !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
1971    });
1972    findings
1973}
1974
1975/// Filter refactoring targets to only include those not present in the baseline.
1976pub fn filter_new_health_targets(
1977    mut targets: Vec<fallow_output::RefactoringTarget>,
1978    baseline: &HealthBaselineData,
1979    root: &Path,
1980) -> Vec<fallow_output::RefactoringTarget> {
1981    let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
1982    targets.retain(|t| {
1983        let key = target_baseline_key(t, root);
1984        !baseline_keys.contains(key.as_str())
1985    });
1986    targets
1987}
1988
1989/// Per-category delta between current results and a baseline.
1990#[derive(Debug, Clone, serde::Serialize)]
1991pub struct CategoryDelta {
1992    pub current: usize,
1993    pub baseline: usize,
1994    pub delta: i64,
1995}
1996
1997/// Deltas between current analysis results and a saved baseline.
1998///
1999/// Used in combined mode to show +/- counts in the failure summary and
2000/// to emit `baseline_deltas` in JSON output.
2001#[derive(Debug, Clone)]
2002pub struct BaselineDeltas {
2003    /// Net change in total issue count (positive = more issues).
2004    pub total_delta: i64,
2005    /// Per-category deltas keyed by category name.
2006    pub per_category: Vec<(String, CategoryDelta)>,
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011    use super::*;
2012    use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2013    use crate::results::{
2014        AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2015        UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2016        UnusedFile,
2017    };
2018    use fallow_types::output_dead_code::{
2019        UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2020    };
2021    use std::path::PathBuf;
2022
2023    fn make_results() -> AnalysisResults {
2024        AnalysisResults {
2025            unused_files: vec![
2026                UnusedFileFinding::with_actions(UnusedFile {
2027                    path: PathBuf::from("src/old.ts"),
2028                }),
2029                UnusedFileFinding::with_actions(UnusedFile {
2030                    path: PathBuf::from("src/dead.ts"),
2031                }),
2032            ],
2033            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2034                path: PathBuf::from("src/utils.ts"),
2035                export_name: "helperA".to_string(),
2036                is_type_only: false,
2037                line: 5,
2038                col: 0,
2039                span_start: 40,
2040                is_re_export: false,
2041            })],
2042            unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2043                path: PathBuf::from("src/types.ts"),
2044                export_name: "OldType".to_string(),
2045                is_type_only: true,
2046                line: 10,
2047                col: 0,
2048                span_start: 100,
2049                is_re_export: false,
2050            })],
2051            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2052                package_name: "lodash".to_string(),
2053                location: DependencyLocation::Dependencies,
2054                path: PathBuf::from("package.json"),
2055                line: 5,
2056                used_in_workspaces: Vec::new(),
2057            })],
2058            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2059                UnusedDependency {
2060                    package_name: "jest".to_string(),
2061                    location: DependencyLocation::DevDependencies,
2062                    path: PathBuf::from("package.json"),
2063                    line: 5,
2064                    used_in_workspaces: Vec::new(),
2065                },
2066            )],
2067            ..Default::default()
2068        }
2069    }
2070
2071    #[test]
2072    fn baseline_from_results_captures_all_fields() {
2073        let results = make_results();
2074        let baseline = BaselineData::from_results(&results, Path::new(""));
2075        assert_eq!(baseline.unused_files.len(), 2);
2076        assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2077        assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2078        assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2079        assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2080        assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2081        assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2082    }
2083
2084    #[test]
2085    fn dependency_baseline_keys_include_package_json_path() {
2086        let root = Path::new("/repo");
2087        let results = AnalysisResults {
2088            unused_dependencies: vec![
2089                UnusedDependencyFinding::with_actions(UnusedDependency {
2090                    package_name: "lodash-es".to_string(),
2091                    location: DependencyLocation::Dependencies,
2092                    path: PathBuf::from("/repo/packages/app-a/package.json"),
2093                    line: 5,
2094                    used_in_workspaces: Vec::new(),
2095                }),
2096                UnusedDependencyFinding::with_actions(UnusedDependency {
2097                    package_name: "lodash-es".to_string(),
2098                    location: DependencyLocation::Dependencies,
2099                    path: PathBuf::from("/repo/packages/app-b/package.json"),
2100                    line: 5,
2101                    used_in_workspaces: Vec::new(),
2102                }),
2103            ],
2104            ..Default::default()
2105        };
2106
2107        let baseline = BaselineData::from_results(&results, root);
2108
2109        assert_eq!(
2110            baseline.unused_dependencies,
2111            vec![
2112                "packages/app-a/package.json:lodash-es",
2113                "packages/app-b/package.json:lodash-es"
2114            ]
2115        );
2116    }
2117
2118    #[test]
2119    fn dependency_baseline_filter_matches_path_before_package_name() {
2120        let root = Path::new("/repo");
2121        let results = AnalysisResults {
2122            unused_dependencies: vec![
2123                UnusedDependencyFinding::with_actions(UnusedDependency {
2124                    package_name: "lodash-es".to_string(),
2125                    location: DependencyLocation::Dependencies,
2126                    path: PathBuf::from("/repo/packages/app-a/package.json"),
2127                    line: 5,
2128                    used_in_workspaces: Vec::new(),
2129                }),
2130                UnusedDependencyFinding::with_actions(UnusedDependency {
2131                    package_name: "lodash-es".to_string(),
2132                    location: DependencyLocation::Dependencies,
2133                    path: PathBuf::from("/repo/packages/app-b/package.json"),
2134                    line: 5,
2135                    used_in_workspaces: Vec::new(),
2136                }),
2137            ],
2138            ..Default::default()
2139        };
2140        let baseline = BaselineData {
2141            unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2142            ..BaselineData::from_results(&AnalysisResults::default(), root)
2143        };
2144
2145        let filtered = filter_new_issues(results, &baseline, root);
2146
2147        assert_eq!(filtered.unused_dependencies.len(), 1);
2148        assert_eq!(
2149            filtered.unused_dependencies[0].dep.path,
2150            PathBuf::from("/repo/packages/app-b/package.json")
2151        );
2152    }
2153
2154    #[test]
2155    fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2156        let root = Path::new("/repo");
2157        let results = AnalysisResults {
2158            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2159                package_name: "lodash-es".to_string(),
2160                location: DependencyLocation::Dependencies,
2161                path: PathBuf::from("/repo/packages/app/package.json"),
2162                line: 5,
2163                used_in_workspaces: Vec::new(),
2164            })],
2165            ..Default::default()
2166        };
2167        let baseline = BaselineData {
2168            unused_dependencies: vec!["lodash-es".to_string()],
2169            ..BaselineData::from_results(&AnalysisResults::default(), root)
2170        };
2171
2172        let filtered = filter_new_issues(results, &baseline, root);
2173
2174        assert!(filtered.unused_dependencies.is_empty());
2175    }
2176
2177    #[test]
2178    fn baseline_serialization_roundtrip() {
2179        let results = make_results();
2180        let baseline = BaselineData::from_results(&results, Path::new(""));
2181        let json = serde_json::to_string(&baseline).unwrap();
2182        let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2183        assert_eq!(deserialized.unused_files, baseline.unused_files);
2184        assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2185        assert_eq!(deserialized.unused_types, baseline.unused_types);
2186        assert_eq!(
2187            deserialized.unused_dependencies,
2188            baseline.unused_dependencies
2189        );
2190        assert_eq!(
2191            deserialized.unused_dev_dependencies,
2192            baseline.unused_dev_dependencies
2193        );
2194    }
2195
2196    #[test]
2197    fn filter_removes_baseline_issues() {
2198        let results = make_results();
2199        let baseline = BaselineData::from_results(&results, Path::new(""));
2200        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2201        assert!(
2202            filtered.unused_files.is_empty(),
2203            "all files were in baseline"
2204        );
2205        assert!(
2206            filtered.unused_exports.is_empty(),
2207            "all exports were in baseline"
2208        );
2209        assert!(
2210            filtered.unused_types.is_empty(),
2211            "all types were in baseline"
2212        );
2213        assert!(
2214            filtered.unused_dependencies.is_empty(),
2215            "all deps were in baseline"
2216        );
2217        assert!(
2218            filtered.unused_dev_dependencies.is_empty(),
2219            "all dev deps were in baseline"
2220        );
2221    }
2222
2223    #[test]
2224    fn filter_keeps_new_issues_not_in_baseline() {
2225        let baseline = BaselineData {
2226            unused_files: vec!["src/old.ts".to_string()],
2227            unused_exports: vec![],
2228            unused_types: vec![],
2229            private_type_leaks: vec![],
2230            unused_dependencies: vec![],
2231            unused_dev_dependencies: vec![],
2232            circular_dependencies: vec![],
2233            re_export_cycles: vec![],
2234            unused_optional_dependencies: vec![],
2235            unused_enum_members: vec![],
2236            unused_class_members: vec![],
2237            unused_store_members: vec![],
2238            unprovided_injects: vec![],
2239            unrendered_components: vec![],
2240            unused_component_props: vec![],
2241            unused_component_emits: vec![],
2242            unused_component_inputs: vec![],
2243            unused_component_outputs: vec![],
2244            unused_svelte_events: vec![],
2245            unused_server_actions: vec![],
2246            unused_load_data_keys: vec![],
2247            unresolved_imports: vec![],
2248            unlisted_dependencies: vec![],
2249            duplicate_exports: vec![],
2250            type_only_dependencies: vec![],
2251            test_only_dependencies: vec![],
2252            boundary_violations: vec![],
2253            boundary_coverage_violations: vec![],
2254            boundary_call_violations: vec![],
2255            policy_violations: vec![],
2256            stale_suppressions: vec![],
2257            unused_catalog_entries: vec![],
2258            empty_catalog_groups: vec![],
2259            unresolved_catalog_references: vec![],
2260            unused_dependency_overrides: vec![],
2261            misconfigured_dependency_overrides: vec![],
2262            invalid_client_exports: vec![],
2263            mixed_client_server_barrels: vec![],
2264            misplaced_directives: vec![],
2265            route_collisions: vec![],
2266            dynamic_segment_name_conflicts: vec![],
2267        };
2268        let results = AnalysisResults {
2269            unused_files: vec![
2270                UnusedFileFinding::with_actions(UnusedFile {
2271                    path: PathBuf::from("src/old.ts"),
2272                }),
2273                UnusedFileFinding::with_actions(UnusedFile {
2274                    path: PathBuf::from("src/new-dead.ts"),
2275                }),
2276            ],
2277            ..Default::default()
2278        };
2279        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2280        assert_eq!(filtered.unused_files.len(), 1);
2281        assert_eq!(
2282            filtered.unused_files[0].file.path,
2283            PathBuf::from("src/new-dead.ts")
2284        );
2285    }
2286
2287    #[test]
2288    fn filter_with_empty_baseline_keeps_all() {
2289        let baseline = BaselineData {
2290            unused_files: vec![],
2291            unused_exports: vec![],
2292            unused_types: vec![],
2293            private_type_leaks: vec![],
2294            unused_dependencies: vec![],
2295            unused_dev_dependencies: vec![],
2296            circular_dependencies: vec![],
2297            re_export_cycles: vec![],
2298            unused_optional_dependencies: vec![],
2299            unused_enum_members: vec![],
2300            unused_class_members: vec![],
2301            unused_store_members: vec![],
2302            unprovided_injects: vec![],
2303            unrendered_components: vec![],
2304            unused_component_props: vec![],
2305            unused_component_emits: vec![],
2306            unused_component_inputs: vec![],
2307            unused_component_outputs: vec![],
2308            unused_svelte_events: vec![],
2309            unused_server_actions: vec![],
2310            unused_load_data_keys: vec![],
2311            unresolved_imports: vec![],
2312            unlisted_dependencies: vec![],
2313            duplicate_exports: vec![],
2314            type_only_dependencies: vec![],
2315            test_only_dependencies: vec![],
2316            boundary_violations: vec![],
2317            boundary_coverage_violations: vec![],
2318            boundary_call_violations: vec![],
2319            policy_violations: vec![],
2320            stale_suppressions: vec![],
2321            unused_catalog_entries: vec![],
2322            empty_catalog_groups: vec![],
2323            unresolved_catalog_references: vec![],
2324            unused_dependency_overrides: vec![],
2325            misconfigured_dependency_overrides: vec![],
2326            invalid_client_exports: vec![],
2327            mixed_client_server_barrels: vec![],
2328            misplaced_directives: vec![],
2329            route_collisions: vec![],
2330            dynamic_segment_name_conflicts: vec![],
2331        };
2332        let results = make_results();
2333        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2334        assert_eq!(filtered.unused_files.len(), 2);
2335        assert_eq!(filtered.unused_exports.len(), 1);
2336    }
2337
2338    #[test]
2339    fn filter_new_exports_by_file_and_name() {
2340        let baseline = BaselineData {
2341            unused_files: vec![],
2342            unused_exports: vec!["src/utils.ts:helperA".to_string()],
2343            unused_types: vec![],
2344            private_type_leaks: vec![],
2345            unused_dependencies: vec![],
2346            unused_dev_dependencies: vec![],
2347            circular_dependencies: vec![],
2348            re_export_cycles: vec![],
2349            unused_optional_dependencies: vec![],
2350            unused_enum_members: vec![],
2351            unused_class_members: vec![],
2352            unused_store_members: vec![],
2353            unprovided_injects: vec![],
2354            unrendered_components: vec![],
2355            unused_component_props: vec![],
2356            unused_component_emits: vec![],
2357            unused_component_inputs: vec![],
2358            unused_component_outputs: vec![],
2359            unused_svelte_events: vec![],
2360            unused_server_actions: vec![],
2361            unused_load_data_keys: vec![],
2362            unresolved_imports: vec![],
2363            unlisted_dependencies: vec![],
2364            duplicate_exports: vec![],
2365            type_only_dependencies: vec![],
2366            test_only_dependencies: vec![],
2367            boundary_violations: vec![],
2368            boundary_coverage_violations: vec![],
2369            boundary_call_violations: vec![],
2370            policy_violations: vec![],
2371            stale_suppressions: vec![],
2372            unused_catalog_entries: vec![],
2373            empty_catalog_groups: vec![],
2374            unresolved_catalog_references: vec![],
2375            unused_dependency_overrides: vec![],
2376            misconfigured_dependency_overrides: vec![],
2377            invalid_client_exports: vec![],
2378            mixed_client_server_barrels: vec![],
2379            misplaced_directives: vec![],
2380            route_collisions: vec![],
2381            dynamic_segment_name_conflicts: vec![],
2382        };
2383        let results = AnalysisResults {
2384            unused_exports: vec![
2385                UnusedExportFinding::with_actions(UnusedExport {
2386                    path: PathBuf::from("src/utils.ts"),
2387                    export_name: "helperA".to_string(),
2388                    is_type_only: false,
2389                    line: 5,
2390                    col: 0,
2391                    span_start: 40,
2392                    is_re_export: false,
2393                }),
2394                UnusedExportFinding::with_actions(UnusedExport {
2395                    path: PathBuf::from("src/utils.ts"),
2396                    export_name: "helperB".to_string(),
2397                    is_type_only: false,
2398                    line: 10,
2399                    col: 0,
2400                    span_start: 80,
2401                    is_re_export: false,
2402                }),
2403            ],
2404            ..Default::default()
2405        };
2406        let filtered = filter_new_issues(results, &baseline, Path::new(""));
2407        assert_eq!(filtered.unused_exports.len(), 1);
2408        assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
2409    }
2410
2411    fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
2412        CloneGroup {
2413            instances: instances
2414                .into_iter()
2415                .map(|(file, start, end)| CloneInstance {
2416                    file: PathBuf::from(file),
2417                    start_line: start,
2418                    end_line: end,
2419                    start_col: 0,
2420                    end_col: 0,
2421                    fragment: String::new(),
2422                })
2423                .collect(),
2424            token_count: 50,
2425            line_count: 10,
2426        }
2427    }
2428
2429    fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
2430        DuplicationReport {
2431            clone_groups: groups,
2432            clone_families: vec![],
2433            mirrored_directories: vec![],
2434            stats: DuplicationStats {
2435                total_files: 10,
2436                files_with_clones: 2,
2437                total_lines: 1000,
2438                duplicated_lines: 100,
2439                total_tokens: 5000,
2440                duplicated_tokens: 500,
2441                clone_groups: 1,
2442                clone_instances: 2,
2443                duplication_percentage: 10.0,
2444                clone_groups_below_min_occurrences: 0,
2445            },
2446        }
2447    }
2448
2449    #[test]
2450    fn clone_group_key_is_deterministic() {
2451        let root = Path::new("/project");
2452        let group = make_clone_group(vec![
2453            ("/project/src/a.ts", 1, 10),
2454            ("/project/src/b.ts", 5, 15),
2455        ]);
2456        let key1 = clone_group_key(&group, root);
2457        let key2 = clone_group_key(&group, root);
2458        assert_eq!(key1, key2);
2459    }
2460
2461    #[test]
2462    fn clone_group_key_is_sorted() {
2463        let root = Path::new("/project");
2464        let group_ab = make_clone_group(vec![
2465            ("/project/src/a.ts", 1, 10),
2466            ("/project/src/b.ts", 5, 15),
2467        ]);
2468        let group_ba = make_clone_group(vec![
2469            ("/project/src/b.ts", 5, 15),
2470            ("/project/src/a.ts", 1, 10),
2471        ]);
2472        assert_eq!(
2473            clone_group_key(&group_ab, root),
2474            clone_group_key(&group_ba, root),
2475            "key should be stable regardless of instance order"
2476        );
2477    }
2478
2479    #[test]
2480    fn duplication_baseline_roundtrip() {
2481        let root = Path::new("/project");
2482        let group = make_clone_group(vec![
2483            ("/project/src/a.ts", 1, 10),
2484            ("/project/src/b.ts", 5, 15),
2485        ]);
2486        let report = make_duplication_report(vec![group]);
2487        let baseline = DuplicationBaselineData::from_report(&report, root);
2488        let json = serde_json::to_string(&baseline).unwrap();
2489        let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
2490        assert_eq!(deserialized.clone_groups, baseline.clone_groups);
2491    }
2492
2493    #[test]
2494    fn filter_new_clone_groups_removes_baseline() {
2495        let root = Path::new("/project");
2496        let group = make_clone_group(vec![
2497            ("/project/src/a.ts", 1, 10),
2498            ("/project/src/b.ts", 5, 15),
2499        ]);
2500        let report = make_duplication_report(vec![group]);
2501        let baseline = DuplicationBaselineData::from_report(&report, root);
2502        let filtered = filter_new_clone_groups(report, &baseline, root);
2503        assert!(
2504            filtered.clone_groups.is_empty(),
2505            "baseline group should be filtered out"
2506        );
2507    }
2508
2509    #[test]
2510    fn filter_new_clone_groups_keeps_new_groups() {
2511        let root = Path::new("/project");
2512        let baseline_group = make_clone_group(vec![
2513            ("/project/src/a.ts", 1, 10),
2514            ("/project/src/b.ts", 5, 15),
2515        ]);
2516        let new_group = make_clone_group(vec![
2517            ("/project/src/c.ts", 20, 30),
2518            ("/project/src/d.ts", 25, 35),
2519        ]);
2520        let baseline_report = make_duplication_report(vec![baseline_group]);
2521        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2522
2523        let report = make_duplication_report(vec![
2524            make_clone_group(vec![
2525                ("/project/src/a.ts", 1, 10),
2526                ("/project/src/b.ts", 5, 15),
2527            ]),
2528            new_group,
2529        ]);
2530        let filtered = filter_new_clone_groups(report, &baseline, root);
2531        assert_eq!(
2532            filtered.clone_groups.len(),
2533            1,
2534            "only the new group should remain"
2535        );
2536    }
2537
2538    #[test]
2539    fn recompute_stats_after_filtering() {
2540        let root = Path::new("/project");
2541        let group = make_clone_group(vec![
2542            ("/project/src/a.ts", 1, 10),
2543            ("/project/src/b.ts", 5, 15),
2544        ]);
2545        let report = make_duplication_report(vec![group]);
2546        let baseline = DuplicationBaselineData::from_report(&report, root);
2547        let filtered = filter_new_clone_groups(report, &baseline, root);
2548        assert_eq!(filtered.stats.clone_groups, 0);
2549        assert_eq!(filtered.stats.clone_instances, 0);
2550        assert_eq!(filtered.stats.duplicated_lines, 0);
2551    }
2552
2553    #[test]
2554    fn recompute_stats_zero_total_lines() {
2555        let report = DuplicationReport {
2556            clone_groups: vec![],
2557            clone_families: vec![],
2558            mirrored_directories: vec![],
2559            stats: DuplicationStats {
2560                total_files: 0,
2561                files_with_clones: 0,
2562                total_lines: 0,
2563                duplicated_lines: 0,
2564                total_tokens: 0,
2565                duplicated_tokens: 0,
2566                clone_groups: 0,
2567                clone_instances: 0,
2568                duplication_percentage: 0.0,
2569                clone_groups_below_min_occurrences: 0,
2570            },
2571        };
2572        let stats = super::recompute_stats(&report);
2573        assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
2574    }
2575
2576    fn make_health_finding(
2577        root: &Path,
2578        name: &str,
2579        line: u32,
2580    ) -> fallow_output::ComplexityViolation {
2581        make_health_finding_with(
2582            root,
2583            name,
2584            line,
2585            fallow_output::ExceededThreshold::Both,
2586            fallow_output::FindingSeverity::High,
2587        )
2588    }
2589
2590    fn make_health_finding_with(
2591        root: &Path,
2592        name: &str,
2593        line: u32,
2594        exceeded: fallow_output::ExceededThreshold,
2595        severity: fallow_output::FindingSeverity,
2596    ) -> fallow_output::ComplexityViolation {
2597        fallow_output::ComplexityViolation {
2598            path: root.join("src/utils.ts"),
2599            name: name.to_string(),
2600            line,
2601            col: 0,
2602            cyclomatic: 25,
2603            cognitive: 30,
2604            line_count: 80,
2605            param_count: 0,
2606            react_hook_count: 0,
2607            react_jsx_max_depth: 0,
2608            react_prop_count: 0,
2609            react_hook_profile: None,
2610            exceeded,
2611            severity,
2612            crap: None,
2613            coverage_pct: None,
2614            coverage_tier: None,
2615            coverage_source: None,
2616            inherited_from: None,
2617            component_rollup: None,
2618            contributions: Vec::new(),
2619            effective_thresholds: None,
2620            threshold_source: None,
2621        }
2622    }
2623
2624    #[test]
2625    fn health_baseline_roundtrip() {
2626        let root = PathBuf::from("/project");
2627        let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2628        let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
2629        let json = serde_json::to_string(&baseline).unwrap();
2630        let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
2631        assert_eq!(deserialized.findings, baseline.findings);
2632        assert_eq!(baseline.findings, Vec::<String>::new());
2633        assert_eq!(
2634            deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
2635            1
2636        );
2637        assert!(!json.contains("parseExpression"));
2638    }
2639
2640    #[test]
2641    fn health_baseline_filters_known_findings() {
2642        let root = PathBuf::from("/project");
2643        let mut findings = vec![
2644            make_health_finding(&root, "parseExpression", 42),
2645            make_health_finding(&root, "newFunction", 100),
2646        ];
2647        findings[1].path = root.join("src/other.ts");
2648        let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
2649        let filtered = filter_new_health_findings(findings, &baseline, &root);
2650        assert_eq!(filtered.len(), 1);
2651        assert_eq!(filtered[0].name, "newFunction");
2652    }
2653
2654    #[test]
2655    fn health_baseline_filters_shifted_lines_with_same_category_count() {
2656        let root = PathBuf::from("/project");
2657        let baseline = HealthBaselineData::from_findings(
2658            &[make_health_finding(&root, "parseExpression", 42)],
2659            &[],
2660            &[],
2661            &root,
2662        );
2663        let filtered = filter_new_health_findings(
2664            vec![make_health_finding(&root, "parseExpression", 43)],
2665            &baseline,
2666            &root,
2667        );
2668        assert!(filtered.is_empty());
2669    }
2670
2671    #[test]
2672    fn health_baseline_reports_full_category_when_count_increases() {
2673        let root = PathBuf::from("/project");
2674        let baseline = HealthBaselineData::from_findings(
2675            &[make_health_finding(&root, "parseExpression", 42)],
2676            &[],
2677            &[],
2678            &root,
2679        );
2680        let filtered = filter_new_health_findings(
2681            vec![
2682                make_health_finding(&root, "parseExpression", 43),
2683                make_health_finding(&root, "newFunction", 100),
2684            ],
2685            &baseline,
2686            &root,
2687        );
2688        assert_eq!(filtered.len(), 2);
2689    }
2690
2691    #[test]
2692    fn health_baseline_legacy_findings_still_load() {
2693        let root = PathBuf::from("/project");
2694        let baseline = HealthBaselineData {
2695            findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
2696            finding_counts: BTreeMap::new(),
2697            target_keys: vec![],
2698            runtime_coverage_findings: vec![],
2699            runtime_coverage_source_hashes: vec![],
2700        };
2701        let filtered = filter_new_health_findings(
2702            vec![make_health_finding(&root, "parseExpression", 42)],
2703            &baseline,
2704            &root,
2705        );
2706        assert!(filtered.is_empty());
2707    }
2708
2709    #[test]
2710    fn health_baseline_keeps_crap_categories_separate_from_complexity() {
2711        let root = PathBuf::from("/project");
2712        let baseline = HealthBaselineData::from_findings(
2713            &[make_health_finding_with(
2714                &root,
2715                "parseExpression",
2716                42,
2717                fallow_output::ExceededThreshold::Crap,
2718                fallow_output::FindingSeverity::High,
2719            )],
2720            &[],
2721            &[],
2722            &root,
2723        );
2724        let filtered = filter_new_health_findings(
2725            vec![
2726                make_health_finding_with(
2727                    &root,
2728                    "parseExpression",
2729                    43,
2730                    fallow_output::ExceededThreshold::Crap,
2731                    fallow_output::FindingSeverity::High,
2732                ),
2733                make_health_finding(&root, "newComplexityOnlyFunction", 100),
2734            ],
2735            &baseline,
2736            &root,
2737        );
2738        assert_eq!(filtered.len(), 1);
2739        assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
2740    }
2741
2742    #[test]
2743    fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
2744        let root = PathBuf::from("/project");
2745        let baseline = HealthBaselineData::from_findings(
2746            &[make_health_finding_with(
2747                &root,
2748                "parseExpression",
2749                42,
2750                fallow_output::ExceededThreshold::Both,
2751                fallow_output::FindingSeverity::Critical,
2752            )],
2753            &[],
2754            &[],
2755            &root,
2756        );
2757        let filtered = filter_new_health_findings(
2758            vec![make_health_finding_with(
2759                &root,
2760                "parseExpression",
2761                42,
2762                fallow_output::ExceededThreshold::Both,
2763                fallow_output::FindingSeverity::High,
2764            )],
2765            &baseline,
2766            &root,
2767        );
2768        assert!(filtered.is_empty());
2769    }
2770
2771    #[test]
2772    fn health_baseline_still_reports_worse_current_severity_as_new() {
2773        let root = PathBuf::from("/project");
2774        let baseline = HealthBaselineData::from_findings(
2775            &[make_health_finding_with(
2776                &root,
2777                "parseExpression",
2778                42,
2779                fallow_output::ExceededThreshold::Both,
2780                fallow_output::FindingSeverity::High,
2781            )],
2782            &[],
2783            &[],
2784            &root,
2785        );
2786        let filtered = filter_new_health_findings(
2787            vec![make_health_finding_with(
2788                &root,
2789                "parseExpression",
2790                42,
2791                fallow_output::ExceededThreshold::Both,
2792                fallow_output::FindingSeverity::Critical,
2793            )],
2794            &baseline,
2795            &root,
2796        );
2797        assert_eq!(filtered.len(), 1);
2798        assert_eq!(filtered[0].name, "parseExpression");
2799        assert!(matches!(
2800            filtered[0].severity,
2801            fallow_output::FindingSeverity::Critical
2802        ));
2803    }
2804
2805    #[test]
2806    fn health_baseline_overlap_counts_partial_category_overflow() {
2807        let root = PathBuf::from("/project");
2808        let baseline = HealthBaselineData::from_findings(
2809            &[make_health_finding(&root, "parseExpression", 42)],
2810            &[],
2811            &[],
2812            &root,
2813        );
2814        let overlap = baseline.overlap_entry_count(
2815            &[
2816                make_health_finding(&root, "parseExpression", 42),
2817                make_health_finding(&root, "newFunction", 100),
2818            ],
2819            &root,
2820        );
2821        assert_eq!(overlap, 1);
2822    }
2823
2824    #[test]
2825    fn health_baseline_empty_keeps_all() {
2826        let root = PathBuf::from("/project");
2827        let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2828        let baseline = HealthBaselineData {
2829            findings: vec![],
2830            finding_counts: BTreeMap::new(),
2831            target_keys: vec![],
2832            runtime_coverage_findings: vec![],
2833            runtime_coverage_source_hashes: vec![],
2834        };
2835        let filtered = filter_new_health_findings(findings, &baseline, &root);
2836        assert_eq!(filtered.len(), 1);
2837    }
2838
2839    #[test]
2840    fn circular_dep_key_is_order_independent() {
2841        use crate::results::CircularDependency;
2842
2843        let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
2844            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2845            length: 2,
2846            line: 1,
2847            col: 0,
2848            edges: Vec::new(),
2849            is_cross_package: false,
2850        });
2851        let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
2852            files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
2853            length: 2,
2854            line: 1,
2855            col: 0,
2856            edges: Vec::new(),
2857            is_cross_package: false,
2858        });
2859        assert_eq!(
2860            super::circular_dep_key(&dep_ab.cycle, Path::new("")),
2861            super::circular_dep_key(&dep_ba.cycle, Path::new("")),
2862            "same files in different order should produce identical keys"
2863        );
2864    }
2865
2866    #[test]
2867    fn circular_dep_key_different_files_different_keys() {
2868        use crate::results::CircularDependency;
2869
2870        let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
2871            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2872            length: 2,
2873            line: 1,
2874            col: 0,
2875            edges: Vec::new(),
2876            is_cross_package: false,
2877        });
2878        let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
2879            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
2880            length: 2,
2881            line: 1,
2882            col: 0,
2883            edges: Vec::new(),
2884            is_cross_package: false,
2885        });
2886        assert_ne!(
2887            super::circular_dep_key(&dep1.cycle, Path::new("")),
2888            super::circular_dep_key(&dep2.cycle, Path::new("")),
2889        );
2890    }
2891
2892    #[test]
2893    fn circular_dep_key_three_files_order_independent() {
2894        use crate::results::CircularDependency;
2895
2896        let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
2897            files: vec![
2898                PathBuf::from("src/a.ts"),
2899                PathBuf::from("src/b.ts"),
2900                PathBuf::from("src/c.ts"),
2901            ],
2902            length: 3,
2903            line: 1,
2904            col: 0,
2905            edges: Vec::new(),
2906            is_cross_package: false,
2907        });
2908        let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
2909            files: vec![
2910                PathBuf::from("src/c.ts"),
2911                PathBuf::from("src/a.ts"),
2912                PathBuf::from("src/b.ts"),
2913            ],
2914            length: 3,
2915            line: 1,
2916            col: 0,
2917            edges: Vec::new(),
2918            is_cross_package: false,
2919        });
2920        assert_eq!(
2921            super::circular_dep_key(&dep_abc.cycle, Path::new("")),
2922            super::circular_dep_key(&dep_cab.cycle, Path::new("")),
2923        );
2924    }
2925
2926    #[expect(
2927        clippy::too_many_lines,
2928        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2929    )]
2930    fn make_full_results() -> AnalysisResults {
2931        use crate::extract::MemberKind;
2932        use crate::results::*;
2933
2934        let mut r = make_results();
2935        r.circular_dependencies
2936            .push(CircularDependencyFinding::with_actions(
2937                CircularDependency {
2938                    files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2939                    length: 2,
2940                    line: 1,
2941                    col: 0,
2942                    edges: Vec::new(),
2943                    is_cross_package: false,
2944                },
2945            ));
2946        r.unused_optional_dependencies
2947            .push(UnusedOptionalDependencyFinding::with_actions(
2948                UnusedDependency {
2949                    package_name: "fsevents".to_string(),
2950                    location: DependencyLocation::OptionalDependencies,
2951                    path: PathBuf::from("package.json"),
2952                    line: 15,
2953                    used_in_workspaces: Vec::new(),
2954                },
2955            ));
2956        r.unused_enum_members
2957            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
2958                path: PathBuf::from("src/enums.ts"),
2959                parent_name: "Status".to_string(),
2960                member_name: "Deprecated".to_string(),
2961                kind: MemberKind::EnumMember,
2962                line: 8,
2963                col: 0,
2964            }));
2965        r.unused_class_members
2966            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2967                path: PathBuf::from("src/service.ts"),
2968                parent_name: "UserService".to_string(),
2969                member_name: "legacy".to_string(),
2970                kind: MemberKind::ClassMethod,
2971                line: 42,
2972                col: 0,
2973            }));
2974        r.unused_store_members
2975            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
2976                path: PathBuf::from("src/store.ts"),
2977                parent_name: "useStore".to_string(),
2978                member_name: "legacyAction".to_string(),
2979                kind: MemberKind::StoreMember,
2980                line: 17,
2981                col: 0,
2982            }));
2983        r.unresolved_imports.push(
2984            fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
2985                crate::results::UnresolvedImport {
2986                    path: PathBuf::from("src/app.ts"),
2987                    specifier: "./missing".to_string(),
2988                    line: 3,
2989                    col: 0,
2990                    specifier_col: 0,
2991                },
2992            ),
2993        );
2994        r.unlisted_dependencies
2995            .push(crate::results::UnlistedDependencyFinding::with_actions(
2996                UnlistedDependency {
2997                    package_name: "chalk".to_string(),
2998                    imported_from: vec![],
2999                },
3000            ));
3001        r.duplicate_exports
3002            .push(crate::results::DuplicateExportFinding::with_actions(
3003                crate::results::DuplicateExport {
3004                    export_name: "Config".to_string(),
3005                    locations: vec![
3006                        crate::results::DuplicateLocation {
3007                            path: PathBuf::from("src/a.ts"),
3008                            line: 1,
3009                            col: 0,
3010                        },
3011                        crate::results::DuplicateLocation {
3012                            path: PathBuf::from("src/b.ts"),
3013                            line: 5,
3014                            col: 0,
3015                        },
3016                    ],
3017                },
3018            ));
3019        r.type_only_dependencies
3020            .push(crate::results::TypeOnlyDependencyFinding::with_actions(
3021                TypeOnlyDependency {
3022                    package_name: "zod".to_string(),
3023                    path: PathBuf::from("package.json"),
3024                    line: 8,
3025                },
3026            ));
3027        r.test_only_dependencies
3028            .push(crate::results::TestOnlyDependencyFinding::with_actions(
3029                TestOnlyDependency {
3030                    package_name: "vitest".to_string(),
3031                    path: PathBuf::from("package.json"),
3032                    line: 10,
3033                },
3034            ));
3035        r.boundary_violations.push(
3036            fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
3037                crate::results::BoundaryViolation {
3038                    from_path: PathBuf::from("src/ui/btn.ts"),
3039                    to_path: PathBuf::from("src/db/query.ts"),
3040                    from_zone: "ui".to_string(),
3041                    to_zone: "db".to_string(),
3042                    import_specifier: "../db/query".to_string(),
3043                    line: 1,
3044                    col: 0,
3045                },
3046            ),
3047        );
3048        r
3049    }
3050
3051    #[test]
3052    fn baseline_from_results_captures_all_extended_fields() {
3053        let results = make_full_results();
3054        let baseline = BaselineData::from_results(&results, Path::new(""));
3055        assert_eq!(baseline.circular_dependencies.len(), 1);
3056        assert_eq!(
3057            baseline.unused_optional_dependencies,
3058            vec!["package.json:fsevents"]
3059        );
3060        assert_eq!(baseline.unused_enum_members.len(), 1);
3061        assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
3062        assert_eq!(baseline.unused_class_members.len(), 1);
3063        assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
3064        assert_eq!(baseline.unused_store_members.len(), 1);
3065        assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
3066        assert_eq!(baseline.unresolved_imports.len(), 1);
3067        assert!(baseline.unresolved_imports[0].contains("./missing"));
3068        assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
3069        assert_eq!(baseline.duplicate_exports.len(), 1);
3070        assert!(baseline.duplicate_exports[0].starts_with("Config|"));
3071        assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
3072        assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
3073        assert_eq!(baseline.boundary_violations.len(), 1);
3074        assert!(baseline.boundary_violations[0].contains("->"));
3075    }
3076
3077    #[test]
3078    fn filter_removes_all_extended_baseline_issues() {
3079        let results = make_full_results();
3080        let baseline = BaselineData::from_results(&results, Path::new(""));
3081        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3082        assert!(filtered.circular_dependencies.is_empty());
3083        assert!(filtered.unused_optional_dependencies.is_empty());
3084        assert!(filtered.unused_enum_members.is_empty());
3085        assert!(filtered.unused_class_members.is_empty());
3086        assert!(filtered.unused_store_members.is_empty());
3087        assert!(filtered.unresolved_imports.is_empty());
3088        assert!(filtered.unlisted_dependencies.is_empty());
3089        assert!(filtered.duplicate_exports.is_empty());
3090        assert!(filtered.type_only_dependencies.is_empty());
3091        assert!(filtered.test_only_dependencies.is_empty());
3092        assert!(filtered.boundary_violations.is_empty());
3093    }
3094
3095    #[test]
3096    fn filter_keeps_new_circular_deps() {
3097        use crate::results::CircularDependency;
3098        let baseline = BaselineData {
3099            circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
3100            ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3101        };
3102        let mut results = AnalysisResults::default();
3103        results
3104            .circular_dependencies
3105            .push(CircularDependencyFinding::with_actions(
3106                CircularDependency {
3107                    files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3108                    length: 2,
3109                    line: 1,
3110                    col: 0,
3111                    edges: Vec::new(),
3112                    is_cross_package: false,
3113                },
3114            ));
3115        results
3116            .circular_dependencies
3117            .push(CircularDependencyFinding::with_actions(
3118                CircularDependency {
3119                    files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
3120                    length: 2,
3121                    line: 5,
3122                    col: 0,
3123                    edges: Vec::new(),
3124                    is_cross_package: false,
3125                },
3126            ));
3127        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3128        assert_eq!(filtered.circular_dependencies.len(), 1);
3129    }
3130
3131    #[test]
3132    fn filter_keeps_new_boundary_violations() {
3133        use crate::results::BoundaryViolation;
3134        let baseline = BaselineData {
3135            boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
3136            boundary_coverage_violations: vec![],
3137            boundary_call_violations: vec![],
3138            policy_violations: vec![],
3139            ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3140        };
3141        let mut results = AnalysisResults::default();
3142        results
3143            .boundary_violations
3144            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3145                from_path: PathBuf::from("src/a.ts"),
3146                to_path: PathBuf::from("src/b.ts"),
3147                from_zone: "a".to_string(),
3148                to_zone: "b".to_string(),
3149                import_specifier: "../b".to_string(),
3150                line: 1,
3151                col: 0,
3152            }));
3153        results
3154            .boundary_violations
3155            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3156                from_path: PathBuf::from("src/new.ts"),
3157                to_path: PathBuf::from("src/secret.ts"),
3158                from_zone: "new".to_string(),
3159                to_zone: "secret".to_string(),
3160                import_specifier: "../secret".to_string(),
3161                line: 1,
3162                col: 0,
3163            }));
3164        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3165        assert_eq!(filtered.boundary_violations.len(), 1);
3166    }
3167
3168    #[test]
3169    fn health_targets_baseline_filters_known() {
3170        let root = PathBuf::from("/project");
3171        let targets = vec![
3172            fallow_output::RefactoringTarget {
3173                path: root.join("src/complex.ts"),
3174                priority: 80.0,
3175                efficiency: 40.0,
3176                recommendation: "Split file".to_string(),
3177                category: fallow_output::RecommendationCategory::SplitHighImpact,
3178                effort: fallow_output::EffortEstimate::Medium,
3179                confidence: fallow_output::Confidence::Medium,
3180                factors: vec![],
3181                evidence: None,
3182            },
3183            fallow_output::RefactoringTarget {
3184                path: root.join("src/new-issue.ts"),
3185                priority: 60.0,
3186                efficiency: 30.0,
3187                recommendation: "Extract function".to_string(),
3188                category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
3189                effort: fallow_output::EffortEstimate::Low,
3190                confidence: fallow_output::Confidence::High,
3191                factors: vec![],
3192                evidence: None,
3193            },
3194        ];
3195        let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
3196        let filtered = filter_new_health_targets(targets, &baseline, &root);
3197        assert_eq!(filtered.len(), 1);
3198        assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
3199    }
3200
3201    #[test]
3202    fn duplicate_export_key_is_sorted() {
3203        use crate::results::{DuplicateExport, DuplicateLocation};
3204        let dup_ab = DuplicateExport {
3205            export_name: "foo".to_string(),
3206            locations: vec![
3207                DuplicateLocation {
3208                    path: PathBuf::from("src/a.ts"),
3209                    line: 1,
3210                    col: 0,
3211                },
3212                DuplicateLocation {
3213                    path: PathBuf::from("src/b.ts"),
3214                    line: 5,
3215                    col: 0,
3216                },
3217            ],
3218        };
3219        let dup_ba = DuplicateExport {
3220            export_name: "foo".to_string(),
3221            locations: vec![
3222                DuplicateLocation {
3223                    path: PathBuf::from("src/b.ts"),
3224                    line: 5,
3225                    col: 0,
3226                },
3227                DuplicateLocation {
3228                    path: PathBuf::from("src/a.ts"),
3229                    line: 1,
3230                    col: 0,
3231                },
3232            ],
3233        };
3234        assert_eq!(
3235            super::duplicate_export_key(&dup_ab, Path::new("")),
3236            super::duplicate_export_key(&dup_ba, Path::new("")),
3237        );
3238    }
3239
3240    #[test]
3241    fn boundary_violation_key_format() {
3242        use crate::results::BoundaryViolation;
3243        let v = BoundaryViolation {
3244            from_path: PathBuf::from("src/ui/btn.ts"),
3245            to_path: PathBuf::from("src/db/query.ts"),
3246            from_zone: "ui".to_string(),
3247            to_zone: "db".to_string(),
3248            import_specifier: "../db/query".to_string(),
3249            line: 1,
3250            col: 0,
3251        };
3252        let key = super::boundary_violation_key(&v, Path::new(""));
3253        assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
3254    }
3255
3256    /// Build results with absolute paths rooted at the given prefix.
3257    fn make_absolute_results(root: &str) -> AnalysisResults {
3258        use crate::extract::MemberKind;
3259        use crate::results::*;
3260
3261        let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
3262
3263        AnalysisResults {
3264            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
3265                path: p("src/old.ts"),
3266            })],
3267            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3268                path: p("src/utils.ts"),
3269                export_name: "helper".to_string(),
3270                is_type_only: false,
3271                line: 5,
3272                col: 0,
3273                span_start: 40,
3274                is_re_export: false,
3275            })],
3276            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3277                package_name: "lodash-es".to_string(),
3278                location: DependencyLocation::Dependencies,
3279                path: p("packages/app/package.json"),
3280                line: 5,
3281                used_in_workspaces: Vec::new(),
3282            })],
3283            circular_dependencies: vec![CircularDependencyFinding::with_actions(
3284                CircularDependency {
3285                    files: vec![p("src/a.ts"), p("src/b.ts")],
3286                    length: 2,
3287                    line: 1,
3288                    col: 0,
3289                    edges: Vec::new(),
3290                    is_cross_package: false,
3291                },
3292            )],
3293            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
3294                path: p("src/enums.ts"),
3295                parent_name: "Status".to_string(),
3296                member_name: "Deprecated".to_string(),
3297                kind: MemberKind::EnumMember,
3298                line: 8,
3299                col: 0,
3300            })],
3301            unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
3302                path: p("src/service.ts"),
3303                parent_name: "UserService".to_string(),
3304                member_name: "legacy".to_string(),
3305                kind: MemberKind::ClassMethod,
3306                line: 42,
3307                col: 0,
3308            })],
3309            unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
3310                path: p("src/store.ts"),
3311                parent_name: "useStore".to_string(),
3312                member_name: "legacyAction".to_string(),
3313                kind: MemberKind::StoreMember,
3314                line: 17,
3315                col: 0,
3316            })],
3317            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
3318                path: p("src/app.ts"),
3319                specifier: "./missing".to_string(),
3320                line: 3,
3321                col: 0,
3322                specifier_col: 0,
3323            })],
3324            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
3325                export_name: "Config".to_string(),
3326                locations: vec![
3327                    DuplicateLocation {
3328                        path: p("src/a.ts"),
3329                        line: 1,
3330                        col: 0,
3331                    },
3332                    DuplicateLocation {
3333                        path: p("src/b.ts"),
3334                        line: 5,
3335                        col: 0,
3336                    },
3337                ],
3338            })],
3339            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
3340                from_path: p("src/ui/btn.ts"),
3341                to_path: p("src/db/query.ts"),
3342                from_zone: "ui".to_string(),
3343                to_zone: "db".to_string(),
3344                import_specifier: "../db/query".to_string(),
3345                line: 1,
3346                col: 0,
3347            })],
3348            ..Default::default()
3349        }
3350    }
3351
3352    /// Regression test: baseline saved on one machine (different absolute root)
3353    /// must match issues found on another machine across all path-based types.
3354    #[test]
3355    fn baseline_keys_are_relative_to_root() {
3356        let local_root = Path::new("/Users/dev/project");
3357        let results = make_absolute_results("/Users/dev/project");
3358        let baseline = BaselineData::from_results(&results, local_root);
3359
3360        assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
3361        assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
3362        assert_eq!(
3363            baseline.unused_dependencies,
3364            vec!["packages/app/package.json:lodash-es"]
3365        );
3366        assert_eq!(
3367            baseline.boundary_violations,
3368            vec!["src/ui/btn.ts->src/db/query.ts"]
3369        );
3370        assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
3371        assert_eq!(
3372            baseline.unused_enum_members,
3373            vec!["src/enums.ts:Status.Deprecated"]
3374        );
3375        assert_eq!(
3376            baseline.unused_class_members,
3377            vec!["src/service.ts:UserService.legacy"]
3378        );
3379        assert_eq!(
3380            baseline.unused_store_members,
3381            vec!["src/store.ts:useStore.legacyAction"]
3382        );
3383        assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
3384        assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
3385
3386        let ci_root = Path::new("/home/runner/work/project/project");
3387        let ci_results = make_absolute_results("/home/runner/work/project/project");
3388
3389        let filtered = filter_new_issues(ci_results, &baseline, ci_root);
3390        assert!(filtered.unused_files.is_empty(), "unused files");
3391        assert!(filtered.unused_exports.is_empty(), "unused exports");
3392        assert!(filtered.unused_dependencies.is_empty(), "unused deps");
3393        assert!(
3394            filtered.boundary_violations.is_empty(),
3395            "boundary violations"
3396        );
3397        assert!(filtered.circular_dependencies.is_empty(), "circular deps");
3398        assert!(filtered.unused_enum_members.is_empty(), "enum members");
3399        assert!(filtered.unused_class_members.is_empty(), "class members");
3400        assert!(filtered.unused_store_members.is_empty(), "store members");
3401        assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
3402        assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
3403    }
3404
3405    #[test]
3406    fn stale_suppression_baseline_keys_include_missing_reason_state() {
3407        let root = Path::new("/project");
3408        let stale = crate::results::StaleSuppression {
3409            path: root.join("src/file.ts"),
3410            line: 1,
3411            col: 0,
3412            origin: crate::results::SuppressionOrigin::Comment {
3413                issue_kind: Some("unused-export".to_string()),
3414                reason: None,
3415                is_file_level: false,
3416                kind_known: true,
3417            },
3418            missing_reason: false,
3419            actions: crate::results::StaleSuppression::actions_for(false),
3420        };
3421        let missing = crate::results::StaleSuppression {
3422            missing_reason: true,
3423            actions: crate::results::StaleSuppression::actions_for(true),
3424            ..stale.clone()
3425        };
3426        let results = AnalysisResults {
3427            stale_suppressions: vec![stale, missing],
3428            ..Default::default()
3429        };
3430        let baseline = BaselineData::from_results(&results, root);
3431
3432        assert_eq!(
3433            baseline.stale_suppressions,
3434            vec![
3435                "stale-suppression:src/file.ts:1",
3436                "missing-suppression-reason:src/file.ts:1",
3437            ]
3438        );
3439
3440        let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
3441        legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
3442        let filtered = filter_new_issues(results, &legacy_baseline, root);
3443        assert!(filtered.stale_suppressions.is_empty());
3444    }
3445
3446    fn runtime_finding(
3447        id: &str,
3448        stable_id: Option<&str>,
3449        line: u32,
3450        source_hash: Option<&str>,
3451    ) -> fallow_output::RuntimeCoverageFinding {
3452        fallow_output::RuntimeCoverageFinding {
3453            id: id.to_owned(),
3454            stable_id: stable_id.map(str::to_owned),
3455            source_hash: source_hash.map(str::to_owned),
3456            path: PathBuf::from("src/a.ts"),
3457            function: "alpha".to_owned(),
3458            line,
3459            verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
3460            invocations: Some(0),
3461            confidence: fallow_output::RuntimeCoverageConfidence::Medium,
3462            evidence: fallow_output::RuntimeCoverageEvidence {
3463                static_status: "used".to_owned(),
3464                test_coverage: "not_covered".to_owned(),
3465                v8_tracking: "tracked".to_owned(),
3466                untracked_reason: None,
3467                observation_days: 1,
3468                deployments_observed: 1,
3469            },
3470            actions: vec![],
3471            discriminators: None,
3472        }
3473    }
3474
3475    #[test]
3476    fn legacy_prod_baseline_still_suppresses_finding() {
3477        let baseline = HealthBaselineData {
3478            runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
3479            ..HealthBaselineData::default()
3480        };
3481        let findings = vec![runtime_finding(
3482            "fallow:prod:deadbeef",
3483            Some("fallow:fn:00000001"),
3484            14,
3485            None,
3486        )];
3487        let filtered =
3488            filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3489        assert!(filtered.is_empty(), "legacy prod id must still suppress");
3490    }
3491
3492    #[test]
3493    fn source_hash_baseline_survives_line_move() {
3494        let root = Path::new("/repo");
3495        let baselined = runtime_finding(
3496            "fallow:prod:deadbeef",
3497            Some("fallow:fn:00000001"),
3498            14,
3499            Some("0123456789abcdef"),
3500        );
3501        let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
3502        assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
3503
3504        let findings = vec![runtime_finding(
3505            "fallow:prod:99999999",
3506            Some("fallow:fn:cafe0002"),
3507            40,
3508            Some("0123456789abcdef"),
3509        )];
3510        let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
3511        assert!(
3512            filtered.is_empty(),
3513            "source_hash baseline must survive a line move despite a changed stable_id and id"
3514        );
3515    }
3516
3517    #[test]
3518    fn unbaselined_finding_is_reported() {
3519        let baseline = HealthBaselineData {
3520            runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
3521            ..HealthBaselineData::default()
3522        };
3523        let findings = vec![runtime_finding(
3524            "fallow:prod:abc1234d",
3525            Some("fallow:fn:beefcafe"),
3526            7,
3527            None,
3528        )];
3529        let filtered =
3530            filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3531        assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
3532    }
3533}