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