Skip to main content

fallow_engine/
dead_code.rs

1//! Dead-code result helpers exposed through the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_config::{ResolvedConfig, RulesConfig, Severity};
8use fallow_types::discover::StableFileKey;
9
10pub use crate::results::{
11    AnalysisResults, DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput,
12    DeadCodeAnalysisWithHashes, derive_security_severity, enable_security_rules,
13    security_catalogue_title, security_finding_id, security_rule_id,
14};
15
16pub use crate::effective_severity::{
17    RuleSeverity, SeveritySource, apply_effective_severities, findings_without_severity,
18    promote_effective_warns,
19};
20
21use crate::{
22    EngineResult, session::analyze_dead_code_with_parse_result_from_config, source::ModuleInfo,
23};
24
25/// Run dead-code analysis from pre-parsed modules.
26///
27/// # Errors
28///
29/// Returns an error if discovery, graph construction, or analysis fails.
30pub(crate) fn analyze_with_parse_result(
31    config: &ResolvedConfig,
32    modules: &[ModuleInfo],
33) -> EngineResult<DeadCodeAnalysisArtifacts> {
34    analyze_dead_code_with_parse_result_from_config(config, modules)
35}
36
37/// Scope dead-code results to the union of the given workspace roots.
38///
39/// The full cross-workspace graph is still built before this helper runs, so
40/// cross-package imports are resolved. Only reported findings are narrowed.
41pub fn filter_to_workspaces(results: &mut AnalysisResults, ws_roots: &[PathBuf]) {
42    let any_under = |path: &Path| ws_roots.iter().any(|root| path.starts_with(root));
43    let pkg_jsons = ws_roots
44        .iter()
45        .map(|root| root.join("package.json"))
46        .collect::<Vec<_>>();
47    let in_pkg_jsons = |path: &Path| pkg_jsons.iter().any(|pkg| path == pkg);
48
49    filter_workspace_source_findings(results, &any_under);
50    filter_workspace_dependency_findings(results, &any_under, &in_pkg_jsons);
51    filter_workspace_graph_findings(results, &any_under);
52    filter_workspace_policy_findings(results, &any_under);
53}
54
55/// The scope of one dead-code run, as the surface resolved it.
56///
57/// Every field is optional. A field that is `None` does not narrow the run.
58#[derive(Debug, Clone, Copy)]
59pub struct DeadCodeScope<'a> {
60    /// `--workspace`, `--changed-workspaces` and a positional path: the union
61    /// of these roots.
62    pub workspace_roots: Option<&'a [PathBuf]>,
63    /// `--changed-since`: the files that changed since the ref.
64    pub changed_files: Option<&'a FxHashSet<PathBuf>>,
65    /// A unified diff, with the root that finding paths resolve against.
66    pub diff: Option<(&'a fallow_output::DiffIndex, &'a Path)>,
67    /// `--file`: the only files to report. Dependency findings are dropped,
68    /// because a file list does not own a manifest.
69    pub files: Option<&'a FxHashSet<PathBuf>>,
70}
71
72/// Narrow dead-code results to the scope of the run.
73///
74/// The CLI, the programmatic API and the MCP typed path call this one function,
75/// so a scope narrows the same way on every surface. The filters run in this
76/// order: workspace roots, changed files, the diff, the file list. Then the
77/// configured `ignoreFindings` patterns run again, because the scope filters
78/// remove owners from a finding with several owners (`duplicate_exports`). A
79/// finding that only ignored owners hold after the scope is hidden, as the
80/// "hidden only when every owner matches" rule says.
81pub fn apply_scope(
82    results: &mut AnalysisResults,
83    scope: &DeadCodeScope<'_>,
84    config: &ResolvedConfig,
85) {
86    if let Some(roots) = scope.workspace_roots {
87        filter_to_workspaces(results, roots);
88    }
89    if let Some(changed_files) = scope.changed_files {
90        filter_by_changed_files(results, changed_files);
91    }
92    if let Some((diff, root)) = scope.diff {
93        crate::diff_scope::filter_dead_code_by_diff(results, diff, root);
94    }
95    if let Some(files) = scope.files {
96        filter_by_changed_files(results, files);
97        clear_dependency_findings(results);
98    }
99    filter_configured_ignored_findings(results, config);
100}
101
102fn clear_dependency_findings(results: &mut AnalysisResults) {
103    results.unused_dependencies.clear();
104    results.unused_dev_dependencies.clear();
105    results.unused_optional_dependencies.clear();
106    results.type_only_dependencies.clear();
107    results.test_only_dependencies.clear();
108    results.dev_dependencies_in_production.clear();
109}
110
111/// Scope dead-code results to findings affected by changed files.
112#[expect(
113    clippy::implicit_hasher,
114    reason = "fallow standardizes on FxHashSet across the workspace"
115)]
116pub fn filter_by_changed_files(results: &mut AnalysisResults, changed_files: &FxHashSet<PathBuf>) {
117    crate::changed_files::filter_results_by_changed_files(results, changed_files);
118}
119
120/// Apply configured source-owned finding exclusions to an analysis result.
121///
122/// Analysis stages that append findings after the engine pipeline, such as
123/// type-aware reconciliation, must call this before exposing their final
124/// result.
125pub fn filter_configured_ignored_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
126    if config.ignore_findings.is_empty() {
127        return;
128    }
129
130    results.remove_ignored_dead_code_findings(|path| {
131        let key = if path.is_absolute() {
132            let Ok(relative) = path.strip_prefix(&config.root) else {
133                return false;
134            };
135            StableFileKey::from_relative(relative)
136        } else {
137            StableFileKey::from_relative(path)
138        };
139        config.ignore_findings.is_ignored(key.as_str())
140    });
141}
142
143fn filter_workspace_source_findings(
144    results: &mut AnalysisResults,
145    any_under: &dyn Fn(&Path) -> bool,
146) {
147    results
148        .unused_files
149        .retain(|finding| any_under(&finding.file.path));
150    results
151        .unused_exports
152        .retain(|finding| any_under(&finding.export.path));
153    results
154        .unused_types
155        .retain(|finding| any_under(&finding.export.path));
156    results
157        .private_type_leaks
158        .retain(|finding| any_under(&finding.leak.path));
159    results
160        .deprecated_exports_in_use
161        .retain(|finding| any_under(&finding.export.path));
162    results
163        .unused_enum_members
164        .retain(|finding| any_under(&finding.member.path));
165    results
166        .unused_class_members
167        .retain(|finding| any_under(&finding.member.path));
168    results
169        .unused_store_members
170        .retain(|finding| any_under(&finding.member.path));
171    results
172        .unprovided_injects
173        .retain(|finding| any_under(&finding.inject.path));
174    results
175        .unrendered_components
176        .retain(|finding| any_under(&finding.component.path));
177    results
178        .unused_component_props
179        .retain(|finding| any_under(&finding.prop.path));
180    results
181        .unused_component_emits
182        .retain(|finding| any_under(&finding.emit.path));
183    results
184        .unused_component_inputs
185        .retain(|finding| any_under(&finding.input.path));
186    results
187        .unused_component_outputs
188        .retain(|finding| any_under(&finding.output.path));
189    results
190        .unused_svelte_events
191        .retain(|finding| any_under(&finding.event.path));
192    results
193        .unused_server_actions
194        .retain(|finding| any_under(&finding.action.path));
195    results
196        .unused_load_data_keys
197        .retain(|finding| any_under(&finding.key.path));
198    results
199        .unresolved_imports
200        .retain(|finding| any_under(&finding.import.path));
201}
202
203fn filter_workspace_dependency_findings(
204    results: &mut AnalysisResults,
205    any_under: &dyn Fn(&Path) -> bool,
206    in_pkg_jsons: &dyn Fn(&Path) -> bool,
207) {
208    results
209        .unused_dependencies
210        .retain(|finding| in_pkg_jsons(&finding.dep.path));
211    results
212        .unused_dev_dependencies
213        .retain(|finding| in_pkg_jsons(&finding.dep.path));
214    results
215        .unused_optional_dependencies
216        .retain(|finding| in_pkg_jsons(&finding.dep.path));
217    results
218        .type_only_dependencies
219        .retain(|finding| in_pkg_jsons(&finding.dep.path));
220    results
221        .test_only_dependencies
222        .retain(|finding| in_pkg_jsons(&finding.dep.path));
223    results
224        .dev_dependencies_in_production
225        .retain(|finding| in_pkg_jsons(&finding.dep.path));
226
227    results.unlisted_dependencies.retain(|finding| {
228        finding
229            .dep
230            .imported_from
231            .iter()
232            .any(|source| any_under(&source.path))
233    });
234    results.unused_dependency_overrides.clear();
235    results.misconfigured_dependency_overrides.clear();
236}
237
238fn filter_workspace_graph_findings(
239    results: &mut AnalysisResults,
240    any_under: &dyn Fn(&Path) -> bool,
241) {
242    for duplicate in &mut results.duplicate_exports {
243        duplicate
244            .export
245            .locations
246            .retain(|location| any_under(&location.path));
247    }
248    results
249        .duplicate_exports
250        .retain(|duplicate| duplicate.export.locations.len() >= 2);
251
252    results
253        .circular_dependencies
254        .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
255
256    results
257        .re_export_cycles
258        .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
259}
260
261fn filter_workspace_policy_findings(
262    results: &mut AnalysisResults,
263    any_under: &dyn Fn(&Path) -> bool,
264) {
265    results
266        .boundary_violations
267        .retain(|finding| any_under(&finding.violation.from_path));
268    results
269        .boundary_coverage_violations
270        .retain(|finding| any_under(&finding.violation.path));
271    results
272        .boundary_call_violations
273        .retain(|finding| any_under(&finding.violation.path));
274    results
275        .policy_violations
276        .retain(|finding| any_under(&finding.violation.path));
277
278    results
279        .stale_suppressions
280        .retain(|finding| any_under(&finding.path));
281
282    results
283        .security_findings
284        .retain(|finding| any_under(&finding.path));
285    results
286        .security_unresolved_callee_diagnostics
287        .retain(|finding| any_under(&finding.path));
288
289    results.unused_catalog_entries.clear();
290    results.empty_catalog_groups.clear();
291    results
292        .unresolved_catalog_references
293        .retain(|finding| any_under(&finding.reference.path));
294
295    results
296        .invalid_client_exports
297        .retain(|finding| any_under(&finding.export.path));
298
299    results
300        .mixed_client_server_barrels
301        .retain(|finding| any_under(&finding.barrel.path));
302
303    results
304        .misplaced_directives
305        .retain(|finding| any_under(&finding.directive_site.path));
306
307    results
308        .route_collisions
309        .retain(|finding| any_under(&finding.collision.path));
310
311    results
312        .dynamic_segment_name_conflicts
313        .retain(|finding| any_under(&finding.conflict.path));
314}
315
316/// Remove findings whose effective severity is `Off` from an analysis result.
317///
318/// Every surface that reports findings runs this pass: the `check` command
319/// (which also serves `dead-code` and the CLI audit), the editor analysis path
320/// behind inline diagnostics and the sidebar, and the programmatic runtime
321/// behind the MCP tools, the decision surface and the Node bindings. Each of
322/// them runs it at the same two points, once over the freshly analyzed set and
323/// once after type-aware reconciliation, because reconciliation can append
324/// findings. The pass removes findings and writes the gate severity of each
325/// finding that stays, so the second run is idempotent when nothing was
326/// appended.
327///
328/// When overrides are configured, per-file rule resolution is used for
329/// file-scoped issue types. Circular dependencies resolve against every file in
330/// the cycle. Non-file-scoped issues (unused deps, unlisted deps, duplicate
331/// exports) use the base rules only.
332pub fn apply_rule_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
333    let rules = &config.rules;
334    let has_overrides = !config.overrides.is_empty();
335
336    if has_overrides {
337        apply_file_override_rules(results, config);
338        apply_boundary_override_rules(results, config);
339    } else {
340        apply_base_file_rules(results, rules);
341    }
342
343    apply_base_collection_rules(results, rules);
344    apply_effective_severities(results, config);
345}
346
347fn apply_base_collection_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
348    if rules.unused_dependencies == Severity::Off {
349        results.unused_dependencies.clear();
350    }
351    if rules.unused_dev_dependencies == Severity::Off {
352        results.unused_dev_dependencies.clear();
353    }
354    if rules.unused_optional_dependencies == Severity::Off {
355        results.unused_optional_dependencies.clear();
356    }
357    if rules.unlisted_dependencies == Severity::Off {
358        results.unlisted_dependencies.clear();
359    }
360    if rules.duplicate_exports == Severity::Off {
361        results.duplicate_exports.clear();
362    }
363    if rules.type_only_dependencies == Severity::Off {
364        results.type_only_dependencies.clear();
365    }
366    if rules.test_only_dependencies == Severity::Off {
367        results.test_only_dependencies.clear();
368    }
369    if rules.dev_dependencies_in_production == Severity::Off {
370        results.dev_dependencies_in_production.clear();
371    }
372    if rules.circular_dependencies == Severity::Off {
373        results.circular_dependencies.clear();
374    }
375    if rules.re_export_cycle == Severity::Off {
376        results.re_export_cycles.clear();
377    }
378    if rules.boundary_violation == Severity::Off {
379        results.boundary_violations.clear();
380        results.boundary_coverage_violations.clear();
381        results.boundary_call_violations.clear();
382    }
383    if rules.policy_violation == Severity::Off {
384        results.policy_violations.clear();
385    }
386    if rules.unused_catalog_entries == Severity::Off {
387        results.unused_catalog_entries.clear();
388    }
389    if rules.empty_catalog_groups == Severity::Off {
390        results.empty_catalog_groups.clear();
391    }
392    if rules.unresolved_catalog_references == Severity::Off {
393        results.unresolved_catalog_references.clear();
394    }
395    if rules.unused_dependency_overrides == Severity::Off {
396        results.unused_dependency_overrides.clear();
397    }
398    if rules.misconfigured_dependency_overrides == Severity::Off {
399        results.misconfigured_dependency_overrides.clear();
400    }
401}
402
403fn apply_file_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
404    apply_dead_code_override_rules(results, config);
405    apply_catalog_override_rules(results, config);
406    apply_framework_override_rules(results, config);
407    apply_circular_override_rules(results, config);
408}
409
410fn apply_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
411    apply_core_dead_code_override_rules(results, config);
412    apply_component_dead_code_override_rules(results, config);
413}
414
415/// Retain core (non-component) dead-code findings whose per-file rule is not Off.
416fn apply_core_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
417    results
418        .unused_files
419        .retain(|f| config.resolve_rules_for_path(&f.file.path).unused_files != Severity::Off);
420    results
421        .unused_exports
422        .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_exports != Severity::Off);
423    results
424        .unused_types
425        .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_types != Severity::Off);
426    results.private_type_leaks.retain(|e| {
427        config
428            .resolve_rules_for_path(&e.leak.path)
429            .private_type_leaks
430            != Severity::Off
431    });
432    results.deprecated_exports_in_use.retain(|e| {
433        config
434            .resolve_rules_for_path(&e.export.path)
435            .deprecated_exports_in_use
436            != Severity::Off
437    });
438    results.unused_enum_members.retain(|m| {
439        config
440            .resolve_rules_for_path(&m.member.path)
441            .unused_enum_members
442            != Severity::Off
443    });
444    results.unused_class_members.retain(|m| {
445        config
446            .resolve_rules_for_path(&m.member.path)
447            .unused_class_members
448            != Severity::Off
449    });
450    results.unused_store_members.retain(|m| {
451        config
452            .resolve_rules_for_path(&m.member.path)
453            .unused_store_members
454            != Severity::Off
455    });
456    results.unprovided_injects.retain(|f| {
457        config
458            .resolve_rules_for_path(&f.inject.path)
459            .unprovided_injects
460            != Severity::Off
461    });
462    results.unresolved_imports.retain(|i| {
463        config
464            .resolve_rules_for_path(&i.import.path)
465            .unresolved_imports
466            != Severity::Off
467    });
468}
469
470/// Retain component-shaped dead-code findings whose per-file rule is not Off.
471fn apply_component_dead_code_override_rules(
472    results: &mut AnalysisResults,
473    config: &ResolvedConfig,
474) {
475    results.unrendered_components.retain(|c| {
476        config
477            .resolve_rules_for_path(&c.component.path)
478            .unrendered_components
479            != Severity::Off
480    });
481    results.unused_component_props.retain(|p| {
482        config
483            .resolve_rules_for_path(&p.prop.path)
484            .unused_component_props
485            != Severity::Off
486    });
487    results.unused_component_emits.retain(|e| {
488        config
489            .resolve_rules_for_path(&e.emit.path)
490            .unused_component_emits
491            != Severity::Off
492    });
493    results.unused_component_inputs.retain(|i| {
494        config
495            .resolve_rules_for_path(&i.input.path)
496            .unused_component_inputs
497            != Severity::Off
498    });
499    results.unused_component_outputs.retain(|o| {
500        config
501            .resolve_rules_for_path(&o.output.path)
502            .unused_component_outputs
503            != Severity::Off
504    });
505    results.unused_svelte_events.retain(|e| {
506        config
507            .resolve_rules_for_path(&e.event.path)
508            .unused_svelte_events
509            != Severity::Off
510    });
511    results.unused_server_actions.retain(|a| {
512        config
513            .resolve_rules_for_path(&a.action.path)
514            .unused_server_actions
515            != Severity::Off
516    });
517    results.unused_load_data_keys.retain(|k| {
518        config
519            .resolve_rules_for_path(&k.key.path)
520            .unused_load_data_keys
521            != Severity::Off
522    });
523}
524
525fn apply_catalog_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
526    results.stale_suppressions.retain(|s| {
527        let rules = config.resolve_rules_for_path(&s.path);
528        if s.missing_reason {
529            rules.require_suppression_reason != Severity::Off
530        } else {
531            rules.stale_suppressions != Severity::Off
532        }
533    });
534    results.unresolved_catalog_references.retain(|r| {
535        config
536            .resolve_rules_for_path(&r.reference.path)
537            .unresolved_catalog_references
538            != Severity::Off
539    });
540    results.empty_catalog_groups.retain(|g| {
541        config
542            .resolve_rules_for_path(&g.group.path)
543            .empty_catalog_groups
544            != Severity::Off
545    });
546    results.unused_dependency_overrides.retain(|o| {
547        config
548            .resolve_rules_for_path(&o.entry.path)
549            .unused_dependency_overrides
550            != Severity::Off
551    });
552    results.misconfigured_dependency_overrides.retain(|o| {
553        config
554            .resolve_rules_for_path(&o.entry.path)
555            .misconfigured_dependency_overrides
556            != Severity::Off
557    });
558}
559
560fn apply_framework_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
561    results.invalid_client_exports.retain(|e| {
562        config
563            .resolve_rules_for_path(&e.export.path)
564            .invalid_client_export
565            != Severity::Off
566    });
567    results.mixed_client_server_barrels.retain(|b| {
568        config
569            .resolve_rules_for_path(&b.barrel.path)
570            .mixed_client_server_barrel
571            != Severity::Off
572    });
573    results.misplaced_directives.retain(|d| {
574        config
575            .resolve_rules_for_path(&d.directive_site.path)
576            .misplaced_directive
577            != Severity::Off
578    });
579    results.route_collisions.retain(|c| {
580        config
581            .resolve_rules_for_path(&c.collision.path)
582            .route_collision
583            != Severity::Off
584    });
585    results.dynamic_segment_name_conflicts.retain(|c| {
586        config
587            .resolve_rules_for_path(&c.conflict.path)
588            .dynamic_segment_name_conflict
589            != Severity::Off
590    });
591}
592
593fn apply_circular_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
594    results.circular_dependencies.retain(|c| {
595        c.cycle
596            .files
597            .iter()
598            .any(|path| config.resolve_rules_for_path(path).circular_dependencies != Severity::Off)
599    });
600}
601
602fn apply_base_file_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
603    clear_base_core_dead_code(results, rules);
604    clear_base_component_dead_code(results, rules);
605    clear_base_suppression_and_framework(results, rules);
606}
607
608/// Clear core (non-component) dead-code findings whose base rule is Off.
609fn clear_base_core_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
610    if rules.unused_files == Severity::Off {
611        results.unused_files.clear();
612    }
613    if rules.unused_exports == Severity::Off {
614        results.unused_exports.clear();
615    }
616    if rules.unused_types == Severity::Off {
617        results.unused_types.clear();
618    }
619    if rules.private_type_leaks == Severity::Off {
620        results.private_type_leaks.clear();
621    }
622    if rules.deprecated_exports_in_use == Severity::Off {
623        results.deprecated_exports_in_use.clear();
624    }
625    if rules.unused_enum_members == Severity::Off {
626        results.unused_enum_members.clear();
627    }
628    if rules.unused_class_members == Severity::Off {
629        results.unused_class_members.clear();
630    }
631    if rules.unused_store_members == Severity::Off {
632        results.unused_store_members.clear();
633    }
634    if rules.unprovided_injects == Severity::Off {
635        results.unprovided_injects.clear();
636    }
637    if rules.unresolved_imports == Severity::Off {
638        results.unresolved_imports.clear();
639    }
640}
641
642/// Clear component-shaped dead-code findings whose base rule is Off.
643fn clear_base_component_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
644    if rules.unrendered_components == Severity::Off {
645        results.unrendered_components.clear();
646    }
647    if rules.unused_component_props == Severity::Off {
648        results.unused_component_props.clear();
649    }
650    if rules.unused_component_emits == Severity::Off {
651        results.unused_component_emits.clear();
652    }
653    if rules.unused_component_inputs == Severity::Off {
654        results.unused_component_inputs.clear();
655    }
656    if rules.unused_component_outputs == Severity::Off {
657        results.unused_component_outputs.clear();
658    }
659    if rules.unused_svelte_events == Severity::Off {
660        results.unused_svelte_events.clear();
661    }
662    if rules.unused_server_actions == Severity::Off {
663        results.unused_server_actions.clear();
664    }
665    if rules.unused_load_data_keys == Severity::Off {
666        results.unused_load_data_keys.clear();
667    }
668}
669
670/// Apply base stale-suppression retention and clear framework findings whose
671/// base rule is Off.
672fn clear_base_suppression_and_framework(results: &mut AnalysisResults, rules: &RulesConfig) {
673    results.stale_suppressions.retain(|s| {
674        if s.missing_reason {
675            rules.require_suppression_reason != Severity::Off
676        } else {
677            rules.stale_suppressions != Severity::Off
678        }
679    });
680    if rules.invalid_client_export == Severity::Off {
681        results.invalid_client_exports.clear();
682    }
683    if rules.mixed_client_server_barrel == Severity::Off {
684        results.mixed_client_server_barrels.clear();
685    }
686    if rules.misplaced_directive == Severity::Off {
687        results.misplaced_directives.clear();
688    }
689    if rules.route_collision == Severity::Off {
690        results.route_collisions.clear();
691    }
692    if rules.dynamic_segment_name_conflict == Severity::Off {
693        results.dynamic_segment_name_conflicts.clear();
694    }
695}
696
697fn apply_boundary_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
698    results.boundary_violations.retain(|v| {
699        config
700            .resolve_rules_for_path(&v.violation.from_path)
701            .boundary_violation
702            != Severity::Off
703    });
704    results.boundary_coverage_violations.retain(|v| {
705        config
706            .resolve_rules_for_path(&v.violation.path)
707            .boundary_violation
708            != Severity::Off
709    });
710    results.boundary_call_violations.retain(|v| {
711        config
712            .resolve_rules_for_path(&v.violation.path)
713            .boundary_violation
714            != Severity::Off
715    });
716    results.policy_violations.retain(|v| {
717        config
718            .resolve_rules_for_path(&v.violation.path)
719            .policy_violation
720            != Severity::Off
721    });
722}
723
724#[cfg(test)]
725mod tests {
726    use std::path::PathBuf;
727
728    use super::*;
729    use fallow_types::output_dead_code::{
730        BoundaryViolationFinding, CircularDependencyFinding, PrivateTypeLeakFinding,
731        UnusedExportFinding, UnusedFileFinding,
732    };
733    use fallow_types::results::{
734        BoundaryViolation, CircularDependency, PrivateTypeLeak, UnusedExport, UnusedFile,
735    };
736
737    #[test]
738    fn workspace_filter_keeps_findings_under_workspace_root() {
739        let root = PathBuf::from("/repo/packages/app");
740        let mut results = AnalysisResults::default();
741        results
742            .unused_files
743            .push(UnusedFileFinding::with_actions(UnusedFile {
744                path: root.join("src/unused.ts"),
745            }));
746        results
747            .unused_files
748            .push(UnusedFileFinding::with_actions(UnusedFile {
749                path: PathBuf::from("/repo/packages/docs/src/unused.ts"),
750            }));
751
752        filter_to_workspaces(&mut results, std::slice::from_ref(&root));
753
754        assert_eq!(results.unused_files.len(), 1);
755        assert_eq!(
756            results.unused_files[0].file.path,
757            root.join("src/unused.ts")
758        );
759    }
760
761    #[test]
762    fn configured_filter_removes_findings_added_after_engine_analysis() {
763        let project = tempfile::tempdir().expect("project");
764        let config = serde_json::from_str::<fallow_config::FallowConfig>(
765            r#"{"ignoreFindings":["src/hidden.ts"]}"#,
766        )
767        .expect("config parses")
768        .resolve(
769            project.path().to_path_buf(),
770            fallow_config::OutputFormat::Human,
771            1,
772            true,
773            true,
774            None,
775        );
776        let mut results = AnalysisResults::default();
777        results
778            .private_type_leaks
779            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
780                path: project.path().join("src/hidden.ts"),
781                export_name: "publicApi".to_string(),
782                type_name: "PrivateShape".to_string(),
783                line: 1,
784                col: 0,
785                span_start: 0,
786                semantic: None,
787            }));
788        results
789            .boundary_violations
790            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
791                from_path: project.path().join("src/hidden.ts"),
792                to_path: project.path().join("src/data.ts"),
793                from_zone: "ui".to_string(),
794                to_zone: "data".to_string(),
795                import_specifier: "./data".to_string(),
796                line: 1,
797                col: 0,
798            }));
799
800        filter_configured_ignored_findings(&mut results, &config);
801
802        assert!(results.private_type_leaks.is_empty());
803        assert_eq!(results.boundary_violations.len(), 1);
804    }
805
806    fn config_with_override(
807        pattern: &str,
808        configure: impl FnOnce(&mut fallow_config::PartialRulesConfig),
809    ) -> ResolvedConfig {
810        let mut partial = fallow_config::PartialRulesConfig::default();
811        configure(&mut partial);
812        fallow_config::FallowConfig {
813            rules: RulesConfig {
814                private_type_leaks: Severity::Warn,
815                ..RulesConfig::default()
816            },
817            overrides: vec![fallow_config::ConfigOverride {
818                files: vec![pattern.to_string()],
819                rules: partial,
820            }],
821            ..fallow_config::FallowConfig::default()
822        }
823        .resolve(
824            PathBuf::from("/project"),
825            fallow_config::OutputFormat::Human,
826            1,
827            true,
828            true,
829            None,
830        )
831    }
832
833    fn unused_export(path: &str) -> UnusedExportFinding {
834        UnusedExportFinding::with_actions(UnusedExport {
835            path: PathBuf::from(path),
836            export_name: "Unused".to_string(),
837            is_type_only: false,
838            line: 1,
839            col: 0,
840            span_start: 0,
841            is_re_export: false,
842            deprecated: false,
843            deprecated_reason: None,
844        })
845    }
846
847    fn private_type_leak(path: &str) -> PrivateTypeLeakFinding {
848        PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
849            path: PathBuf::from(path),
850            export_name: "Unused".to_string(),
851            type_name: "Props".to_string(),
852            line: 1,
853            col: 0,
854            span_start: 0,
855            semantic: None,
856        })
857    }
858
859    fn overridden_fixture() -> AnalysisResults {
860        let mut results = AnalysisResults::default();
861        results
862            .unused_exports
863            .push(unused_export("/project/src/ui/kit.ts"));
864        results
865            .unused_exports
866            .push(unused_export("/project/src/lib/util.ts"));
867        results
868            .private_type_leaks
869            .push(private_type_leak("/project/src/ui/kit.ts"));
870        results
871            .private_type_leaks
872            .push(private_type_leak("/project/src/lib/util.ts"));
873        results
874    }
875
876    #[test]
877    fn rule_severities_drop_findings_only_on_overridden_paths() {
878        let config = config_with_override("src/ui/**", |rules| {
879            rules.unused_exports = Some(Severity::Off);
880            rules.private_type_leaks = Some(Severity::Off);
881        });
882        let mut results = overridden_fixture();
883
884        apply_rule_severities(&mut results, &config);
885
886        assert_eq!(
887            results
888                .unused_exports
889                .iter()
890                .map(|finding| finding.export.path.clone())
891                .collect::<Vec<_>>(),
892            vec![PathBuf::from("/project/src/lib/util.ts")]
893        );
894        assert_eq!(
895            results
896                .private_type_leaks
897                .iter()
898                .map(|finding| finding.leak.path.clone())
899                .collect::<Vec<_>>(),
900            vec![PathBuf::from("/project/src/lib/util.ts")]
901        );
902    }
903
904    #[test]
905    fn rule_severities_are_idempotent() {
906        // The editor path resolves severities once after analysis and again
907        // after type-aware reconciliation, so a second pass must not change
908        // the result set.
909        let config = config_with_override("src/ui/**", |rules| {
910            rules.unused_exports = Some(Severity::Off);
911            rules.private_type_leaks = Some(Severity::Off);
912        });
913
914        let mut once = overridden_fixture();
915        apply_rule_severities(&mut once, &config);
916        let mut twice = overridden_fixture();
917        apply_rule_severities(&mut twice, &config);
918        apply_rule_severities(&mut twice, &config);
919
920        assert_eq!(
921            once.unused_exports
922                .iter()
923                .map(|finding| finding.export.path.clone())
924                .collect::<Vec<_>>(),
925            twice
926                .unused_exports
927                .iter()
928                .map(|finding| finding.export.path.clone())
929                .collect::<Vec<_>>()
930        );
931        assert_eq!(
932            once.private_type_leaks
933                .iter()
934                .map(|finding| finding.leak.path.clone())
935                .collect::<Vec<_>>(),
936            twice
937                .private_type_leaks
938                .iter()
939                .map(|finding| finding.leak.path.clone())
940                .collect::<Vec<_>>()
941        );
942    }
943
944    #[test]
945    fn rule_severities_keep_a_cycle_when_any_member_file_stays_enabled() {
946        let config = config_with_override("src/ui/**", |rules| {
947            rules.circular_dependencies = Some(Severity::Off);
948        });
949        let mut results = AnalysisResults::default();
950        results
951            .circular_dependencies
952            .push(CircularDependencyFinding::with_actions(
953                CircularDependency {
954                    files: vec![
955                        PathBuf::from("/project/src/ui/a.ts"),
956                        PathBuf::from("/project/src/lib/b.ts"),
957                    ],
958                    length: 2,
959                    line: 1,
960                    col: 0,
961                    edges: Vec::new(),
962                    is_cross_package: false,
963                },
964            ));
965        results
966            .circular_dependencies
967            .push(CircularDependencyFinding::with_actions(
968                CircularDependency {
969                    files: vec![
970                        PathBuf::from("/project/src/ui/c.ts"),
971                        PathBuf::from("/project/src/ui/d.ts"),
972                    ],
973                    length: 2,
974                    line: 1,
975                    col: 0,
976                    edges: Vec::new(),
977                    is_cross_package: false,
978                },
979            ));
980
981        apply_rule_severities(&mut results, &config);
982
983        assert_eq!(results.circular_dependencies.len(), 1);
984        assert_eq!(
985            results.circular_dependencies[0].cycle.files[0],
986            PathBuf::from("/project/src/ui/a.ts")
987        );
988    }
989
990    #[test]
991    fn rule_severities_clear_base_rules_without_overrides() {
992        let config = fallow_config::FallowConfig {
993            rules: RulesConfig {
994                unused_exports: Severity::Off,
995                private_type_leaks: Severity::Warn,
996                ..RulesConfig::default()
997            },
998            ..fallow_config::FallowConfig::default()
999        }
1000        .resolve(
1001            PathBuf::from("/project"),
1002            fallow_config::OutputFormat::Human,
1003            1,
1004            true,
1005            true,
1006            None,
1007        );
1008        let mut results = overridden_fixture();
1009
1010        apply_rule_severities(&mut results, &config);
1011
1012        assert!(results.unused_exports.is_empty());
1013        assert_eq!(results.private_type_leaks.len(), 2);
1014    }
1015}