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