Skip to main content

fallow_engine/
baseline.rs

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