1use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_config::{
8 ResolvedConfig, RulesConfig, Severity, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
9};
10use fallow_types::discover::StableFileKey;
11
12pub use crate::results::{
13 AnalysisResults, DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput,
14 DeadCodeAnalysisWithHashes, derive_security_severity, enable_security_rules,
15 security_catalogue_title, security_finding_id, security_rule_id,
16};
17
18pub use crate::effective_severity::{
19 RuleSeverity, SeveritySource, apply_effective_severities, findings_without_severity,
20 promote_effective_warns,
21};
22
23use crate::{
24 EngineResult, change_scope::ChangeScope,
25 session::analyze_dead_code_with_parse_result_from_config, source::ModuleInfo,
26};
27
28pub(crate) fn analyze_with_parse_result(
34 config: &ResolvedConfig,
35 modules: &[ModuleInfo],
36) -> EngineResult<DeadCodeAnalysisArtifacts> {
37 analyze_dead_code_with_parse_result_from_config(config, modules)
38}
39
40#[must_use]
54pub fn config_pattern_diagnostics(
55 config: &ResolvedConfig,
56 reports_dependencies: bool,
57) -> Vec<WorkspaceDiagnostic> {
58 let dependency_globs = if reports_dependencies && dependency_rules_on(&config.rules) {
59 config.ignore_dependencies.unmatched_globs()
60 } else {
61 Vec::new()
62 };
63 let finding_patterns = config.ignore_findings.unmatched_patterns();
64 finding_patterns
65 .into_iter()
66 .map(
67 |pattern| WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched {
68 pattern: pattern.to_owned(),
69 },
70 )
71 .chain(dependency_globs.into_iter().map(|pattern| {
72 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
73 pattern: pattern.to_owned(),
74 }
75 }))
76 .map(|kind| {
77 WorkspaceDiagnostic::new(&config.root, config.root.clone(), kind)
78 .into_root_relative(&config.root)
79 })
80 .collect()
81}
82
83fn dependency_rules_on(rules: &RulesConfig) -> bool {
86 [
87 rules.unused_dependencies,
88 rules.unused_dev_dependencies,
89 rules.unused_optional_dependencies,
90 rules.unlisted_dependencies,
91 rules.type_only_dependencies,
92 rules.test_only_dependencies,
93 rules.dev_dependencies_in_production,
94 ]
95 .into_iter()
96 .any(|severity| severity != Severity::Off)
97}
98
99pub fn stamp_finding_ids(results: &mut AnalysisResults, root: &Path) {
105 fallow_types::identity::stamp_dead_code_finding_ids(results, root);
106}
107
108pub fn stamp_missing_finding_ids(results: &mut AnalysisResults, root: &Path) {
114 fallow_types::identity::stamp_missing_dead_code_finding_ids(results, root);
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct FindingIdFilter {
125 requested: Vec<String>,
126 set: FxHashSet<String>,
127}
128
129impl FindingIdFilter {
130 pub fn parse<S: AsRef<str>>(values: &[S]) -> Result<Option<Self>, String> {
139 if values.is_empty() {
140 return Ok(None);
141 }
142 let mut requested = Vec::with_capacity(values.len());
143 let mut set = FxHashSet::default();
144 for value in values {
145 let id = value.as_ref().trim();
146 if !fallow_types::identity::is_dead_code_finding_id(id) {
147 return Err(format!(
148 "invalid finding id '{id}': expected {}:<rule>:<16 hex digits>, \
149 optionally with a ~<k> suffix, as printed in the finding_id field",
150 fallow_types::identity::DEAD_CODE_ID_SCHEME
151 ));
152 }
153 if set.insert(id.to_owned()) {
154 requested.push(id.to_owned());
155 }
156 }
157 Ok(Some(Self { requested, set }))
158 }
159
160 #[must_use]
162 pub fn present(&self, results: &mut AnalysisResults) -> FxHashSet<String> {
163 fallow_types::identity::present_dead_code_finding_ids(results, &self.set)
164 }
165
166 pub fn apply(
173 &self,
174 results: &mut AnalysisResults,
175 config: &ResolvedConfig,
176 filtered: &FxHashSet<String>,
177 run_reasons: impl IntoIterator<Item = fallow_output::FindingIdQueryReason>,
178 ) -> fallow_output::FindingIdQuery {
179 let found = fallow_types::identity::retain_dead_code_findings_by_id(results, &self.set);
180 let rule_off = self
181 .requested
182 .iter()
183 .filter(|id| !found.contains(*id))
184 .any(|id| finding_id_rule_is_off(id, config));
185 fallow_output::FindingIdQuery::new(
186 self.requested.clone(),
187 |id| found.contains(id),
188 |id| filtered.contains(id),
189 run_reasons
190 .into_iter()
191 .chain(rule_off.then_some(fallow_output::FindingIdQueryReason::RuleOff)),
192 analysis_fingerprint(config),
193 )
194 }
195}
196
197#[derive(Debug, Clone)]
205pub struct FindingIdTrace {
206 filter: FindingIdFilter,
207 before_stage: FxHashSet<String>,
208 filtered: FxHashSet<String>,
209}
210
211impl FindingIdTrace {
212 #[must_use]
214 pub fn start(filter: FindingIdFilter, results: &mut AnalysisResults) -> Self {
215 let before_stage = filter.present(results);
216 Self {
217 filter,
218 before_stage,
219 filtered: FxHashSet::default(),
220 }
221 }
222
223 pub fn start_stage(&mut self, results: &mut AnalysisResults) {
225 self.before_stage = self.filter.present(results);
226 }
227
228 pub fn end_stage(&mut self, results: &mut AnalysisResults) {
230 let after = self.filter.present(results);
231 self.filtered
232 .extend(self.before_stage.drain().filter(|id| !after.contains(id)));
233 }
234
235 pub fn finish(
237 self,
238 results: &mut AnalysisResults,
239 config: &ResolvedConfig,
240 run_reasons: impl IntoIterator<Item = fallow_output::FindingIdQueryReason>,
241 ) -> fallow_output::FindingIdQuery {
242 self.filter
243 .apply(results, config, &self.filtered, run_reasons)
244 }
245}
246
247const ANALYSIS_FINGERPRINT_SCHEME: &str = "af1";
250
251const IGNORE_FILE_NAMES: &[&str] = &[".gitignore", ".ignore"];
253
254const RESOLUTION_FILE_GLOBS: &[&str] =
258 &["**/package.json", "**/tsconfig*.json", "**/jsconfig*.json"];
259
260const MAX_EXTENDS_DEPTH: usize = 8;
262
263#[must_use]
283pub fn analysis_fingerprint(config: &ResolvedConfig) -> String {
284 analysis_fingerprint_for_version(config, env!("CARGO_PKG_VERSION"))
285}
286
287#[must_use]
289pub fn analysis_fingerprint_for_version(config: &ResolvedConfig, version: &str) -> String {
290 let rules = serde_json::to_string(&config.rules).unwrap_or_default();
291 let projects: Vec<String> = config
292 .type_aware
293 .projects
294 .iter()
295 .map(|project| root_relative_text(&config.root, project))
296 .collect();
297 let type_aware = format!(
298 "{}:{}:{}",
299 config.type_aware.enabled,
300 serde_json::to_string(&config.type_aware.require).unwrap_or_default(),
301 projects.join("|")
302 );
303 let max_file_size = config
304 .max_file_size_bytes
305 .map_or_else(|| "none".to_owned(), |bytes| bytes.to_string());
306 let input_files = input_files_digest(config);
307 let hash = fallow_types::identity::fnv1a64_parts(&[
308 ANALYSIS_FINGERPRINT_SCHEME,
309 version,
310 &config.detection_config_digest,
311 if config.production {
312 "production"
313 } else {
314 "all"
315 },
316 if config.include_entry_exports {
317 "entry-exports"
318 } else {
319 "no-entry-exports"
320 },
321 &rules,
322 &type_aware,
323 &max_file_size,
324 &input_files,
325 ]);
326 format!("{ANALYSIS_FINGERPRINT_SCHEME}:{hash}")
327}
328
329fn root_relative_text(root: &Path, path: &str) -> String {
332 Path::new(path).strip_prefix(root).map_or_else(
333 |_| path.replace('\\', "/"),
334 |relative| StableFileKey::from_relative(relative).as_str().to_owned(),
335 )
336}
337
338fn normalized_text(content: &[u8]) -> String {
342 String::from_utf8_lossy(content)
343 .replace("\r\n", "\n")
344 .trim_end_matches('\n')
345 .to_owned()
346}
347
348fn fingerprint_walk_builder(root: &Path) -> ignore::WalkBuilder {
355 let mut builder = ignore::WalkBuilder::new(root);
356 builder
357 .hidden(false)
358 .git_ignore(true)
359 .git_global(false)
360 .git_exclude(true)
361 .filter_entry(|entry| {
362 let is_dir = entry.file_type().is_some_and(|kind| kind.is_dir());
363 if !is_dir || entry.depth() == 0 {
364 return true;
365 }
366 let name = entry.file_name().to_string_lossy();
367 !name.starts_with('.') && name != "node_modules"
368 });
369 builder
370}
371
372fn resolution_file_globs(config: &ResolvedConfig) -> globset::GlobSet {
375 let mut builder = globset::GlobSetBuilder::new();
376 let external = config
377 .external_plugins
378 .iter()
379 .flat_map(|plugin| plugin.config_patterns.iter().map(String::as_str));
380 let patterns = crate::core_backend::builtin_config_patterns()
381 .into_iter()
382 .chain(external)
383 .chain(RESOLUTION_FILE_GLOBS.iter().copied());
384 for pattern in patterns {
385 let anywhere = if pattern.starts_with("**/") {
386 pattern.to_owned()
387 } else {
388 format!("**/{pattern}")
389 };
390 for candidate in [pattern.to_owned(), anywhere] {
391 if let Ok(glob) = globset::Glob::new(&candidate) {
392 builder.add(glob);
393 }
394 }
395 }
396 builder
397 .build()
398 .unwrap_or_else(|_| globset::GlobSet::empty())
399}
400
401fn is_project_config_name(name: &str) -> bool {
402 (name.starts_with("tsconfig") || name.starts_with("jsconfig"))
403 && std::path::Path::new(name)
404 .extension()
405 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
406}
407
408fn input_files_digest(config: &ResolvedConfig) -> String {
411 let root = config.root.as_path();
412 let globs = resolution_file_globs(config);
413 let mut files: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
414 if let Ok(content) = std::fs::read(root.join(".git/info/exclude")) {
415 files.insert(".git/info/exclude".to_owned(), normalized_text(&content));
416 }
417 let mut project_configs: Vec<PathBuf> = Vec::new();
418 for entry in fingerprint_walk_builder(root).build().flatten() {
419 if entry.file_type().is_none_or(|kind| kind.is_dir()) {
420 continue;
421 }
422 let Ok(relative) = entry.path().strip_prefix(root) else {
423 continue;
424 };
425 let name = entry.file_name().to_string_lossy();
426 let is_ignore_file = IGNORE_FILE_NAMES.contains(&name.as_ref());
427 if !is_ignore_file && !globs.is_match(relative) {
428 continue;
429 }
430 if config.ignore_patterns.is_match(relative) {
431 continue;
432 }
433 if let Ok(content) = std::fs::read(entry.path()) {
434 let key = StableFileKey::from_relative(relative).as_str().to_owned();
435 files.insert(key, normalized_text(&content));
436 if is_project_config_name(&name) {
437 project_configs.push(entry.path().to_path_buf());
438 }
439 }
440 }
441 for project_config in project_configs {
442 add_extends_chain(root, &project_config, &mut files);
443 }
444 let parts: Vec<&str> = files
445 .iter()
446 .flat_map(|(path, content)| [path.as_str(), content.as_str()])
447 .collect();
448 fallow_types::identity::fnv1a64_parts(&parts)
449}
450
451fn add_extends_chain(
455 root: &Path,
456 project_config: &Path,
457 files: &mut std::collections::BTreeMap<String, String>,
458) {
459 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
460 let mut frontier: Vec<(PathBuf, usize)> = vec![(project_config.to_path_buf(), 0)];
461 while let Some((current, depth)) = frontier.pop() {
462 if depth >= MAX_EXTENDS_DEPTH || !seen.insert(current.clone()) {
463 continue;
464 }
465 for target in read_extends(¤t).unwrap_or_default() {
466 let Some(next) = resolve_extends_target(root, ¤t, &target) else {
467 continue;
468 };
469 if let Ok(content) = std::fs::read(&next) {
470 files.insert(extends_key(root, &next), normalized_text(&content));
471 frontier.push((next, depth + 1));
472 }
473 }
474 }
475}
476
477fn read_extends(path: &Path) -> Option<Vec<String>> {
480 let content = std::fs::read_to_string(path).ok()?;
481 let value: serde_json::Value = fallow_config::jsonc::parse_to_value(&content).ok()?;
482 match value.get("extends")? {
483 serde_json::Value::String(target) => Some(vec![target.clone()]),
484 serde_json::Value::Array(items) => Some(
485 items
486 .iter()
487 .filter_map(|item| item.as_str().map(str::to_owned))
488 .collect(),
489 ),
490 _ => None,
491 }
492}
493
494fn resolve_extends_target(root: &Path, from: &Path, target: &str) -> Option<PathBuf> {
497 let base = if target.starts_with('.') || Path::new(target).is_absolute() {
498 from.parent()?.join(target)
499 } else {
500 root.join("node_modules").join(target)
501 };
502 let candidates = [
503 base.clone(),
504 base.with_extension("json"),
505 base.join("tsconfig.json"),
506 ];
507 candidates.into_iter().find(|candidate| candidate.is_file())
508}
509
510fn extends_key(root: &Path, path: &Path) -> String {
513 path.strip_prefix(root).map_or_else(
514 |_| {
515 format!(
516 "extends:{}",
517 path.file_name()
518 .map(|name| name.to_string_lossy().into_owned())
519 .unwrap_or_default()
520 )
521 },
522 |relative| StableFileKey::from_relative(relative).as_str().to_owned(),
523 )
524}
525
526fn finding_id_rule_is_off(id: &str, config: &ResolvedConfig) -> bool {
530 let Some(kind) = id
531 .split(':')
532 .nth(1)
533 .and_then(fallow_types::suppress::IssueKind::parse)
534 else {
535 return false;
536 };
537 let off = |rules: &RulesConfig| rules.severity_for_kind(kind) == Severity::Off;
538 off(&config.rules)
539 || config.overrides.iter().any(|entry| {
540 let mut rules = config.rules.clone();
541 rules.apply_partial(&entry.rules);
542 off(&rules)
543 })
544}
545
546pub fn filter_to_workspaces(results: &mut AnalysisResults, ws_roots: &[PathBuf]) {
551 let any_under = |path: &Path| ws_roots.iter().any(|root| path.starts_with(root));
552 let pkg_jsons = ws_roots
553 .iter()
554 .map(|root| root.join("package.json"))
555 .collect::<Vec<_>>();
556 let in_pkg_jsons = |path: &Path| pkg_jsons.iter().any(|pkg| path == pkg);
557
558 filter_workspace_source_findings(results, &any_under);
559 filter_workspace_dependency_findings(results, &any_under, &in_pkg_jsons);
560 filter_workspace_graph_findings(results, &any_under);
561 filter_workspace_policy_findings(results, &any_under);
562}
563
564#[derive(Debug, Clone, Copy)]
568pub struct DeadCodeScope<'a> {
569 pub workspace_roots: Option<&'a [PathBuf]>,
572 pub changes: Option<&'a ChangeScope>,
575 pub diff: Option<(&'a fallow_output::DiffIndex, &'a Path)>,
577 pub files: Option<&'a FxHashSet<PathBuf>>,
580}
581
582pub fn apply_scope(
592 results: &mut AnalysisResults,
593 scope: &DeadCodeScope<'_>,
594 config: &ResolvedConfig,
595) {
596 if let Some(roots) = scope.workspace_roots {
597 filter_to_workspaces(results, roots);
598 }
599 if let Some(changes) = scope.changes {
600 changes.retain_dead_code(results);
601 }
602 if let Some((diff, root)) = scope.diff {
603 crate::diff_scope::filter_dead_code_by_diff(results, diff, root);
604 }
605 if let Some(files) = scope.files {
606 filter_by_changed_files(results, files);
607 clear_dependency_findings(results);
608 }
609 filter_configured_ignored_findings(results, config);
610}
611
612fn clear_dependency_findings(results: &mut AnalysisResults) {
613 results.unused_dependencies.clear();
614 results.unused_dev_dependencies.clear();
615 results.unused_optional_dependencies.clear();
616 results.type_only_dependencies.clear();
617 results.test_only_dependencies.clear();
618 results.dev_dependencies_in_production.clear();
619}
620
621#[expect(
623 clippy::implicit_hasher,
624 reason = "fallow standardizes on FxHashSet across the workspace"
625)]
626pub fn filter_by_changed_files(results: &mut AnalysisResults, changed_files: &FxHashSet<PathBuf>) {
627 crate::changed_files::filter_results_by_changed_files(results, changed_files);
628}
629
630pub fn filter_configured_ignored_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
636 if config.ignore_findings.is_empty() {
637 return;
638 }
639
640 results.remove_ignored_dead_code_findings(|path| {
641 let key = if path.is_absolute() {
642 let Ok(relative) = path.strip_prefix(&config.root) else {
643 return false;
644 };
645 StableFileKey::from_relative(relative)
646 } else {
647 StableFileKey::from_relative(path)
648 };
649 config.ignore_findings.is_ignored(key.as_str())
650 });
651}
652
653fn filter_workspace_source_findings(
654 results: &mut AnalysisResults,
655 any_under: &dyn Fn(&Path) -> bool,
656) {
657 results
658 .unused_files
659 .retain(|finding| any_under(&finding.file.path));
660 results
661 .unused_exports
662 .retain(|finding| any_under(&finding.export.path));
663 results
664 .unused_types
665 .retain(|finding| any_under(&finding.export.path));
666 results
667 .private_type_leaks
668 .retain(|finding| any_under(&finding.leak.path));
669 results
670 .deprecated_exports_in_use
671 .retain(|finding| any_under(&finding.export.path));
672 results
673 .unused_enum_members
674 .retain(|finding| any_under(&finding.member.path));
675 results
676 .unused_class_members
677 .retain(|finding| any_under(&finding.member.path));
678 results
679 .unused_store_members
680 .retain(|finding| any_under(&finding.member.path));
681 results
682 .unprovided_injects
683 .retain(|finding| any_under(&finding.inject.path));
684 results
685 .unrendered_components
686 .retain(|finding| any_under(&finding.component.path));
687 results
688 .unused_component_props
689 .retain(|finding| any_under(&finding.prop.path));
690 results
691 .unused_component_emits
692 .retain(|finding| any_under(&finding.emit.path));
693 results
694 .unused_component_inputs
695 .retain(|finding| any_under(&finding.input.path));
696 results
697 .unused_component_outputs
698 .retain(|finding| any_under(&finding.output.path));
699 results
700 .unused_svelte_events
701 .retain(|finding| any_under(&finding.event.path));
702 results
703 .unused_server_actions
704 .retain(|finding| any_under(&finding.action.path));
705 results
706 .unused_load_data_keys
707 .retain(|finding| any_under(&finding.key.path));
708 results
709 .unresolved_imports
710 .retain(|finding| any_under(&finding.import.path));
711}
712
713fn filter_workspace_dependency_findings(
714 results: &mut AnalysisResults,
715 any_under: &dyn Fn(&Path) -> bool,
716 in_pkg_jsons: &dyn Fn(&Path) -> bool,
717) {
718 results
719 .unused_dependencies
720 .retain(|finding| in_pkg_jsons(&finding.dep.path));
721 results
722 .unused_dev_dependencies
723 .retain(|finding| in_pkg_jsons(&finding.dep.path));
724 results
725 .unused_optional_dependencies
726 .retain(|finding| in_pkg_jsons(&finding.dep.path));
727 results
728 .type_only_dependencies
729 .retain(|finding| in_pkg_jsons(&finding.dep.path));
730 results
731 .test_only_dependencies
732 .retain(|finding| in_pkg_jsons(&finding.dep.path));
733 results
734 .dev_dependencies_in_production
735 .retain(|finding| in_pkg_jsons(&finding.dep.path));
736
737 results.unlisted_dependencies.retain(|finding| {
738 finding
739 .dep
740 .imported_from
741 .iter()
742 .any(|source| any_under(&source.path))
743 });
744 results.unused_dependency_overrides.clear();
745 results.misconfigured_dependency_overrides.clear();
746}
747
748fn filter_workspace_graph_findings(
749 results: &mut AnalysisResults,
750 any_under: &dyn Fn(&Path) -> bool,
751) {
752 for duplicate in &mut results.duplicate_exports {
753 duplicate
754 .export
755 .locations
756 .retain(|location| any_under(&location.path));
757 }
758 results
759 .duplicate_exports
760 .retain(|duplicate| duplicate.export.locations.len() >= 2);
761
762 results
763 .circular_dependencies
764 .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
765
766 results
767 .re_export_cycles
768 .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
769
770 results
771 .package_cycles
772 .retain(|cycle| cycle.cycle.edges.iter().any(|edge| any_under(&edge.path)));
773}
774
775fn filter_workspace_policy_findings(
776 results: &mut AnalysisResults,
777 any_under: &dyn Fn(&Path) -> bool,
778) {
779 results
780 .boundary_violations
781 .retain(|finding| any_under(&finding.violation.from_path));
782 results
783 .boundary_coverage_violations
784 .retain(|finding| any_under(&finding.violation.path));
785 results
786 .boundary_call_violations
787 .retain(|finding| any_under(&finding.violation.path));
788 results
789 .policy_violations
790 .retain(|finding| any_under(&finding.violation.path));
791
792 results
793 .stale_suppressions
794 .retain(|finding| any_under(&finding.path));
795
796 results
797 .security_findings
798 .retain(|finding| any_under(&finding.path));
799 results
800 .security_unresolved_callee_diagnostics
801 .retain(|finding| any_under(&finding.path));
802
803 results.unused_catalog_entries.clear();
804 results.empty_catalog_groups.clear();
805 results
806 .unresolved_catalog_references
807 .retain(|finding| any_under(&finding.reference.path));
808
809 results
810 .invalid_client_exports
811 .retain(|finding| any_under(&finding.export.path));
812
813 results
814 .mixed_client_server_barrels
815 .retain(|finding| any_under(&finding.barrel.path));
816
817 results
818 .misplaced_directives
819 .retain(|finding| any_under(&finding.directive_site.path));
820
821 results
822 .route_collisions
823 .retain(|finding| any_under(&finding.collision.path));
824
825 results
826 .dynamic_segment_name_conflicts
827 .retain(|finding| any_under(&finding.conflict.path));
828}
829
830pub fn apply_rule_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
847 let rules = &config.rules;
848 let has_overrides = !config.overrides.is_empty();
849
850 if has_overrides {
851 apply_file_override_rules(results, config);
852 apply_boundary_override_rules(results, config);
853 } else {
854 apply_base_file_rules(results, rules);
855 }
856
857 apply_base_collection_rules(results, rules);
858 apply_effective_severities(results, config);
859}
860
861fn apply_base_collection_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
862 if rules.unused_dependencies == Severity::Off {
863 results.unused_dependencies.clear();
864 }
865 if rules.unused_dev_dependencies == Severity::Off {
866 results.unused_dev_dependencies.clear();
867 }
868 if rules.unused_optional_dependencies == Severity::Off {
869 results.unused_optional_dependencies.clear();
870 }
871 if rules.unlisted_dependencies == Severity::Off {
872 results.unlisted_dependencies.clear();
873 }
874 if rules.duplicate_exports == Severity::Off {
875 results.duplicate_exports.clear();
876 }
877 if rules.type_only_dependencies == Severity::Off {
878 results.type_only_dependencies.clear();
879 }
880 if rules.test_only_dependencies == Severity::Off {
881 results.test_only_dependencies.clear();
882 }
883 if rules.dev_dependencies_in_production == Severity::Off {
884 results.dev_dependencies_in_production.clear();
885 }
886 if rules.circular_dependencies == Severity::Off {
887 results.circular_dependencies.clear();
888 }
889 if rules.re_export_cycle == Severity::Off {
890 results.re_export_cycles.clear();
891 }
892 if rules.package_cycle == Severity::Off {
893 results.package_cycles.clear();
894 }
895 if rules.boundary_violation == Severity::Off {
896 results.boundary_violations.clear();
897 results.boundary_coverage_violations.clear();
898 results.boundary_call_violations.clear();
899 }
900 if rules.policy_violation == Severity::Off {
901 results.policy_violations.clear();
902 }
903 if rules.unused_catalog_entries == Severity::Off {
904 results.unused_catalog_entries.clear();
905 }
906 if rules.empty_catalog_groups == Severity::Off {
907 results.empty_catalog_groups.clear();
908 }
909 if rules.unresolved_catalog_references == Severity::Off {
910 results.unresolved_catalog_references.clear();
911 }
912 if rules.unused_dependency_overrides == Severity::Off {
913 results.unused_dependency_overrides.clear();
914 }
915 if rules.misconfigured_dependency_overrides == Severity::Off {
916 results.misconfigured_dependency_overrides.clear();
917 }
918}
919
920fn apply_file_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
921 apply_dead_code_override_rules(results, config);
922 apply_catalog_override_rules(results, config);
923 apply_framework_override_rules(results, config);
924 apply_circular_override_rules(results, config);
925}
926
927fn apply_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
928 apply_core_dead_code_override_rules(results, config);
929 apply_component_dead_code_override_rules(results, config);
930}
931
932fn apply_core_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
934 results
935 .unused_files
936 .retain(|f| config.resolve_rules_for_path(&f.file.path).unused_files != Severity::Off);
937 results
938 .unused_exports
939 .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_exports != Severity::Off);
940 results
941 .unused_types
942 .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_types != Severity::Off);
943 results.private_type_leaks.retain(|e| {
944 config
945 .resolve_rules_for_path(&e.leak.path)
946 .private_type_leaks
947 != Severity::Off
948 });
949 results.deprecated_exports_in_use.retain(|e| {
950 config
951 .resolve_rules_for_path(&e.export.path)
952 .deprecated_exports_in_use
953 != Severity::Off
954 });
955 results.unused_enum_members.retain(|m| {
956 config
957 .resolve_rules_for_path(&m.member.path)
958 .unused_enum_members
959 != Severity::Off
960 });
961 results.unused_class_members.retain(|m| {
962 config
963 .resolve_rules_for_path(&m.member.path)
964 .unused_class_members
965 != Severity::Off
966 });
967 results.unused_store_members.retain(|m| {
968 config
969 .resolve_rules_for_path(&m.member.path)
970 .unused_store_members
971 != Severity::Off
972 });
973 results.unprovided_injects.retain(|f| {
974 config
975 .resolve_rules_for_path(&f.inject.path)
976 .unprovided_injects
977 != Severity::Off
978 });
979 results.unresolved_imports.retain(|i| {
980 config
981 .resolve_rules_for_path(&i.import.path)
982 .unresolved_imports
983 != Severity::Off
984 });
985}
986
987fn apply_component_dead_code_override_rules(
989 results: &mut AnalysisResults,
990 config: &ResolvedConfig,
991) {
992 results.unrendered_components.retain(|c| {
993 config
994 .resolve_rules_for_path(&c.component.path)
995 .unrendered_components
996 != Severity::Off
997 });
998 results.unused_component_props.retain(|p| {
999 config
1000 .resolve_rules_for_path(&p.prop.path)
1001 .unused_component_props
1002 != Severity::Off
1003 });
1004 results.unused_component_emits.retain(|e| {
1005 config
1006 .resolve_rules_for_path(&e.emit.path)
1007 .unused_component_emits
1008 != Severity::Off
1009 });
1010 results.unused_component_inputs.retain(|i| {
1011 config
1012 .resolve_rules_for_path(&i.input.path)
1013 .unused_component_inputs
1014 != Severity::Off
1015 });
1016 results.unused_component_outputs.retain(|o| {
1017 config
1018 .resolve_rules_for_path(&o.output.path)
1019 .unused_component_outputs
1020 != Severity::Off
1021 });
1022 results.unused_svelte_events.retain(|e| {
1023 config
1024 .resolve_rules_for_path(&e.event.path)
1025 .unused_svelte_events
1026 != Severity::Off
1027 });
1028 results.unused_server_actions.retain(|a| {
1029 config
1030 .resolve_rules_for_path(&a.action.path)
1031 .unused_server_actions
1032 != Severity::Off
1033 });
1034 results.unused_load_data_keys.retain(|k| {
1035 config
1036 .resolve_rules_for_path(&k.key.path)
1037 .unused_load_data_keys
1038 != Severity::Off
1039 });
1040}
1041
1042fn apply_catalog_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1043 results.stale_suppressions.retain(|s| {
1044 let rules = config.resolve_rules_for_path(&s.path);
1045 if s.missing_reason {
1046 rules.require_suppression_reason != Severity::Off
1047 } else {
1048 rules.stale_suppressions != Severity::Off
1049 }
1050 });
1051 results.unresolved_catalog_references.retain(|r| {
1052 config
1053 .resolve_rules_for_path(&r.reference.path)
1054 .unresolved_catalog_references
1055 != Severity::Off
1056 });
1057 results.empty_catalog_groups.retain(|g| {
1058 config
1059 .resolve_rules_for_path(&g.group.path)
1060 .empty_catalog_groups
1061 != Severity::Off
1062 });
1063 results.unused_dependency_overrides.retain(|o| {
1064 config
1065 .resolve_rules_for_path(&o.entry.path)
1066 .unused_dependency_overrides
1067 != Severity::Off
1068 });
1069 results.misconfigured_dependency_overrides.retain(|o| {
1070 config
1071 .resolve_rules_for_path(&o.entry.path)
1072 .misconfigured_dependency_overrides
1073 != Severity::Off
1074 });
1075}
1076
1077fn apply_framework_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1078 results.invalid_client_exports.retain(|e| {
1079 config
1080 .resolve_rules_for_path(&e.export.path)
1081 .invalid_client_export
1082 != Severity::Off
1083 });
1084 results.mixed_client_server_barrels.retain(|b| {
1085 config
1086 .resolve_rules_for_path(&b.barrel.path)
1087 .mixed_client_server_barrel
1088 != Severity::Off
1089 });
1090 results.misplaced_directives.retain(|d| {
1091 config
1092 .resolve_rules_for_path(&d.directive_site.path)
1093 .misplaced_directive
1094 != Severity::Off
1095 });
1096 results.route_collisions.retain(|c| {
1097 config
1098 .resolve_rules_for_path(&c.collision.path)
1099 .route_collision
1100 != Severity::Off
1101 });
1102 results.dynamic_segment_name_conflicts.retain(|c| {
1103 config
1104 .resolve_rules_for_path(&c.conflict.path)
1105 .dynamic_segment_name_conflict
1106 != Severity::Off
1107 });
1108}
1109
1110fn apply_circular_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1111 results.circular_dependencies.retain(|c| {
1112 c.cycle
1113 .files
1114 .iter()
1115 .any(|path| config.resolve_rules_for_path(path).circular_dependencies != Severity::Off)
1116 });
1117}
1118
1119fn apply_base_file_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
1120 clear_base_core_dead_code(results, rules);
1121 clear_base_component_dead_code(results, rules);
1122 clear_base_suppression_and_framework(results, rules);
1123}
1124
1125fn clear_base_core_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1127 if rules.unused_files == Severity::Off {
1128 results.unused_files.clear();
1129 }
1130 if rules.unused_exports == Severity::Off {
1131 results.unused_exports.clear();
1132 }
1133 if rules.unused_types == Severity::Off {
1134 results.unused_types.clear();
1135 }
1136 if rules.private_type_leaks == Severity::Off {
1137 results.private_type_leaks.clear();
1138 }
1139 if rules.deprecated_exports_in_use == Severity::Off {
1140 results.deprecated_exports_in_use.clear();
1141 }
1142 if rules.unused_enum_members == Severity::Off {
1143 results.unused_enum_members.clear();
1144 }
1145 if rules.unused_class_members == Severity::Off {
1146 results.unused_class_members.clear();
1147 }
1148 if rules.unused_store_members == Severity::Off {
1149 results.unused_store_members.clear();
1150 }
1151 if rules.unprovided_injects == Severity::Off {
1152 results.unprovided_injects.clear();
1153 }
1154 if rules.unresolved_imports == Severity::Off {
1155 results.unresolved_imports.clear();
1156 }
1157}
1158
1159fn clear_base_component_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1161 if rules.unrendered_components == Severity::Off {
1162 results.unrendered_components.clear();
1163 }
1164 if rules.unused_component_props == Severity::Off {
1165 results.unused_component_props.clear();
1166 }
1167 if rules.unused_component_emits == Severity::Off {
1168 results.unused_component_emits.clear();
1169 }
1170 if rules.unused_component_inputs == Severity::Off {
1171 results.unused_component_inputs.clear();
1172 }
1173 if rules.unused_component_outputs == Severity::Off {
1174 results.unused_component_outputs.clear();
1175 }
1176 if rules.unused_svelte_events == Severity::Off {
1177 results.unused_svelte_events.clear();
1178 }
1179 if rules.unused_server_actions == Severity::Off {
1180 results.unused_server_actions.clear();
1181 }
1182 if rules.unused_load_data_keys == Severity::Off {
1183 results.unused_load_data_keys.clear();
1184 }
1185}
1186
1187fn clear_base_suppression_and_framework(results: &mut AnalysisResults, rules: &RulesConfig) {
1190 results.stale_suppressions.retain(|s| {
1191 if s.missing_reason {
1192 rules.require_suppression_reason != Severity::Off
1193 } else {
1194 rules.stale_suppressions != Severity::Off
1195 }
1196 });
1197 if rules.invalid_client_export == Severity::Off {
1198 results.invalid_client_exports.clear();
1199 }
1200 if rules.mixed_client_server_barrel == Severity::Off {
1201 results.mixed_client_server_barrels.clear();
1202 }
1203 if rules.misplaced_directive == Severity::Off {
1204 results.misplaced_directives.clear();
1205 }
1206 if rules.route_collision == Severity::Off {
1207 results.route_collisions.clear();
1208 }
1209 if rules.dynamic_segment_name_conflict == Severity::Off {
1210 results.dynamic_segment_name_conflicts.clear();
1211 }
1212}
1213
1214fn apply_boundary_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1215 results.boundary_violations.retain(|v| {
1216 config
1217 .resolve_rules_for_path(&v.violation.from_path)
1218 .boundary_violation
1219 != Severity::Off
1220 });
1221 results.boundary_coverage_violations.retain(|v| {
1222 config
1223 .resolve_rules_for_path(&v.violation.path)
1224 .boundary_violation
1225 != Severity::Off
1226 });
1227 results.boundary_call_violations.retain(|v| {
1228 config
1229 .resolve_rules_for_path(&v.violation.path)
1230 .boundary_violation
1231 != Severity::Off
1232 });
1233 results.policy_violations.retain(|v| {
1234 config
1235 .resolve_rules_for_path(&v.violation.path)
1236 .policy_violation
1237 != Severity::Off
1238 });
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use std::path::PathBuf;
1244
1245 use super::*;
1246 use fallow_types::output_dead_code::{
1247 BoundaryViolationFinding, CircularDependencyFinding, PrivateTypeLeakFinding,
1248 UnusedExportFinding, UnusedFileFinding,
1249 };
1250 use fallow_types::results::{
1251 BoundaryViolation, CircularDependency, PrivateTypeLeak, UnusedExport, UnusedFile,
1252 };
1253
1254 #[test]
1255 fn finding_id_filter_keeps_request_order_and_drops_duplicates() {
1256 let a = "dc1:unused-export:0123456789abcdef";
1257 let b = "dc1:unused-file:0123456789abcdef~1";
1258
1259 let filter = FindingIdFilter::parse(&[b, a, b])
1260 .expect("valid ids")
1261 .expect("a filter");
1262
1263 assert_eq!(filter.requested, vec![b.to_owned(), a.to_owned()]);
1264 assert_eq!(FindingIdFilter::parse::<&str>(&[]), Ok(None));
1265 }
1266
1267 #[test]
1268 fn normalized_text_ignores_line_endings_and_trailing_newlines() {
1269 assert_eq!(normalized_text(b"dist\r\nbuild\r\n"), "dist\nbuild");
1270 assert_eq!(normalized_text(b"dist\nbuild\n\n"), "dist\nbuild");
1271 assert_eq!(normalized_text(b"dist\nbuild"), "dist\nbuild");
1272 assert_ne!(
1273 normalized_text(b"dist\nbuild"),
1274 normalized_text(b"dist\nbuilt")
1275 );
1276 }
1277
1278 #[test]
1279 fn a_crlf_checkout_has_the_same_fingerprint_as_an_lf_checkout() {
1280 let write_project = |line_end: &str| {
1281 let dir = tempfile::tempdir().expect("project");
1282 let root = dir.path();
1283 std::fs::create_dir_all(root.join("src")).expect("src");
1284 std::fs::write(
1285 root.join(".gitignore"),
1286 format!("dist{line_end}coverage{line_end}"),
1287 )
1288 .expect("gitignore");
1289 std::fs::write(
1290 root.join("package.json"),
1291 format!("{{{line_end} \"name\": \"crlf\"{line_end}}}{line_end}"),
1292 )
1293 .expect("package.json");
1294 dir
1295 };
1296 let config_at = |root: &std::path::Path| {
1297 fallow_config::FallowConfig::default().resolve(
1298 root.to_path_buf(),
1299 fallow_config::OutputFormat::Json,
1300 1,
1301 true,
1302 true,
1303 None,
1304 )
1305 };
1306 let lf = write_project("\n");
1307 let crlf = write_project("\r\n");
1308
1309 assert_eq!(
1310 analysis_fingerprint_for_version(&config_at(lf.path()), "1.0.0"),
1311 analysis_fingerprint_for_version(&config_at(crlf.path()), "1.0.0")
1312 );
1313 }
1314
1315 #[test]
1316 fn a_tsconfig_extends_target_outside_the_walk_is_an_input() {
1317 let dir = tempfile::tempdir().expect("project");
1318 let root = dir.path();
1319 std::fs::create_dir_all(root.join(".config")).expect("hidden dir");
1320 std::fs::write(
1321 root.join("tsconfig.json"),
1322 r#"{ "extends": "./.config/tsconfig.base.json" }"#,
1323 )
1324 .expect("tsconfig");
1325 let base = root.join(".config/tsconfig.base.json");
1326 std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "." } }"#).expect("base");
1327 let config = fallow_config::FallowConfig::default().resolve(
1328 root.to_path_buf(),
1329 fallow_config::OutputFormat::Json,
1330 1,
1331 true,
1332 true,
1333 None,
1334 );
1335 let before = analysis_fingerprint_for_version(&config, "1.0.0");
1336
1337 std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "src" } }"#).expect("edit");
1338
1339 assert_ne!(analysis_fingerprint_for_version(&config, "1.0.0"), before);
1340 }
1341
1342 #[test]
1343 fn analysis_fingerprint_depends_on_the_version_and_not_on_the_root() {
1344 let config_at = |root: &std::path::Path| {
1345 fallow_config::FallowConfig::default().resolve(
1346 root.to_path_buf(),
1347 fallow_config::OutputFormat::Json,
1348 1,
1349 true,
1350 true,
1351 None,
1352 )
1353 };
1354 let a = tempfile::tempdir().expect("project a");
1355 let b = tempfile::tempdir().expect("project b");
1356 let config_a = config_at(a.path());
1357 let config_b = config_at(b.path());
1358
1359 let base = analysis_fingerprint_for_version(&config_a, "1.0.0");
1360 assert!(base.starts_with("af1:"), "{base}");
1361 assert_eq!(base, analysis_fingerprint_for_version(&config_a, "1.0.0"));
1362 assert_eq!(base, analysis_fingerprint_for_version(&config_b, "1.0.0"));
1363 assert_ne!(base, analysis_fingerprint_for_version(&config_a, "1.0.1"));
1364 }
1365
1366 #[test]
1367 fn finding_id_filter_refuses_a_malformed_id() {
1368 let error = FindingIdFilter::parse(&["dc1:unused-export:helper"])
1369 .expect_err("malformed id refused");
1370
1371 assert!(error.contains("dc1:unused-export:helper"), "{error}");
1372 }
1373
1374 #[test]
1375 fn finding_id_rule_is_off_reads_rules_and_overrides() {
1376 let id = "dc1:unused-export:0123456789abcdef";
1377 let mut config = fallow_config::FallowConfig::default().resolve(
1378 PathBuf::from("/repo"),
1379 fallow_config::OutputFormat::Json,
1380 1,
1381 true,
1382 true,
1383 None,
1384 );
1385 assert!(!finding_id_rule_is_off(id, &config));
1386
1387 config.rules.unused_exports = Severity::Off;
1388 assert!(finding_id_rule_is_off(id, &config));
1389 assert!(!finding_id_rule_is_off(
1390 "dc1:unused-file:0123456789abcdef",
1391 &config
1392 ));
1393
1394 let with_override = serde_json::from_str::<fallow_config::FallowConfig>(
1395 r#"{"overrides":[{"files":["src/a.ts"],"rules":{"unused-exports":"off"}}]}"#,
1396 )
1397 .expect("config parses")
1398 .resolve(
1399 PathBuf::from("/repo"),
1400 fallow_config::OutputFormat::Json,
1401 1,
1402 true,
1403 true,
1404 None,
1405 );
1406 assert!(finding_id_rule_is_off(id, &with_override));
1407 }
1408
1409 #[test]
1410 fn workspace_filter_keeps_findings_under_workspace_root() {
1411 let root = PathBuf::from("/repo/packages/app");
1412 let mut results = AnalysisResults::default();
1413 results
1414 .unused_files
1415 .push(UnusedFileFinding::with_actions(UnusedFile {
1416 path: root.join("src/unused.ts"),
1417 }));
1418 results
1419 .unused_files
1420 .push(UnusedFileFinding::with_actions(UnusedFile {
1421 path: PathBuf::from("/repo/packages/docs/src/unused.ts"),
1422 }));
1423
1424 filter_to_workspaces(&mut results, std::slice::from_ref(&root));
1425
1426 assert_eq!(results.unused_files.len(), 1);
1427 assert_eq!(
1428 results.unused_files[0].file.path,
1429 root.join("src/unused.ts")
1430 );
1431 }
1432
1433 #[test]
1434 fn configured_filter_removes_findings_added_after_engine_analysis() {
1435 let project = tempfile::tempdir().expect("project");
1436 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1437 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1438 )
1439 .expect("config parses")
1440 .resolve(
1441 project.path().to_path_buf(),
1442 fallow_config::OutputFormat::Human,
1443 1,
1444 true,
1445 true,
1446 None,
1447 );
1448 let mut results = AnalysisResults::default();
1449 results
1450 .private_type_leaks
1451 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1452 path: project.path().join("src/hidden.ts"),
1453 export_name: "publicApi".to_string(),
1454 type_name: "PrivateShape".to_string(),
1455 line: 1,
1456 col: 0,
1457 span_start: 0,
1458 semantic: None,
1459 }));
1460 results
1461 .boundary_violations
1462 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1463 from_path: project.path().join("src/hidden.ts"),
1464 to_path: project.path().join("src/data.ts"),
1465 from_zone: "ui".to_string(),
1466 to_zone: "data".to_string(),
1467 import_specifier: "./data".to_string(),
1468 line: 1,
1469 col: 0,
1470 via_path: None,
1471 }));
1472
1473 filter_configured_ignored_findings(&mut results, &config);
1474
1475 assert!(results.private_type_leaks.is_empty());
1476 assert_eq!(results.boundary_violations.len(), 1);
1477 }
1478
1479 fn unmatched_patterns(diagnostics: &[WorkspaceDiagnostic]) -> Vec<(&'static str, String)> {
1480 diagnostics
1481 .iter()
1482 .filter_map(|diagnostic| match &diagnostic.kind {
1483 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => {
1484 Some(("ignoreFindings", pattern.clone()))
1485 }
1486 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => {
1487 Some(("ignoreDependencies", pattern.clone()))
1488 }
1489 _ => None,
1490 })
1491 .collect()
1492 }
1493
1494 fn write_manifest(root: &Path, dependencies: &str) {
1495 std::fs::write(
1496 root.join("package.json"),
1497 format!(
1498 r#"{{"name":"app","private":true,"main":"src/index.ts","dependencies":{dependencies}}}"#
1499 ),
1500 )
1501 .expect("write package.json");
1502 }
1503
1504 #[test]
1505 fn config_pattern_diagnostics_describe_the_latest_pass_only() {
1506 let project = tempfile::tempdir().expect("project");
1507 let root = project.path();
1508 std::fs::create_dir_all(root.join("src")).expect("create src");
1509 std::fs::write(root.join("src/index.ts"), "export const main = 1;\n").expect("write entry");
1510 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n")
1511 .expect("write orphan");
1512 write_manifest(root, r#"{"@acme/lib":"1.0.0"}"#);
1513 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1514 r#"{"ignoreDependencies":["@acme/*","@typo/*"],"ignoreFindings":["src/legcy/**"]}"#,
1515 )
1516 .expect("config parses")
1517 .resolve(
1518 root.to_path_buf(),
1519 fallow_config::OutputFormat::Human,
1520 1,
1521 true,
1522 true,
1523 None,
1524 );
1525
1526 crate::session::AnalysisSession::from_resolved_config(config.clone())
1527 .expect("session")
1528 .analyze_dead_code()
1529 .expect("first pass");
1530 assert_eq!(
1531 unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1532 vec![
1533 ("ignoreFindings", "src/legcy/**".to_owned()),
1534 ("ignoreDependencies", "@typo/*".to_owned()),
1535 ]
1536 );
1537 assert_eq!(
1538 unmatched_patterns(&config_pattern_diagnostics(&config, false)),
1539 vec![("ignoreFindings", "src/legcy/**".to_owned())],
1540 "a run that reports no dependency findings omits the dependency globs"
1541 );
1542
1543 write_manifest(root, r#"{"react":"1.0.0"}"#);
1546 crate::session::AnalysisSession::from_resolved_config(config.clone())
1547 .expect("session")
1548 .analyze_dead_code()
1549 .expect("second pass");
1550 assert_eq!(
1551 unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1552 vec![
1553 ("ignoreFindings", "src/legcy/**".to_owned()),
1554 ("ignoreDependencies", "@acme/*".to_owned()),
1555 ("ignoreDependencies", "@typo/*".to_owned()),
1556 ]
1557 );
1558 }
1559
1560 fn config_with_override(
1561 pattern: &str,
1562 configure: impl FnOnce(&mut fallow_config::PartialRulesConfig),
1563 ) -> ResolvedConfig {
1564 let mut partial = fallow_config::PartialRulesConfig::default();
1565 configure(&mut partial);
1566 fallow_config::FallowConfig {
1567 rules: RulesConfig {
1568 private_type_leaks: Severity::Warn,
1569 ..RulesConfig::default()
1570 },
1571 overrides: vec![fallow_config::ConfigOverride {
1572 files: vec![pattern.to_string()],
1573 rules: partial,
1574 }],
1575 ..fallow_config::FallowConfig::default()
1576 }
1577 .resolve(
1578 PathBuf::from("/project"),
1579 fallow_config::OutputFormat::Human,
1580 1,
1581 true,
1582 true,
1583 None,
1584 )
1585 }
1586
1587 fn unused_export(path: &str) -> UnusedExportFinding {
1588 UnusedExportFinding::with_actions(UnusedExport {
1589 path: PathBuf::from(path),
1590 export_name: "Unused".to_string(),
1591 is_type_only: false,
1592 line: 1,
1593 col: 0,
1594 span_start: 0,
1595 is_re_export: false,
1596 deprecated: false,
1597 deprecated_reason: None,
1598 })
1599 }
1600
1601 fn private_type_leak(path: &str) -> PrivateTypeLeakFinding {
1602 PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1603 path: PathBuf::from(path),
1604 export_name: "Unused".to_string(),
1605 type_name: "Props".to_string(),
1606 line: 1,
1607 col: 0,
1608 span_start: 0,
1609 semantic: None,
1610 })
1611 }
1612
1613 fn overridden_fixture() -> AnalysisResults {
1614 let mut results = AnalysisResults::default();
1615 results
1616 .unused_exports
1617 .push(unused_export("/project/src/ui/kit.ts"));
1618 results
1619 .unused_exports
1620 .push(unused_export("/project/src/lib/util.ts"));
1621 results
1622 .private_type_leaks
1623 .push(private_type_leak("/project/src/ui/kit.ts"));
1624 results
1625 .private_type_leaks
1626 .push(private_type_leak("/project/src/lib/util.ts"));
1627 results
1628 }
1629
1630 #[test]
1631 fn rule_severities_drop_findings_only_on_overridden_paths() {
1632 let config = config_with_override("src/ui/**", |rules| {
1633 rules.unused_exports = Some(Severity::Off);
1634 rules.private_type_leaks = Some(Severity::Off);
1635 });
1636 let mut results = overridden_fixture();
1637
1638 apply_rule_severities(&mut results, &config);
1639
1640 assert_eq!(
1641 results
1642 .unused_exports
1643 .iter()
1644 .map(|finding| finding.export.path.clone())
1645 .collect::<Vec<_>>(),
1646 vec![PathBuf::from("/project/src/lib/util.ts")]
1647 );
1648 assert_eq!(
1649 results
1650 .private_type_leaks
1651 .iter()
1652 .map(|finding| finding.leak.path.clone())
1653 .collect::<Vec<_>>(),
1654 vec![PathBuf::from("/project/src/lib/util.ts")]
1655 );
1656 }
1657
1658 #[test]
1659 fn rule_severities_are_idempotent() {
1660 let config = config_with_override("src/ui/**", |rules| {
1664 rules.unused_exports = Some(Severity::Off);
1665 rules.private_type_leaks = Some(Severity::Off);
1666 });
1667
1668 let mut once = overridden_fixture();
1669 apply_rule_severities(&mut once, &config);
1670 let mut twice = overridden_fixture();
1671 apply_rule_severities(&mut twice, &config);
1672 apply_rule_severities(&mut twice, &config);
1673
1674 assert_eq!(
1675 once.unused_exports
1676 .iter()
1677 .map(|finding| finding.export.path.clone())
1678 .collect::<Vec<_>>(),
1679 twice
1680 .unused_exports
1681 .iter()
1682 .map(|finding| finding.export.path.clone())
1683 .collect::<Vec<_>>()
1684 );
1685 assert_eq!(
1686 once.private_type_leaks
1687 .iter()
1688 .map(|finding| finding.leak.path.clone())
1689 .collect::<Vec<_>>(),
1690 twice
1691 .private_type_leaks
1692 .iter()
1693 .map(|finding| finding.leak.path.clone())
1694 .collect::<Vec<_>>()
1695 );
1696 }
1697
1698 #[test]
1699 fn rule_severities_keep_a_cycle_when_any_member_file_stays_enabled() {
1700 let config = config_with_override("src/ui/**", |rules| {
1701 rules.circular_dependencies = Some(Severity::Off);
1702 });
1703 let mut results = AnalysisResults::default();
1704 results
1705 .circular_dependencies
1706 .push(CircularDependencyFinding::with_actions(
1707 CircularDependency {
1708 files: vec![
1709 PathBuf::from("/project/src/ui/a.ts"),
1710 PathBuf::from("/project/src/lib/b.ts"),
1711 ],
1712 length: 2,
1713 line: 1,
1714 col: 0,
1715 edges: Vec::new(),
1716 is_cross_package: false,
1717 },
1718 ));
1719 results
1720 .circular_dependencies
1721 .push(CircularDependencyFinding::with_actions(
1722 CircularDependency {
1723 files: vec![
1724 PathBuf::from("/project/src/ui/c.ts"),
1725 PathBuf::from("/project/src/ui/d.ts"),
1726 ],
1727 length: 2,
1728 line: 1,
1729 col: 0,
1730 edges: Vec::new(),
1731 is_cross_package: false,
1732 },
1733 ));
1734
1735 apply_rule_severities(&mut results, &config);
1736
1737 assert_eq!(results.circular_dependencies.len(), 1);
1738 assert_eq!(
1739 results.circular_dependencies[0].cycle.files[0],
1740 PathBuf::from("/project/src/ui/a.ts")
1741 );
1742 }
1743
1744 #[test]
1745 fn rule_severities_clear_base_rules_without_overrides() {
1746 let config = fallow_config::FallowConfig {
1747 rules: RulesConfig {
1748 unused_exports: Severity::Off,
1749 private_type_leaks: Severity::Warn,
1750 ..RulesConfig::default()
1751 },
1752 ..fallow_config::FallowConfig::default()
1753 }
1754 .resolve(
1755 PathBuf::from("/project"),
1756 fallow_config::OutputFormat::Human,
1757 1,
1758 true,
1759 true,
1760 None,
1761 );
1762 let mut results = overridden_fixture();
1763
1764 apply_rule_severities(&mut results, &config);
1765
1766 assert!(results.unused_exports.is_empty());
1767 assert_eq!(results.private_type_leaks.len(), 2);
1768 }
1769}