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