1use std::collections::{BTreeMap, hash_map::DefaultHasher};
2use std::ffi::{OsStr, OsString};
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use globset::{Glob, GlobMatcher, GlobSetBuilder};
8use rustc_hash::FxHashSet;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use super::boundaries::ResolvedBoundaryConfig;
13use super::duplicates_config::DuplicatesConfig;
14use super::flags::FlagsConfig;
15use super::format::OutputFormat;
16use super::glob_validation::compile_path_glob;
17use super::health::HealthConfig;
18use super::mutation::MutationConfig;
19use super::resolve::ResolveConfig;
20use super::rules::{PartialRulesConfig, RulesConfig, Severity};
21use super::similar_code::SimilarCodeConfig;
22use super::used_class_members::UsedClassMemberRule;
23use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
24
25use super::{
26 BoundaryConfig, FallowConfig, FindingIgnoreMatcher, IgnoreDependencyMatcher,
27 IgnoreExportsUsedInFileConfig, IgnorePatternSet, ProductionConfig, SecurityConfig,
28 TypeAwareConfig,
29};
30
31static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
33
34fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
36 let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
37 sorted.sort_unstable();
38 let mut hasher = DefaultHasher::new();
39 rule_name.hash(&mut hasher);
40 for s in &sorted {
41 s.hash(&mut hasher);
42 }
43 hasher.finish()
44}
45
46fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
48 let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
49 let key = inter_file_warn_key(rule_name, files);
50 seen.lock().map_or(true, |mut set| set.insert(key))
51}
52
53#[cfg(test)]
54fn reset_inter_file_warn_dedup_for_test() {
55 if let Some(seen) = INTER_FILE_WARN_SEEN.get()
56 && let Ok(mut set) = seen.lock()
57 {
58 set.clear();
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
64#[serde(deny_unknown_fields)]
65pub struct IgnoreExportRule {
66 pub file: String,
69 pub exports: Vec<String>,
71}
72
73#[derive(Debug, Clone)]
75pub struct CompiledIgnoreExportRule {
76 pub matcher: globset::GlobMatcher,
78 pub exports: Vec<String>,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
84#[serde(deny_unknown_fields)]
85pub struct IgnoreCatalogReferenceRule {
86 pub package: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub catalog: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub consumer: Option<String>,
94}
95
96#[derive(Debug, Clone)]
98pub struct CompiledIgnoreCatalogReferenceRule {
99 pub package: String,
101 pub catalog: Option<String>,
103 pub consumer_matcher: Option<globset::GlobMatcher>,
106}
107
108impl CompiledIgnoreCatalogReferenceRule {
109 #[must_use]
111 pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
112 if self.package != package {
113 return false;
114 }
115 if let Some(catalog_filter) = &self.catalog
116 && catalog_filter != catalog
117 {
118 return false;
119 }
120 if let Some(matcher) = &self.consumer_matcher
121 && !matcher.is_match(consumer_path)
122 {
123 return false;
124 }
125 true
126 }
127}
128
129#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
131#[serde(deny_unknown_fields)]
132pub struct IgnoreDependencyOverrideRule {
133 pub package: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub source: Option<String>,
138}
139
140#[derive(Debug, Clone)]
142pub struct CompiledIgnoreDependencyOverrideRule {
143 pub package: String,
145 pub source: Option<String>,
148}
149
150impl CompiledIgnoreDependencyOverrideRule {
151 #[must_use]
153 pub fn matches(&self, package: &str, source_label: &str) -> bool {
154 if self.package != package {
155 return false;
156 }
157 if let Some(source_filter) = &self.source
158 && source_filter != source_label
159 {
160 return false;
161 }
162 true
163 }
164}
165
166#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct ConfigOverride {
170 pub files: Vec<String>,
172 #[serde(default)]
174 pub rules: PartialRulesConfig,
175}
176
177#[derive(Debug, Clone)]
179pub struct ResolvedOverride {
180 pub matchers: Vec<globset::GlobMatcher>,
183 pub rules: PartialRulesConfig,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub enum AnalysisSnapshot {
195 #[default]
197 Current,
198 Base,
200}
201
202impl AnalysisSnapshot {
203 #[must_use]
205 pub const fn is_base(self) -> bool {
206 matches!(self, Self::Base)
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct ResolvedConfig {
213 pub root: PathBuf,
215 pub entry_patterns: Vec<String>,
218 pub ignore_patterns: IgnorePatternSet,
223 pub user_ignore_pattern_count: usize,
233 pub ignore_findings: FindingIgnoreMatcher,
236 pub output: OutputFormat,
238 pub cache_dir: PathBuf,
241 pub threads: usize,
243 pub no_cache: bool,
245 pub cache_max_size_mb: Option<u32>,
248 pub cache_config_hash: u64,
252 pub ignore_dependencies: IgnoreDependencyMatcher,
255 pub ignore_command_entries: Vec<String>,
258 pub ignore_unresolved_imports: Vec<GlobMatcher>,
261 pub ignore_export_rules: Vec<IgnoreExportRule>,
265 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
267 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
269 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
271 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
273 pub used_class_members: Vec<UsedClassMemberRule>,
276 pub ignore_decorators: Vec<String>,
279 pub unused_component_props_ignore: Option<regex::Regex>,
284 pub circular_dependencies: super::CircularDependenciesConfig,
286 pub duplicates: DuplicatesConfig,
288 pub similar_code: SimilarCodeConfig,
290 pub mutation: MutationConfig,
292 pub health: HealthConfig,
294 pub type_aware: TypeAwareConfig,
296 pub rules: RulesConfig,
300 pub boundaries: ResolvedBoundaryConfig,
303 pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
308 pub rule_pack_sources: Vec<PathBuf>,
311 pub production: bool,
316 pub quiet: bool,
319 pub external_plugins: Vec<ExternalPluginDef>,
323 pub dynamically_loaded: Vec<String>,
326 pub overrides: Vec<ResolvedOverride>,
328 pub workspace_changed_since: BTreeMap<String, String>,
331 pub workspace_patterns: Vec<String>,
334 pub regression: Option<super::RegressionConfig>,
336 pub audit: super::AuditConfig,
338 pub codeowners: Option<String>,
341 pub public_packages: Vec<String>,
345 pub flags: FlagsConfig,
347 pub security: SecurityConfig,
350 pub fix: super::FixConfig,
352 pub resolve: ResolveConfig,
355 pub include_entry_exports: bool,
358 pub show_cascade: bool,
361 pub auto_imports: bool,
365 pub fail_on_parse_error: bool,
369 pub max_file_size_bytes: Option<u64>,
378 pub analysis_snapshot: AnalysisSnapshot,
383 pub detection_config_digest: String,
391 pub legacy_glob_groups: Vec<super::LegacyGlobGroup>,
395}
396
397const NON_DETECTION_CONFIG_KEYS: &[&str] = &[
401 "minimumVersion",
402 "duplicates",
403 "similarCode",
404 "mutation",
405 "health",
406 "security",
407 "fix",
408 "codeowners",
409 "regression",
410 "audit",
411 "failOnParseError",
412 "cache",
413];
414
415fn canonical_json(value: serde_json::Value) -> serde_json::Value {
418 match value {
419 serde_json::Value::Object(map) => {
420 let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
421 entries.sort_by(|a, b| a.0.cmp(&b.0));
422 serde_json::Value::Object(
423 entries
424 .into_iter()
425 .map(|(key, value)| (key, canonical_json(value)))
426 .collect(),
427 )
428 }
429 serde_json::Value::Array(items) => {
430 serde_json::Value::Array(items.into_iter().map(canonical_json).collect())
431 }
432 other => other,
433 }
434}
435
436fn canonical_json_string<T: Serialize>(value: &T) -> String {
437 serde_json::to_value(value)
438 .map(canonical_json)
439 .map(|value| value.to_string())
440 .unwrap_or_default()
441}
442
443fn detection_config_json(config: &FallowConfig) -> String {
445 let mut value = serde_json::to_value(config).unwrap_or_default();
446 if let serde_json::Value::Object(map) = &mut value {
447 for key in NON_DETECTION_CONFIG_KEYS {
448 map.shift_remove(*key);
449 }
450 }
451 canonical_json(value).to_string()
452}
453
454pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
458
459pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
461
462#[must_use]
467pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
468 match max_file_size_mb {
469 None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
470 Some(0) => None,
471 Some(mb) => Some(u64::from(mb) * 1024 * 1024),
472 }
473}
474
475#[must_use]
485pub fn cache_config_hash(external_plugins: &[ExternalPluginDef], flags: &FlagsConfig) -> u64 {
486 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
487 names.sort_unstable();
488 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
489 for name in names {
490 hash_str(&mut hasher, name);
491 }
492 let patterns = flags.patterns();
493 if !patterns.is_builtin_only() {
494 hasher.update(b"\0flags");
495 hasher.update(&(patterns.sdk_patterns.len() as u64).to_le_bytes());
496 for (function, name_arg, provider) in &patterns.sdk_patterns {
497 hash_str(&mut hasher, function);
498 hasher.update(&(*name_arg as u64).to_le_bytes());
499 hash_str(&mut hasher, provider);
500 }
501 hasher.update(&(patterns.env_prefixes.len() as u64).to_le_bytes());
502 for prefix in &patterns.env_prefixes {
503 hash_str(&mut hasher, prefix);
504 }
505 hasher.update(&[u8::from(patterns.config_object_heuristics)]);
506 }
507 hasher.digest()
508}
509
510fn hash_str(hasher: &mut xxhash_rust::xxh3::Xxh3, value: &str) {
511 hasher.update(&(value.len() as u32).to_le_bytes());
512 hasher.update(value.as_bytes());
513}
514
515pub const CACHE_DIR_ENV: &str = "FALLOW_CACHE_DIR";
517
518#[must_use]
524pub fn cache_dir_env_override() -> Option<PathBuf> {
525 std::env::var_os(CACHE_DIR_ENV).and_then(cache_dir_from_env_value)
526}
527
528#[must_use]
530pub fn cache_dir_from_env_value(raw: OsString) -> Option<PathBuf> {
531 Some(PathBuf::from(raw)).filter(|path| !path.as_os_str().is_empty())
532}
533
534pub const CACHE_MAX_SIZE_ENV: &str = "FALLOW_CACHE_MAX_SIZE";
538
539#[must_use]
542pub fn cache_max_size_env_override() -> Option<u32> {
543 std::env::var_os(CACHE_MAX_SIZE_ENV)
544 .as_deref()
545 .and_then(cache_max_size_from_env_value)
546}
547
548#[must_use]
551pub fn cache_max_size_from_env_value(raw: &OsStr) -> Option<u32> {
552 raw.to_str()?
553 .trim()
554 .parse::<u32>()
555 .ok()
556 .filter(|mb| *mb > 0)
557}
558
559pub const PACKAGE_BASELINES_ENV: &str = "FALLOW_PACKAGE_BASELINES";
563
564#[must_use]
568pub fn package_baselines_disabled_by_env_value(raw: &OsStr) -> bool {
569 raw.to_str().is_some_and(|value| {
570 matches!(
571 value.trim().to_ascii_lowercase().as_str(),
572 "false" | "0" | "no" | "off"
573 )
574 })
575}
576
577fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
578 let Some(dir) = configured else {
579 return root.join(".fallow");
580 };
581 if dir.is_absolute() {
582 dir
583 } else {
584 root.join(dir)
585 }
586}
587
588fn cache_dir_is_inside_root(root: &Path, cache_dir: &Path) -> bool {
589 if cache_dir.starts_with(root) {
590 return true;
591 }
592 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
593 let canonical_cache =
594 dunce::canonicalize(cache_dir).unwrap_or_else(|_| cache_dir.to_path_buf());
595 canonical_cache.starts_with(&canonical_root)
596}
597
598fn per_root_cache_subdir(root: &Path) -> String {
602 let path = root.to_string_lossy().replace('\\', "/");
603 let hash = xxhash_rust::xxh3::xxh3_64(path.as_bytes());
604 let name: String = root
605 .file_name()
606 .map(|name| name.to_string_lossy())
607 .unwrap_or_default()
608 .chars()
609 .map(|c| {
610 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
611 c
612 } else {
613 '_'
614 }
615 })
616 .collect();
617 if name.is_empty() {
618 format!("{hash:016x}")
619 } else {
620 format!("{name}-{hash:016x}")
621 }
622}
623
624fn normalize_user_glob_pattern(pattern: &str) -> &str {
625 pattern.strip_prefix("./").unwrap_or(pattern)
626}
627
628pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
643 "**/node_modules/**",
644 "**/dist/**",
645 "**/build/**",
646 "**/.git/**",
647 "**/coverage/**",
648 "**/*.min.js",
649 "**/*.min.mjs",
650 "**/*.min.cjs",
651 "**/*.bundle.js",
652];
653
654#[expect(
655 clippy::expect_used,
656 reason = "user glob patterns are validated before config resolution"
657)]
658fn compile_ignore_patterns(ignore_patterns: &[String]) -> (IgnorePatternSet, usize) {
659 let mut ignore_builder = GlobSetBuilder::new();
660 let mut user_pattern_count = 0;
661 let mut exceptions: Vec<&str> = Vec::new();
662 for pattern in ignore_patterns {
663 if let Some(body) = pattern.strip_prefix('!') {
664 exceptions.push(normalize_user_glob_pattern(body));
665 continue;
666 }
667 let normalized = normalize_user_glob_pattern(pattern);
668 ignore_builder.add(
669 compile_path_glob(normalized)
670 .expect("ignorePatterns entry was validated at config load time"),
671 );
672 user_pattern_count += 1;
673 }
674
675 for pattern in DEFAULT_IGNORE_PATTERNS {
676 ignore_builder.add(compile_path_glob(pattern).expect("default ignore pattern is valid"));
677 }
678
679 let set = IgnorePatternSet::new(ignore_builder.build().unwrap_or_default(), &exceptions);
680 (set, user_pattern_count)
681}
682
683#[expect(
684 clippy::expect_used,
685 reason = "user glob patterns are validated before config resolution"
686)]
687fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
688 patterns
689 .iter()
690 .map(|pattern| {
691 let normalized = normalize_user_glob_pattern(pattern);
692 Glob::new(normalized)
693 .expect("ignoreUnresolvedImports entry was validated at config load time")
694 .compile_matcher()
695 })
696 .collect()
697}
698
699fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
700 if production {
701 rules.unused_dev_dependencies = Severity::Off;
702 rules.unused_optional_dependencies = Severity::Off;
703 }
704 rules
705}
706
707fn resolve_boundaries(
708 mut boundaries: super::boundaries::BoundaryConfig,
709 root: &Path,
710) -> ResolvedBoundaryConfig {
711 expand_boundary_preset(&mut boundaries, root);
712 let logical_groups = boundaries.expand_auto_discover(root);
713 let mut resolved = boundaries.resolve();
714 resolved.logical_groups = logical_groups;
715 resolved
716}
717
718pub(super) fn expand_boundary_preset(
721 boundaries: &mut super::boundaries::BoundaryConfig,
722 root: &Path,
723) {
724 if boundaries.preset.is_some() {
725 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
726 .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
727 .unwrap_or_else(|| "src".to_owned());
728 if source_root != "src" {
729 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
730 }
731 boundaries.expand(&source_root);
732 }
733}
734
735fn ineffective_inter_file_override_rules(rules: &PartialRulesConfig) -> Vec<&'static str> {
741 let mut names = Vec::new();
742 if rules.duplicate_exports.is_some() {
743 names.push("duplicate-exports");
744 }
745 if rules.re_export_cycle.is_some() {
746 names.push("re-export-cycle");
747 }
748 names
749}
750
751fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
752 let ineffective = ineffective_inter_file_override_rules(rules);
753 if ineffective.contains(&"duplicate-exports")
754 && record_inter_file_warn_seen("duplicate-exports", files)
755 {
756 let files = files.join(", ");
757 tracing::warn!(
758 "overrides.rules.duplicate-exports has no effect for files matching [{files}]: duplicate-exports is an inter-file rule. Use top-level `ignoreExports` to exclude these files from duplicate-export grouping."
759 );
760 }
761 if ineffective.contains(&"re-export-cycle")
762 && record_inter_file_warn_seen("re-export-cycle", files)
763 {
764 let files = files.join(", ");
765 tracing::warn!(
766 "overrides.rules.re-export-cycle has no effect for files matching [{files}]: re-export-cycle is an inter-file rule (the cycle spans multiple barrels). Use a file-level `// fallow-ignore-file re-export-cycle` comment in one participating file instead, or set `rules.re-export-cycle: off` at the top level."
767 );
768 }
769}
770
771#[expect(
772 clippy::expect_used,
773 reason = "override glob patterns are validated before config resolution"
774)]
775fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
776 overrides
777 .into_iter()
778 .filter_map(|override_entry| {
779 warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
780 let matchers: Vec<globset::GlobMatcher> = override_entry
781 .files
782 .iter()
783 .map(|pattern| {
784 compile_path_glob(pattern)
785 .expect("overrides[].files pattern was validated at config load time")
786 .compile_matcher()
787 })
788 .collect();
789 if matchers.is_empty() {
790 None
791 } else {
792 Some(ResolvedOverride {
793 matchers,
794 rules: override_entry.rules,
795 })
796 }
797 })
798 .collect()
799}
800
801#[expect(
803 clippy::expect_used,
804 reason = "user glob patterns are validated before config resolution"
805)]
806fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
807 rules
808 .iter()
809 .map(|rule| CompiledIgnoreExportRule {
810 matcher: compile_path_glob(&rule.file)
811 .expect("ignoreExports[].file was validated at config load time")
812 .compile_matcher(),
813 exports: rule.exports.clone(),
814 })
815 .collect()
816}
817
818#[expect(
820 clippy::expect_used,
821 reason = "user glob patterns are validated before config resolution"
822)]
823fn compile_ignore_catalog_reference_rules(
824 rules: &[IgnoreCatalogReferenceRule],
825) -> Vec<CompiledIgnoreCatalogReferenceRule> {
826 rules
827 .iter()
828 .map(|rule| CompiledIgnoreCatalogReferenceRule {
829 package: rule.package.clone(),
830 catalog: rule.catalog.clone(),
831 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
832 compile_path_glob(pattern)
833 .expect("ignoreCatalogReferences[].consumer was validated at config load time")
834 .compile_matcher()
835 }),
836 })
837 .collect()
838}
839
840fn compile_ignore_dependency_override_rules(
842 rules: &[IgnoreDependencyOverrideRule],
843) -> Vec<CompiledIgnoreDependencyOverrideRule> {
844 rules
845 .iter()
846 .map(|rule| CompiledIgnoreDependencyOverrideRule {
847 package: rule.package.clone(),
848 source: rule.source.clone(),
849 })
850 .collect()
851}
852
853struct CompiledIgnoreSettings {
854 patterns: IgnorePatternSet,
855 user_pattern_count: usize,
856 findings: FindingIgnoreMatcher,
857 unresolved_imports: Vec<GlobMatcher>,
858 exports: Vec<CompiledIgnoreExportRule>,
859 catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
860 dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
861}
862
863fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
864 let (patterns, user_pattern_count) = compile_ignore_patterns(&config.ignore_patterns);
865 CompiledIgnoreSettings {
866 patterns,
867 user_pattern_count,
868 findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
869 unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
870 exports: compile_ignore_export_rules(&config.ignore_exports),
871 catalog_references: compile_ignore_catalog_reference_rules(
872 &config.ignore_catalog_references,
873 ),
874 dependency_overrides: compile_ignore_dependency_override_rules(
875 &config.ignore_dependency_overrides,
876 ),
877 }
878}
879
880struct ResolvedPluginSettings {
881 external_plugins: Vec<ExternalPluginDef>,
882 rule_packs: Vec<crate::rule_pack::RulePackDef>,
883 rule_pack_sources: Vec<PathBuf>,
884}
885
886fn resolve_plugin_settings(
887 root: &Path,
888 configured_plugins: &[String],
889 framework: Vec<ExternalPluginDef>,
890 rule_packs: &[String],
891) -> ResolvedPluginSettings {
892 let mut external_plugins = discover_external_plugins(root, configured_plugins);
893 external_plugins.extend(framework);
894
895 let configured_rule_packs = rule_packs;
896 let rule_packs =
897 crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
898 for error in &errors {
899 tracing::error!("invalid rule pack: {error}");
900 }
901 Vec::new()
902 });
903 let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
904 configured_rule_packs.iter().map(PathBuf::from).collect()
905 } else {
906 Vec::new()
907 };
908
909 ResolvedPluginSettings {
910 external_plugins,
911 rule_packs,
912 rule_pack_sources,
913 }
914}
915
916struct ResolvedCacheSettings {
917 dir: PathBuf,
918 max_size_mb: Option<u32>,
919 config_hash: u64,
920}
921
922struct ResolvedProductionRules {
923 production: bool,
924 rules: RulesConfig,
925}
926
927fn resolve_production_rules(
928 production_config: ProductionConfig,
929 rules: RulesConfig,
930) -> ResolvedProductionRules {
931 let production = production_config.global();
932 ResolvedProductionRules {
933 production,
934 rules: resolve_rules_for_production(rules, production),
935 }
936}
937
938fn resolve_cache_settings(
939 root: &Path,
940 configured_dir: Option<PathBuf>,
941 configured_max_size_mb: Option<u32>,
942 override_max_size_mb: Option<u32>,
943 no_cache: bool,
944 config_hash: impl FnOnce() -> u64,
945) -> ResolvedCacheSettings {
946 ResolvedCacheSettings {
947 dir: resolve_cache_dir(root, configured_dir),
948 max_size_mb: override_max_size_mb.or(configured_max_size_mb),
949 config_hash: if no_cache { 0 } else { config_hash() },
950 }
951}
952
953fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
954 SecurityConfig {
955 request_receivers: security.normalized_request_receivers(),
956 ..security
957 }
958}
959
960struct ResolvedPathPolicySettings {
961 boundaries: ResolvedBoundaryConfig,
962 overrides: Vec<ResolvedOverride>,
963}
964
965fn resolve_path_policy_settings(
966 boundaries: BoundaryConfig,
967 overrides: Vec<ConfigOverride>,
968 root: &Path,
969) -> ResolvedPathPolicySettings {
970 ResolvedPathPolicySettings {
971 boundaries: resolve_boundaries(boundaries, root),
972 overrides: compile_overrides(overrides),
973 }
974}
975
976fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
977 pattern.and_then(|pattern| match regex::Regex::new(pattern) {
978 Ok(re) => Some(re),
979 Err(error) => {
980 tracing::warn!(
981 %error,
982 "ignoring invalid unusedComponentProps.ignorePattern; this config was \
983 not validated through FallowConfig::load"
984 );
985 None
986 }
987 })
988}
989
990impl FallowConfig {
991 #[expect(
993 clippy::too_many_arguments,
994 reason = "public cross-crate API: ResolvedConfig builder whose runtime-override parameters (root, output, threads, no_cache, quiet, cache_max_size_mb) are an established stable signature; bundling them would break callers"
995 )]
996 pub fn resolve(
997 self,
998 root: PathBuf,
999 output: OutputFormat,
1000 threads: usize,
1001 no_cache: bool,
1002 quiet: bool,
1003 cache_max_size_mb: Option<u32>,
1004 ) -> ResolvedConfig {
1005 let mut legacy_glob_groups = super::legacy_glob::collect_config_legacy_glob_groups(&self);
1006 let compiled_ignores = compile_ignore_settings(&self);
1007 let config_json = detection_config_json(&self);
1008
1009 let production_rules = resolve_production_rules(self.production, self.rules);
1010
1011 let plugins =
1012 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
1013
1014 let cache = resolve_cache_settings(
1015 &root,
1016 self.cache.dir,
1017 self.cache.max_size_mb,
1018 cache_max_size_mb,
1019 no_cache,
1020 || cache_config_hash(&plugins.external_plugins, &self.flags),
1021 );
1022
1023 let detection_config_digest = fallow_types::identity::fnv1a64_parts(&[
1024 "config",
1025 &config_json,
1026 &canonical_json_string(&{
1027 let mut sorted: Vec<&ExternalPluginDef> = plugins.external_plugins.iter().collect();
1028 sorted.sort_by(|a, b| a.name.cmp(&b.name));
1029 sorted
1030 }),
1031 &canonical_json_string(&plugins.rule_packs),
1032 ]);
1033
1034 super::legacy_glob::extend_legacy_glob_groups(
1035 &mut legacy_glob_groups,
1036 &plugins.external_plugins,
1037 &plugins.rule_packs,
1038 );
1039
1040 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
1041 let (workspace_patterns, workspace_changed_since) = self
1042 .workspaces
1043 .map(|workspaces| (workspaces.patterns, workspaces.changed_since))
1044 .unwrap_or_default();
1045
1046 let unused_component_props_ignore = compile_unused_component_props_ignore(
1047 self.unused_component_props.ignore_pattern.as_deref(),
1048 );
1049
1050 ResolvedConfig {
1051 root,
1052 entry_patterns: self.entry,
1053 ignore_patterns: compiled_ignores.patterns,
1054 user_ignore_pattern_count: compiled_ignores.user_pattern_count,
1055 ignore_findings: compiled_ignores.findings,
1056 output,
1057 cache_dir: cache.dir,
1058 threads,
1059 no_cache,
1060 cache_max_size_mb: cache.max_size_mb,
1061 cache_config_hash: cache.config_hash,
1062 ignore_dependencies: IgnoreDependencyMatcher::compile(&self.ignore_dependencies),
1063 ignore_command_entries: self.ignore_command_entries,
1064 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
1065 ignore_export_rules: self.ignore_exports,
1066 compiled_ignore_exports: compiled_ignores.exports,
1067 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
1068 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
1069 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
1070 used_class_members: self.used_class_members,
1071 ignore_decorators: self.ignore_decorators,
1072 unused_component_props_ignore,
1073 circular_dependencies: self.circular_dependencies,
1074 duplicates: self.duplicates,
1075 similar_code: self.similar_code,
1076 mutation: self.mutation,
1077 health: self.health,
1078 type_aware: self.type_aware,
1079 rules: production_rules.rules,
1080 boundaries: path_policy.boundaries,
1081 rule_packs: plugins.rule_packs,
1082 rule_pack_sources: plugins.rule_pack_sources,
1083 production: production_rules.production,
1084 quiet,
1085 external_plugins: plugins.external_plugins,
1086 dynamically_loaded: self.dynamically_loaded,
1087 overrides: path_policy.overrides,
1088 workspace_changed_since,
1089 workspace_patterns,
1090 regression: self.regression,
1091 audit: self.audit,
1092 codeowners: self.codeowners,
1093 public_packages: self.public_packages,
1094 flags: self.flags,
1095 security: normalize_security_config(self.security),
1096 fix: self.fix,
1097 resolve: self.resolve,
1098 include_entry_exports: self.include_entry_exports,
1099 show_cascade: self.show_cascade,
1100 auto_imports: self.auto_imports,
1101 fail_on_parse_error: self.fail_on_parse_error,
1102 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
1103 analysis_snapshot: AnalysisSnapshot::Current,
1104 detection_config_digest,
1105 legacy_glob_groups,
1106 }
1107 }
1108}
1109
1110impl ResolvedConfig {
1111 pub fn apply_package_baselines_env(&mut self, raw: Option<&OsStr>) {
1114 if raw.is_some_and(package_baselines_disabled_by_env_value) {
1115 self.workspace_changed_since.clear();
1116 }
1117 }
1118
1119 pub fn override_cache_dir(&mut self, dir: PathBuf) {
1123 self.cache_dir = resolve_cache_dir(&self.root, Some(dir));
1124 }
1125
1126 pub fn scope_shared_cache_dir_to_root(&mut self) {
1137 if cache_dir_is_inside_root(&self.root, &self.cache_dir) {
1138 return;
1139 }
1140 self.cache_dir = self.cache_dir.join(per_root_cache_subdir(&self.root));
1141 }
1142
1143 #[must_use]
1146 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
1147 if self.overrides.is_empty() {
1148 return self.rules.clone();
1149 }
1150
1151 let relative = path.strip_prefix(&self.root).unwrap_or(path);
1152 let relative_str = relative.to_string_lossy();
1153
1154 let mut rules = self.rules.clone();
1155 for override_entry in &self.overrides {
1156 let matches = override_entry
1157 .matchers
1158 .iter()
1159 .any(|m| m.is_match(relative_str.as_ref()));
1160 if matches {
1161 rules.apply_partial(&override_entry.rules);
1162 }
1163 }
1164 rules
1165 }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use crate::CacheConfig;
1172 use crate::config::boundaries::BoundaryConfig;
1173 use crate::config::health::HealthConfig;
1174
1175 #[test]
1176 fn mutation_section_reaches_resolved_config_and_leaves_the_detection_digest() {
1177 let resolve = |json: &str| {
1178 let authored: FallowConfig = serde_json::from_str(json).expect("config deserializes");
1179 authored.resolve(
1180 PathBuf::from("/project"),
1181 OutputFormat::Json,
1182 1,
1183 true,
1184 true,
1185 None,
1186 )
1187 };
1188 let plain = resolve("{}");
1189 let with_mutation = resolve(r#"{"mutation":{"minScore":75,"operators":["removed-call"]}}"#);
1190 assert_eq!(with_mutation.mutation.min_score, Some(75.0));
1191 assert_eq!(
1192 with_mutation.mutation.operators,
1193 Some(vec![crate::MutationOperatorName::RemovedCall])
1194 );
1195 assert_eq!(
1196 plain.detection_config_digest,
1197 with_mutation.detection_config_digest
1198 );
1199 let detecting = resolve(r#"{"ignorePatterns":["gen/**"]}"#);
1200 assert_ne!(
1201 plain.detection_config_digest,
1202 detecting.detection_config_digest
1203 );
1204 }
1205
1206 #[test]
1209 fn single_star_stays_within_one_segment_in_config_path_globs() {
1210 let authored: FallowConfig = serde_json::from_str(
1211 r#"{
1212 "ignorePatterns": ["gen/*.ts"],
1213 "ignoreFindings": ["legacy/*.ts"],
1214 "overrides": [{ "files": ["old/*.ts"], "rules": { "unused-files": "off" } }],
1215 "ignoreExports": [{ "file": "api/*.ts", "exports": ["*"] }],
1216 "ignoreCatalogReferences": [{ "package": "react", "consumer": "apps/*/package.json" }],
1217 "boundaries": {
1218 "zones": [{ "name": "ui", "patterns": ["ui/*.ts"] }],
1219 "coverage": { "allowUnmatched": ["scripts/*.ts"] }
1220 }
1221 }"#,
1222 )
1223 .expect("config deserializes");
1224 let resolved = authored.resolve(
1225 PathBuf::from("/project"),
1226 OutputFormat::Json,
1227 1,
1228 true,
1229 true,
1230 None,
1231 );
1232
1233 assert!(resolved.ignore_patterns.is_match("gen/a.ts"));
1234 assert!(!resolved.ignore_patterns.is_match("gen/deep/a.ts"));
1235
1236 assert!(resolved.ignore_findings.is_ignored("legacy/a.ts"));
1237 assert!(!resolved.ignore_findings.is_ignored("legacy/deep/a.ts"));
1238
1239 let off = |path: &str| {
1240 resolved
1241 .resolve_rules_for_path(Path::new(path))
1242 .unused_files
1243 == Severity::Off
1244 };
1245 assert!(off("/project/old/a.ts"));
1246 assert!(!off("/project/old/deep/a.ts"));
1247
1248 let export_rule = &resolved.compiled_ignore_exports[0].matcher;
1249 assert!(export_rule.is_match("api/a.ts"));
1250 assert!(!export_rule.is_match("api/deep/a.ts"));
1251
1252 let catalog_rule = &resolved.compiled_ignore_catalog_references[0];
1253 assert!(catalog_rule.matches("react", "default", "apps/web/package.json"));
1254 assert!(!catalog_rule.matches("react", "default", "apps/web/nested/package.json"));
1255
1256 assert_eq!(resolved.boundaries.classify_zone("ui/a.ts"), Some("ui"));
1257 assert_eq!(resolved.boundaries.classify_zone("ui/deep/a.ts"), None);
1258
1259 let allow_unmatched = &resolved.boundaries.coverage.allow_unmatched[0];
1260 assert!(allow_unmatched.is_match("scripts/a.ts"));
1261 assert!(!allow_unmatched.is_match("scripts/deep/a.ts"));
1262 }
1263
1264 #[test]
1265 fn workspace_patterns_reach_resolved_config() {
1266 let authored: FallowConfig =
1267 serde_json::from_str(r#"{"workspaces":{"patterns":["apps/*/*"]}}"#)
1268 .expect("workspace patterns deserialize");
1269 let resolved = authored.resolve(
1270 PathBuf::from("/project"),
1271 OutputFormat::Json,
1272 1,
1273 true,
1274 true,
1275 None,
1276 );
1277
1278 assert_eq!(resolved.workspace_patterns, vec!["apps/*/*".to_string()]);
1279 }
1280
1281 #[test]
1282 fn workspace_changed_since_reaches_resolved_config() {
1283 let authored: FallowConfig = serde_json::from_str(
1284 r#"{"workspaces":{"changedSince":{"packages/web":"main","packages/legacy":"release/2024.10"}}}"#,
1285 )
1286 .expect("workspace baselines deserialize");
1287 let resolved = authored.resolve(
1288 PathBuf::from("/project"),
1289 OutputFormat::Json,
1290 1,
1291 true,
1292 true,
1293 None,
1294 );
1295
1296 assert_eq!(resolved.workspace_changed_since.len(), 2);
1297 assert_eq!(resolved.workspace_changed_since["packages/web"], "main");
1298 assert_eq!(
1299 resolved.workspace_changed_since["packages/legacy"],
1300 "release/2024.10"
1301 );
1302 }
1303
1304 #[test]
1305 fn cache_config_hash_keys_on_user_flag_patterns() {
1306 let builtin = cache_config_hash(&[], &FlagsConfig::default());
1307 let with_pattern = |function: &str| FlagsConfig {
1308 sdk_patterns: vec![super::super::flags::SdkPattern {
1309 function: function.to_string(),
1310 name_arg: 0,
1311 provider: None,
1312 }],
1313 ..FlagsConfig::default()
1314 };
1315
1316 assert_eq!(
1317 builtin,
1318 xxhash_rust::xxh3::Xxh3::new().digest(),
1319 "a config without flag patterns keeps the plugin-only hash"
1320 );
1321 assert_ne!(builtin, cache_config_hash(&[], &with_pattern("isOn")));
1322 assert_ne!(
1323 cache_config_hash(&[], &with_pattern("isOn")),
1324 cache_config_hash(&[], &with_pattern("isOff"))
1325 );
1326 assert_ne!(
1327 builtin,
1328 cache_config_hash(
1329 &[],
1330 &FlagsConfig {
1331 config_object_heuristics: true,
1332 ..FlagsConfig::default()
1333 }
1334 )
1335 );
1336 assert_ne!(
1337 builtin,
1338 cache_config_hash(
1339 &[],
1340 &FlagsConfig {
1341 env_prefixes: vec!["MYAPP_".to_string()],
1342 ..FlagsConfig::default()
1343 }
1344 )
1345 );
1346 }
1347
1348 #[test]
1349 fn overrides_deserialize() {
1350 let json_str = r#"{
1351 "overrides": [{
1352 "files": ["*.test.ts"],
1353 "rules": {
1354 "unused-exports": "off"
1355 }
1356 }]
1357 }"#;
1358 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1359 assert_eq!(config.overrides.len(), 1);
1360 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
1361 assert_eq!(
1362 config.overrides[0].rules.unused_exports,
1363 Some(Severity::Off)
1364 );
1365 assert_eq!(config.overrides[0].rules.unused_files, None);
1366 }
1367
1368 #[test]
1369 fn resolve_rules_for_path_no_overrides() {
1370 let config = FallowConfig {
1371 schema: None,
1372 minimum_version: None,
1373 extends: vec![],
1374 entry: vec![],
1375 ignore_patterns: vec![],
1376 ignore_findings: vec![],
1377 framework: vec![],
1378 workspaces: None,
1379 ignore_dependencies: vec![],
1380 ignore_command_entries: vec![],
1381 ignore_unresolved_imports: vec![],
1382 ignore_exports: vec![],
1383 ignore_catalog_references: vec![],
1384 ignore_dependency_overrides: vec![],
1385 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1386 used_class_members: vec![],
1387 ignore_decorators: vec![],
1388 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1389 circular_dependencies: crate::CircularDependenciesConfig::default(),
1390 duplicates: DuplicatesConfig::default(),
1391 similar_code: SimilarCodeConfig::default(),
1392 mutation: MutationConfig::default(),
1393 health: HealthConfig::default(),
1394 rules: RulesConfig::default(),
1395 boundaries: BoundaryConfig::default(),
1396 production: false.into(),
1397 plugins: vec![],
1398 rule_packs: vec![],
1399 dynamically_loaded: vec![],
1400 overrides: vec![],
1401 regression: None,
1402 type_aware: crate::TypeAwareConfig::default(),
1403 audit: crate::config::AuditConfig::default(),
1404 codeowners: None,
1405 public_packages: vec![],
1406 flags: FlagsConfig::default(),
1407 security: SecurityConfig::default(),
1408 fix: crate::config::FixConfig::default(),
1409 resolve: ResolveConfig::default(),
1410 sealed: false,
1411 include_entry_exports: false,
1412 show_cascade: false,
1413 auto_imports: false,
1414 fail_on_parse_error: false,
1415 cache: CacheConfig::default(),
1416 };
1417 let resolved = config.resolve(
1418 PathBuf::from("/project"),
1419 OutputFormat::Human,
1420 1,
1421 true,
1422 true,
1423 None,
1424 );
1425 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1426 assert_eq!(rules.unused_files, Severity::Error);
1427 }
1428
1429 #[test]
1430 fn resolve_rules_for_path_with_matching_override() {
1431 let config = FallowConfig {
1432 schema: None,
1433 minimum_version: None,
1434 extends: vec![],
1435 entry: vec![],
1436 ignore_patterns: vec![],
1437 ignore_findings: vec![],
1438 framework: vec![],
1439 workspaces: None,
1440 ignore_dependencies: vec![],
1441 ignore_command_entries: vec![],
1442 ignore_unresolved_imports: vec![],
1443 ignore_exports: vec![],
1444 ignore_catalog_references: vec![],
1445 ignore_dependency_overrides: vec![],
1446 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1447 used_class_members: vec![],
1448 ignore_decorators: vec![],
1449 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1450 circular_dependencies: crate::CircularDependenciesConfig::default(),
1451 duplicates: DuplicatesConfig::default(),
1452 similar_code: SimilarCodeConfig::default(),
1453 mutation: MutationConfig::default(),
1454 health: HealthConfig::default(),
1455 rules: RulesConfig::default(),
1456 boundaries: BoundaryConfig::default(),
1457 production: false.into(),
1458 plugins: vec![],
1459 rule_packs: vec![],
1460 dynamically_loaded: vec![],
1461 overrides: vec![ConfigOverride {
1462 files: vec!["**/*.test.ts".to_string()],
1463 rules: PartialRulesConfig {
1464 unused_exports: Some(Severity::Off),
1465 ..Default::default()
1466 },
1467 }],
1468 regression: None,
1469 type_aware: crate::TypeAwareConfig::default(),
1470 audit: crate::config::AuditConfig::default(),
1471 codeowners: None,
1472 public_packages: vec![],
1473 flags: FlagsConfig::default(),
1474 security: SecurityConfig::default(),
1475 fix: crate::config::FixConfig::default(),
1476 resolve: ResolveConfig::default(),
1477 sealed: false,
1478 include_entry_exports: false,
1479 show_cascade: false,
1480 auto_imports: false,
1481 fail_on_parse_error: false,
1482 cache: CacheConfig::default(),
1483 };
1484 let resolved = config.resolve(
1485 PathBuf::from("/project"),
1486 OutputFormat::Human,
1487 1,
1488 true,
1489 true,
1490 None,
1491 );
1492
1493 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1494 assert_eq!(test_rules.unused_exports, Severity::Off);
1495 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1498 assert_eq!(src_rules.unused_exports, Severity::Error);
1499 }
1500
1501 #[test]
1502 fn resolve_rules_for_path_later_override_wins() {
1503 let config = FallowConfig {
1504 schema: None,
1505 minimum_version: None,
1506 extends: vec![],
1507 entry: vec![],
1508 ignore_patterns: vec![],
1509 ignore_findings: vec![],
1510 framework: vec![],
1511 workspaces: None,
1512 ignore_dependencies: vec![],
1513 ignore_command_entries: vec![],
1514 ignore_unresolved_imports: vec![],
1515 ignore_exports: vec![],
1516 ignore_catalog_references: vec![],
1517 ignore_dependency_overrides: vec![],
1518 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1519 used_class_members: vec![],
1520 ignore_decorators: vec![],
1521 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1522 circular_dependencies: crate::CircularDependenciesConfig::default(),
1523 duplicates: DuplicatesConfig::default(),
1524 similar_code: SimilarCodeConfig::default(),
1525 mutation: MutationConfig::default(),
1526 health: HealthConfig::default(),
1527 rules: RulesConfig::default(),
1528 boundaries: BoundaryConfig::default(),
1529 production: false.into(),
1530 plugins: vec![],
1531 rule_packs: vec![],
1532 dynamically_loaded: vec![],
1533 overrides: vec![
1534 ConfigOverride {
1535 files: vec!["*.ts".to_string()],
1536 rules: PartialRulesConfig {
1537 unused_files: Some(Severity::Warn),
1538 ..Default::default()
1539 },
1540 },
1541 ConfigOverride {
1542 files: vec!["*.test.ts".to_string()],
1543 rules: PartialRulesConfig {
1544 unused_files: Some(Severity::Off),
1545 ..Default::default()
1546 },
1547 },
1548 ],
1549 regression: None,
1550 type_aware: crate::TypeAwareConfig::default(),
1551 audit: crate::config::AuditConfig::default(),
1552 codeowners: None,
1553 public_packages: vec![],
1554 flags: FlagsConfig::default(),
1555 security: SecurityConfig::default(),
1556 fix: crate::config::FixConfig::default(),
1557 resolve: ResolveConfig::default(),
1558 sealed: false,
1559 include_entry_exports: false,
1560 show_cascade: false,
1561 auto_imports: false,
1562 fail_on_parse_error: false,
1563 cache: CacheConfig::default(),
1564 };
1565 let resolved = config.resolve(
1566 PathBuf::from("/project"),
1567 OutputFormat::Human,
1568 1,
1569 true,
1570 true,
1571 None,
1572 );
1573
1574 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1575 assert_eq!(rules.unused_files, Severity::Off);
1576
1577 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1578 assert_eq!(rules2.unused_files, Severity::Warn);
1579 }
1580
1581 #[test]
1582 fn resolve_keeps_inter_file_rule_override_after_warning() {
1583 let config = FallowConfig {
1584 schema: None,
1585 minimum_version: None,
1586 extends: vec![],
1587 entry: vec![],
1588 ignore_patterns: vec![],
1589 ignore_findings: vec![],
1590 framework: vec![],
1591 workspaces: None,
1592 ignore_dependencies: vec![],
1593 ignore_command_entries: vec![],
1594 ignore_unresolved_imports: vec![],
1595 ignore_exports: vec![],
1596 ignore_catalog_references: vec![],
1597 ignore_dependency_overrides: vec![],
1598 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1599 used_class_members: vec![],
1600 ignore_decorators: vec![],
1601 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1602 circular_dependencies: crate::CircularDependenciesConfig::default(),
1603 duplicates: DuplicatesConfig::default(),
1604 similar_code: SimilarCodeConfig::default(),
1605 mutation: MutationConfig::default(),
1606 health: HealthConfig::default(),
1607 rules: RulesConfig::default(),
1608 boundaries: BoundaryConfig::default(),
1609 production: false.into(),
1610 plugins: vec![],
1611 rule_packs: vec![],
1612 dynamically_loaded: vec![],
1613 overrides: vec![ConfigOverride {
1614 files: vec!["**/ui/**".to_string()],
1615 rules: PartialRulesConfig {
1616 duplicate_exports: Some(Severity::Off),
1617 unused_files: Some(Severity::Warn),
1618 ..Default::default()
1619 },
1620 }],
1621 regression: None,
1622 type_aware: crate::TypeAwareConfig::default(),
1623 audit: crate::config::AuditConfig::default(),
1624 codeowners: None,
1625 public_packages: vec![],
1626 flags: FlagsConfig::default(),
1627 security: SecurityConfig::default(),
1628 fix: crate::config::FixConfig::default(),
1629 resolve: ResolveConfig::default(),
1630 sealed: false,
1631 include_entry_exports: false,
1632 show_cascade: false,
1633 auto_imports: false,
1634 fail_on_parse_error: false,
1635 cache: CacheConfig::default(),
1636 };
1637 let resolved = config.resolve(
1638 PathBuf::from("/project"),
1639 OutputFormat::Human,
1640 1,
1641 true,
1642 true,
1643 None,
1644 );
1645 assert_eq!(
1646 resolved.overrides.len(),
1647 1,
1648 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1649 );
1650 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1651 assert_eq!(rules.unused_files, Severity::Warn);
1652 }
1653
1654 #[test]
1655 fn circular_dependency_override_is_not_reported_as_ineffective() {
1656 let rules = PartialRulesConfig {
1657 circular_dependencies: Some(Severity::Off),
1658 duplicate_exports: Some(Severity::Off),
1659 re_export_cycle: Some(Severity::Off),
1660 ..PartialRulesConfig::default()
1661 };
1662 assert_eq!(
1663 ineffective_inter_file_override_rules(&rules),
1664 vec!["duplicate-exports", "re-export-cycle"]
1665 );
1666
1667 let only_circular = PartialRulesConfig {
1668 circular_dependencies: Some(Severity::Off),
1669 ..PartialRulesConfig::default()
1670 };
1671 assert!(ineffective_inter_file_override_rules(&only_circular).is_empty());
1672 }
1673
1674 #[test]
1675 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1676 reset_inter_file_warn_dedup_for_test();
1677 let files_a = vec!["__test_dedup_a/*".to_string()];
1678 let files_b = vec!["__test_dedup_b/*".to_string()];
1679
1680 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1681 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1682 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1683
1684 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1685 assert!(!record_inter_file_warn_seen(
1686 "circular-dependency",
1687 &files_a
1688 ));
1689
1690 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1691
1692 let files_reordered = vec![
1693 "__test_dedup_b/*".to_string(),
1694 "__test_dedup_a/*".to_string(),
1695 ];
1696 let files_natural = vec![
1697 "__test_dedup_a/*".to_string(),
1698 "__test_dedup_b/*".to_string(),
1699 ];
1700 reset_inter_file_warn_dedup_for_test();
1701 assert!(record_inter_file_warn_seen(
1702 "duplicate-exports",
1703 &files_natural
1704 ));
1705 assert!(!record_inter_file_warn_seen(
1706 "duplicate-exports",
1707 &files_reordered
1708 ));
1709 }
1710
1711 #[test]
1712 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1713 reset_inter_file_warn_dedup_for_test();
1714 let files = vec!["__test_resolve_dedup/**".to_string()];
1715 let build_config = || FallowConfig {
1716 schema: None,
1717 minimum_version: None,
1718 extends: vec![],
1719 entry: vec![],
1720 ignore_patterns: vec![],
1721 ignore_findings: vec![],
1722 framework: vec![],
1723 workspaces: None,
1724 ignore_dependencies: vec![],
1725 ignore_command_entries: vec![],
1726 ignore_unresolved_imports: vec![],
1727 ignore_exports: vec![],
1728 ignore_catalog_references: vec![],
1729 ignore_dependency_overrides: vec![],
1730 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1731 used_class_members: vec![],
1732 ignore_decorators: vec![],
1733 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1734 circular_dependencies: crate::CircularDependenciesConfig::default(),
1735 duplicates: DuplicatesConfig::default(),
1736 similar_code: SimilarCodeConfig::default(),
1737 mutation: MutationConfig::default(),
1738 health: HealthConfig::default(),
1739 rules: RulesConfig::default(),
1740 boundaries: BoundaryConfig::default(),
1741 production: false.into(),
1742 plugins: vec![],
1743 rule_packs: vec![],
1744 dynamically_loaded: vec![],
1745 overrides: vec![ConfigOverride {
1746 files: files.clone(),
1747 rules: PartialRulesConfig {
1748 duplicate_exports: Some(Severity::Off),
1749 ..Default::default()
1750 },
1751 }],
1752 regression: None,
1753 type_aware: crate::TypeAwareConfig::default(),
1754 audit: crate::config::AuditConfig::default(),
1755 codeowners: None,
1756 public_packages: vec![],
1757 flags: FlagsConfig::default(),
1758 security: SecurityConfig::default(),
1759 fix: crate::config::FixConfig::default(),
1760 resolve: ResolveConfig::default(),
1761 sealed: false,
1762 include_entry_exports: false,
1763 show_cascade: false,
1764 auto_imports: false,
1765 fail_on_parse_error: false,
1766 cache: CacheConfig::default(),
1767 };
1768 for _ in 0..10 {
1769 let _ = build_config().resolve(
1770 PathBuf::from("/project"),
1771 OutputFormat::Human,
1772 1,
1773 true,
1774 true,
1775 None,
1776 );
1777 }
1778 assert!(
1779 !record_inter_file_warn_seen("duplicate-exports", &files),
1780 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1781 );
1782 }
1783
1784 fn make_config(production: bool) -> FallowConfig {
1786 FallowConfig {
1787 schema: None,
1788 minimum_version: None,
1789 extends: vec![],
1790 entry: vec![],
1791 ignore_patterns: vec![],
1792 ignore_findings: vec![],
1793 framework: vec![],
1794 workspaces: None,
1795 ignore_dependencies: vec![],
1796 ignore_command_entries: vec![],
1797 ignore_unresolved_imports: vec![],
1798 ignore_exports: vec![],
1799 ignore_catalog_references: vec![],
1800 ignore_dependency_overrides: vec![],
1801 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1802 used_class_members: vec![],
1803 ignore_decorators: vec![],
1804 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1805 circular_dependencies: crate::CircularDependenciesConfig::default(),
1806 duplicates: DuplicatesConfig::default(),
1807 similar_code: SimilarCodeConfig::default(),
1808 mutation: MutationConfig::default(),
1809 health: HealthConfig::default(),
1810 rules: RulesConfig::default(),
1811 boundaries: BoundaryConfig::default(),
1812 production: production.into(),
1813 plugins: vec![],
1814 rule_packs: vec![],
1815 dynamically_loaded: vec![],
1816 overrides: vec![],
1817 regression: None,
1818 type_aware: crate::TypeAwareConfig::default(),
1819 audit: crate::config::AuditConfig::default(),
1820 codeowners: None,
1821 public_packages: vec![],
1822 flags: FlagsConfig::default(),
1823 security: SecurityConfig::default(),
1824 fix: crate::config::FixConfig::default(),
1825 resolve: ResolveConfig::default(),
1826 sealed: false,
1827 include_entry_exports: false,
1828 show_cascade: false,
1829 auto_imports: false,
1830 fail_on_parse_error: false,
1831 cache: CacheConfig::default(),
1832 }
1833 }
1834
1835 #[test]
1836 fn resolve_tracks_rule_pack_sources_in_config_order() {
1837 let dir = tempfile::tempdir().unwrap();
1838 std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1839 std::fs::write(
1840 dir.path().join("rule-packs/team-policy.jsonc"),
1841 r#"{
1842 "version": 1,
1843 "name": "team-policy",
1844 "rules": [
1845 {
1846 "id": "no-moment",
1847 "kind": "banned-import",
1848 "specifiers": ["moment"]
1849 }
1850 ]
1851}
1852"#,
1853 )
1854 .unwrap();
1855
1856 let mut config = make_config(false);
1857 config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1858
1859 let resolved = config.resolve(
1860 dir.path().to_path_buf(),
1861 OutputFormat::Human,
1862 1,
1863 true,
1864 true,
1865 None,
1866 );
1867
1868 assert_eq!(resolved.rule_packs.len(), 1);
1869 assert_eq!(resolved.rule_packs[0].name, "team-policy");
1870 assert_eq!(
1871 resolved.rule_pack_sources,
1872 vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1873 );
1874 }
1875
1876 #[test]
1877 fn resolve_production_forces_dev_deps_off() {
1878 let resolved = make_config(true).resolve(
1879 PathBuf::from("/project"),
1880 OutputFormat::Human,
1881 1,
1882 true,
1883 true,
1884 None,
1885 );
1886 assert_eq!(
1887 resolved.rules.unused_dev_dependencies,
1888 Severity::Off,
1889 "production mode should force unused_dev_dependencies to off"
1890 );
1891 }
1892
1893 #[test]
1894 fn resolve_production_forces_optional_deps_off() {
1895 let resolved = make_config(true).resolve(
1896 PathBuf::from("/project"),
1897 OutputFormat::Human,
1898 1,
1899 true,
1900 true,
1901 None,
1902 );
1903 assert_eq!(
1904 resolved.rules.unused_optional_dependencies,
1905 Severity::Off,
1906 "production mode should force unused_optional_dependencies to off"
1907 );
1908 }
1909
1910 #[test]
1911 fn resolve_production_preserves_other_rules() {
1912 let resolved = make_config(true).resolve(
1913 PathBuf::from("/project"),
1914 OutputFormat::Human,
1915 1,
1916 true,
1917 true,
1918 None,
1919 );
1920 assert_eq!(resolved.rules.unused_files, Severity::Error);
1921 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1922 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1923 }
1924
1925 #[test]
1926 fn resolve_non_production_keeps_dev_deps_default() {
1927 let resolved = make_config(false).resolve(
1928 PathBuf::from("/project"),
1929 OutputFormat::Human,
1930 1,
1931 true,
1932 true,
1933 None,
1934 );
1935 assert_eq!(
1936 resolved.rules.unused_dev_dependencies,
1937 Severity::Warn,
1938 "non-production should keep default severity"
1939 );
1940 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1941 }
1942
1943 #[test]
1944 fn resolve_production_flag_stored() {
1945 let resolved = make_config(true).resolve(
1946 PathBuf::from("/project"),
1947 OutputFormat::Human,
1948 1,
1949 true,
1950 true,
1951 None,
1952 );
1953 assert!(resolved.production);
1954
1955 let resolved2 = make_config(false).resolve(
1956 PathBuf::from("/project"),
1957 OutputFormat::Human,
1958 1,
1959 true,
1960 true,
1961 None,
1962 );
1963 assert!(!resolved2.production);
1964 }
1965
1966 #[test]
1967 fn resolve_default_ignores_node_modules() {
1968 let resolved = make_config(false).resolve(
1969 PathBuf::from("/project"),
1970 OutputFormat::Human,
1971 1,
1972 true,
1973 true,
1974 None,
1975 );
1976 assert!(
1977 resolved
1978 .ignore_patterns
1979 .is_match("node_modules/lodash/index.js")
1980 );
1981 assert!(
1982 resolved
1983 .ignore_patterns
1984 .is_match("packages/a/node_modules/react/index.js")
1985 );
1986 }
1987
1988 #[test]
1989 fn resolve_default_ignores_dist() {
1990 let resolved = make_config(false).resolve(
1991 PathBuf::from("/project"),
1992 OutputFormat::Human,
1993 1,
1994 true,
1995 true,
1996 None,
1997 );
1998 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1999 assert!(
2000 resolved
2001 .ignore_patterns
2002 .is_match("packages/ui/dist/index.js")
2003 );
2004 }
2005
2006 #[test]
2011 fn the_compiled_ignore_union_puts_user_patterns_first_and_the_built_ins_in_order() {
2012 let mut config = make_config(false);
2013 config.ignore_patterns = vec!["vendor/**".to_owned(), "legacy/**".to_owned()];
2014 let resolved = config.resolve(
2015 PathBuf::from("/project"),
2016 OutputFormat::Human,
2017 1,
2018 true,
2019 true,
2020 None,
2021 );
2022
2023 assert_eq!(resolved.user_ignore_pattern_count, 2);
2024 assert_eq!(
2025 resolved.ignore_patterns.len(),
2026 2 + DEFAULT_IGNORE_PATTERNS.len(),
2027 "the union only ever adds"
2028 );
2029 assert_eq!(
2030 resolved.ignore_patterns.matches("vendor/a.ts"),
2031 vec![0],
2032 "a user pattern keeps its configured index"
2033 );
2034 for (offset, pattern) in DEFAULT_IGNORE_PATTERNS.iter().enumerate() {
2035 let sample = match *pattern {
2036 "**/node_modules/**" => "node_modules/react/index.js",
2037 "**/dist/**" => "dist/a.ts",
2038 "**/build/**" => "build/a.ts",
2039 "**/.git/**" => ".git/hooks/a.js",
2040 "**/coverage/**" => "coverage/a.ts",
2041 "**/*.min.js" => "a.min.js",
2042 "**/*.min.mjs" => "a.min.mjs",
2043 "**/*.min.cjs" => "a.min.cjs",
2044 "**/*.bundle.js" => "a.bundle.js",
2045 other => panic!("no sample path for built-in ignore {other}"),
2046 };
2047 assert_eq!(
2048 resolved.ignore_patterns.matches(sample),
2049 vec![resolved.user_ignore_pattern_count + offset],
2050 "{pattern} must sit at its DEFAULT_IGNORE_PATTERNS offset"
2051 );
2052 }
2053 }
2054
2055 #[test]
2059 fn a_user_pattern_that_overlaps_a_built_in_matches_at_the_lower_index() {
2060 let mut config = make_config(false);
2061 config.ignore_patterns = vec!["dist/**".to_owned()];
2062 let resolved = config.resolve(
2063 PathBuf::from("/project"),
2064 OutputFormat::Human,
2065 1,
2066 true,
2067 true,
2068 None,
2069 );
2070
2071 let matches = resolved.ignore_patterns.matches("dist/a.ts");
2072 assert_eq!(matches.first(), Some(&0));
2073 assert!(matches.len() > 1, "the built-in still matches too");
2074 }
2075
2076 #[test]
2077 fn resolve_default_ignores_build_at_any_depth() {
2078 let resolved = make_config(false).resolve(
2079 PathBuf::from("/project"),
2080 OutputFormat::Human,
2081 1,
2082 true,
2083 true,
2084 None,
2085 );
2086 assert!(
2087 resolved.ignore_patterns.is_match("build/output.js"),
2088 "root build/ should be ignored"
2089 );
2090 assert!(
2091 resolved.ignore_patterns.is_match("src/build/helper.ts"),
2092 "nested build/ should be ignored, like dist/ and coverage/"
2093 );
2094 assert!(
2095 resolved
2096 .ignore_patterns
2097 .is_match("projects/app/build/index.js"),
2098 "build/ inside a workspace package should be ignored"
2099 );
2100 }
2101
2102 #[test]
2103 fn resolve_default_ignores_match_build_only_as_a_whole_segment() {
2104 let resolved = make_config(false).resolve(
2105 PathBuf::from("/project"),
2106 OutputFormat::Human,
2107 1,
2108 true,
2109 true,
2110 None,
2111 );
2112 assert!(!resolved.ignore_patterns.is_match("src/build.ts"));
2113 assert!(!resolved.ignore_patterns.is_match("src/rebuild/helper.ts"));
2114 assert!(!resolved.ignore_patterns.is_match("src/buildings/a.ts"));
2115 assert!(!resolved.ignore_patterns.is_match("src/prebuild/a.ts"));
2116 }
2117
2118 #[test]
2127 fn resolve_default_ignores_cover_a_workspace_package_named_build() {
2128 let resolved = make_config(false).resolve(
2129 PathBuf::from("/project"),
2130 OutputFormat::Human,
2131 1,
2132 true,
2133 true,
2134 None,
2135 );
2136 assert!(
2137 resolved
2138 .ignore_patterns
2139 .is_match("packages/build/package.json"),
2140 "the manifest stops contributing unused-dependency findings"
2141 );
2142 assert!(
2143 resolved
2144 .ignore_patterns
2145 .is_match("packages/build/src/index.ts"),
2146 "the package's source stops being analyzed entirely"
2147 );
2148 assert!(
2149 resolved
2150 .ignore_patterns
2151 .is_match("packages/build/src/nested/deep.ts"),
2152 "including source below the package's own subdirectories"
2153 );
2154 }
2155
2156 #[test]
2161 fn resolve_default_ignores_cover_a_framework_config_inside_build() {
2162 let resolved = make_config(false).resolve(
2163 PathBuf::from("/project"),
2164 OutputFormat::Human,
2165 1,
2166 true,
2167 true,
2168 None,
2169 );
2170 assert!(
2171 resolved
2172 .ignore_patterns
2173 .is_match("app/build/webpack.config.js")
2174 );
2175 }
2176
2177 #[test]
2178 fn resolve_default_ignores_minified_files() {
2179 let resolved = make_config(false).resolve(
2180 PathBuf::from("/project"),
2181 OutputFormat::Human,
2182 1,
2183 true,
2184 true,
2185 None,
2186 );
2187 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
2188 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
2189 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
2190 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
2191 assert!(
2192 resolved
2193 .ignore_patterns
2194 .is_match("src/vendor/app.bundle.js")
2195 );
2196 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
2198 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
2199 }
2200
2201 #[test]
2202 fn resolve_max_file_size_bytes_default_and_unlimited() {
2203 assert_eq!(
2205 resolve_max_file_size_bytes(None),
2206 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
2207 );
2208 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
2210 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
2212 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
2213 }
2214
2215 #[test]
2216 fn resolve_sets_default_max_file_size() {
2217 let resolved = make_config(false).resolve(
2218 PathBuf::from("/project"),
2219 OutputFormat::Human,
2220 1,
2221 true,
2222 true,
2223 None,
2224 );
2225 assert_eq!(
2226 resolved.max_file_size_bytes,
2227 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
2228 );
2229 }
2230
2231 #[test]
2232 fn resolve_default_ignores_git() {
2233 let resolved = make_config(false).resolve(
2234 PathBuf::from("/project"),
2235 OutputFormat::Human,
2236 1,
2237 true,
2238 true,
2239 None,
2240 );
2241 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
2242 }
2243
2244 #[test]
2245 fn resolve_default_ignores_coverage() {
2246 let resolved = make_config(false).resolve(
2247 PathBuf::from("/project"),
2248 OutputFormat::Human,
2249 1,
2250 true,
2251 true,
2252 None,
2253 );
2254 assert!(
2255 resolved
2256 .ignore_patterns
2257 .is_match("coverage/lcov-report/index.js")
2258 );
2259 }
2260
2261 #[test]
2262 fn resolve_source_files_not_ignored_by_default() {
2263 let resolved = make_config(false).resolve(
2264 PathBuf::from("/project"),
2265 OutputFormat::Human,
2266 1,
2267 true,
2268 true,
2269 None,
2270 );
2271 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
2272 assert!(
2273 !resolved
2274 .ignore_patterns
2275 .is_match("src/components/Button.tsx")
2276 );
2277 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
2278 }
2279
2280 #[test]
2281 fn resolve_custom_ignore_patterns_merged_with_defaults() {
2282 let mut config = make_config(false);
2283 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
2284 let resolved = config.resolve(
2285 PathBuf::from("/project"),
2286 OutputFormat::Human,
2287 1,
2288 true,
2289 true,
2290 None,
2291 );
2292 assert!(
2293 resolved
2294 .ignore_patterns
2295 .is_match("src/__generated__/types.ts")
2296 );
2297 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
2298 }
2299
2300 #[test]
2301 fn resolve_normalizes_leading_dot_ignore_patterns() {
2302 let mut config = make_config(false);
2303 config.ignore_patterns = vec!["./src/generated/**".to_string()];
2304 let resolved = config.resolve(
2305 PathBuf::from("/project"),
2306 OutputFormat::Human,
2307 1,
2308 true,
2309 true,
2310 None,
2311 );
2312
2313 assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
2314 assert!(
2315 !resolved
2316 .ignore_patterns
2317 .is_match("./src/generated/client.ts")
2318 );
2319 }
2320
2321 #[test]
2322 fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
2323 let mut config = make_config(false);
2324 config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
2325 let resolved = config.resolve(
2326 PathBuf::from("/project"),
2327 OutputFormat::Human,
2328 1,
2329 true,
2330 true,
2331 None,
2332 );
2333
2334 assert!(
2335 resolved
2336 .ignore_unresolved_imports
2337 .iter()
2338 .any(|matcher| matcher.is_match("src/generated/client"))
2339 );
2340 assert!(
2341 !resolved
2342 .ignore_unresolved_imports
2343 .iter()
2344 .any(|matcher| matcher.is_match("./src/generated/client"))
2345 );
2346 }
2347
2348 #[test]
2349 fn resolve_passes_through_entry_patterns() {
2350 let mut config = make_config(false);
2351 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
2352 let resolved = config.resolve(
2353 PathBuf::from("/project"),
2354 OutputFormat::Human,
2355 1,
2356 true,
2357 true,
2358 None,
2359 );
2360 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
2361 }
2362
2363 #[test]
2364 fn resolve_passes_through_ignore_dependencies() {
2365 let mut config = make_config(false);
2366 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
2367 let resolved = config.resolve(
2368 PathBuf::from("/project"),
2369 OutputFormat::Human,
2370 1,
2371 true,
2372 true,
2373 None,
2374 );
2375 assert!(resolved.ignore_dependencies.is_ignored("postcss"));
2376 assert!(resolved.ignore_dependencies.is_ignored("autoprefixer"));
2377 assert!(!resolved.ignore_dependencies.is_ignored("postcss-cli"));
2378 }
2379
2380 #[test]
2381 fn resolve_passes_through_ignore_command_entries() {
2382 let mut config = make_config(false);
2383 config.ignore_command_entries = vec!["my-codegen".to_string()];
2384 let resolved = config.resolve(
2385 PathBuf::from("/project"),
2386 OutputFormat::Human,
2387 1,
2388 true,
2389 true,
2390 None,
2391 );
2392 assert_eq!(resolved.ignore_command_entries, vec!["my-codegen"]);
2393 }
2394
2395 #[test]
2396 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
2397 let mut config = make_config(false);
2398 config.ignore_unresolved_imports = vec![
2399 "@example/icons".to_string(),
2400 "@example/icons/**".to_string(),
2401 "../generated/**".to_string(),
2402 ];
2403 let resolved = config.resolve(
2404 PathBuf::from("/project"),
2405 OutputFormat::Human,
2406 1,
2407 true,
2408 true,
2409 None,
2410 );
2411
2412 assert!(
2413 resolved
2414 .ignore_unresolved_imports
2415 .iter()
2416 .any(|matcher| matcher.is_match("@example/icons"))
2417 );
2418 assert!(
2419 resolved
2420 .ignore_unresolved_imports
2421 .iter()
2422 .any(|matcher| matcher.is_match("@example/icons/metadata"))
2423 );
2424 assert!(
2425 resolved
2426 .ignore_unresolved_imports
2427 .iter()
2428 .any(|matcher| matcher.is_match("../generated/client"))
2429 );
2430 }
2431
2432 #[test]
2433 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
2434 let mut config = make_config(false);
2435 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
2436 let resolved = config.resolve(
2437 PathBuf::from("/project"),
2438 OutputFormat::Human,
2439 1,
2440 true,
2441 true,
2442 None,
2443 );
2444
2445 assert!(
2446 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
2447 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
2448 );
2449 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
2450 }
2451
2452 #[test]
2453 fn resolve_sets_cache_dir() {
2454 let resolved = make_config(false).resolve(
2455 PathBuf::from("/my/project"),
2456 OutputFormat::Human,
2457 1,
2458 true,
2459 true,
2460 None,
2461 );
2462 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
2463 }
2464
2465 #[test]
2466 fn resolve_uses_relative_configured_cache_dir_from_root() {
2467 let config = FallowConfig {
2468 cache: crate::CacheConfig {
2469 dir: Some(PathBuf::from(".cache/fallow")),
2470 ..Default::default()
2471 },
2472 ..make_config(false)
2473 };
2474 let resolved = config.resolve(
2475 PathBuf::from("/my/project"),
2476 OutputFormat::Human,
2477 1,
2478 false,
2479 true,
2480 None,
2481 );
2482 assert_eq!(
2483 resolved.cache_dir,
2484 PathBuf::from("/my/project/.cache/fallow")
2485 );
2486 }
2487
2488 #[test]
2489 fn cache_dir_override_wins_over_configured_cache_dir() {
2490 let config = FallowConfig {
2491 cache: crate::CacheConfig {
2492 dir: Some(PathBuf::from(".cache/from-config")),
2493 ..Default::default()
2494 },
2495 ..make_config(false)
2496 };
2497 let mut resolved = config.resolve(
2498 PathBuf::from("/my/project"),
2499 OutputFormat::Human,
2500 1,
2501 false,
2502 true,
2503 None,
2504 );
2505
2506 resolved.override_cache_dir(PathBuf::from(".cache/from-env"));
2507 assert_eq!(
2508 resolved.cache_dir,
2509 PathBuf::from("/my/project/.cache/from-env")
2510 );
2511 resolved.override_cache_dir(PathBuf::from("/tmp/fallow-cache"));
2512 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
2513 }
2514
2515 fn resolved_with_cache_dir(root: &str, cache_dir: &str) -> ResolvedConfig {
2516 let mut resolved = make_config(false).resolve(
2517 PathBuf::from(root),
2518 OutputFormat::Human,
2519 1,
2520 false,
2521 true,
2522 None,
2523 );
2524 resolved.override_cache_dir(PathBuf::from(cache_dir));
2525 resolved
2526 }
2527
2528 #[test]
2529 fn scoped_shared_cache_dir_gives_each_root_its_own_subdirectory() {
2530 let mut first = resolved_with_cache_dir("/work/app", "/var/cache/fallow");
2531 let mut second = resolved_with_cache_dir("/other/app", "/var/cache/fallow");
2532 first.scope_shared_cache_dir_to_root();
2533 second.scope_shared_cache_dir_to_root();
2534
2535 assert_eq!(
2536 first.cache_dir.parent(),
2537 Some(Path::new("/var/cache/fallow"))
2538 );
2539 assert_eq!(
2540 second.cache_dir.parent(),
2541 Some(Path::new("/var/cache/fallow"))
2542 );
2543 assert_ne!(
2544 first.cache_dir, second.cache_dir,
2545 "two roots with the same folder name must not share a cache"
2546 );
2547 let name = first
2548 .cache_dir
2549 .file_name()
2550 .unwrap()
2551 .to_string_lossy()
2552 .into_owned();
2553 assert!(name.starts_with("app-"), "the folder name leads: {name}");
2554 }
2555
2556 #[test]
2557 fn scoped_cache_dir_is_stable_for_one_root() {
2558 let mut first = resolved_with_cache_dir("/work/app", "/var/cache/fallow");
2559 let mut again = resolved_with_cache_dir("/work/app", "/var/cache/fallow");
2560 first.scope_shared_cache_dir_to_root();
2561 again.scope_shared_cache_dir_to_root();
2562 assert_eq!(first.cache_dir, again.cache_dir);
2563 }
2564
2565 #[test]
2566 fn scoping_keeps_a_cache_dir_inside_the_root() {
2567 let mut relative = resolved_with_cache_dir("/work/app", ".cache/fallow");
2568 relative.scope_shared_cache_dir_to_root();
2569 assert_eq!(relative.cache_dir, PathBuf::from("/work/app/.cache/fallow"));
2570
2571 let mut absolute = resolved_with_cache_dir("/work/app", "/work/app/.fallow");
2572 absolute.scope_shared_cache_dir_to_root();
2573 assert_eq!(absolute.cache_dir, PathBuf::from("/work/app/.fallow"));
2574 }
2575
2576 #[test]
2577 fn resolve_keeps_absolute_configured_cache_dir() {
2578 let config = FallowConfig {
2579 cache: crate::CacheConfig {
2580 dir: Some(PathBuf::from("/tmp/fallow-cache")),
2581 ..Default::default()
2582 },
2583 ..make_config(false)
2584 };
2585 let resolved = config.resolve(
2586 PathBuf::from("/my/project"),
2587 OutputFormat::Human,
2588 1,
2589 false,
2590 true,
2591 None,
2592 );
2593 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
2594 }
2595
2596 #[test]
2597 fn resolve_passes_through_thread_count() {
2598 let resolved = make_config(false).resolve(
2599 PathBuf::from("/project"),
2600 OutputFormat::Human,
2601 8,
2602 true,
2603 true,
2604 None,
2605 );
2606 assert_eq!(resolved.threads, 8);
2607 }
2608
2609 #[test]
2610 fn resolve_passes_through_quiet_flag() {
2611 let resolved = make_config(false).resolve(
2612 PathBuf::from("/project"),
2613 OutputFormat::Human,
2614 1,
2615 true,
2616 false,
2617 None,
2618 );
2619 assert!(!resolved.quiet);
2620
2621 let resolved2 = make_config(false).resolve(
2622 PathBuf::from("/project"),
2623 OutputFormat::Human,
2624 1,
2625 true,
2626 true,
2627 None,
2628 );
2629 assert!(resolved2.quiet);
2630 }
2631
2632 #[test]
2633 fn resolve_passes_through_no_cache_flag() {
2634 let resolved_no_cache = make_config(false).resolve(
2635 PathBuf::from("/project"),
2636 OutputFormat::Human,
2637 1,
2638 true,
2639 true,
2640 None,
2641 );
2642 assert!(resolved_no_cache.no_cache);
2643
2644 let resolved_with_cache = make_config(false).resolve(
2645 PathBuf::from("/project"),
2646 OutputFormat::Human,
2647 1,
2648 false,
2649 true,
2650 None,
2651 );
2652 assert!(!resolved_with_cache.no_cache);
2653 }
2654
2655 #[test]
2656 #[should_panic(expected = "validated at config load time")]
2657 fn resolve_panics_on_unvalidated_invalid_override_glob() {
2658 let mut config = make_config(false);
2659 config.overrides = vec![ConfigOverride {
2660 files: vec!["[invalid".to_string()],
2661 rules: PartialRulesConfig {
2662 unused_files: Some(Severity::Off),
2663 ..Default::default()
2664 },
2665 }];
2666 let _ = config.resolve(
2667 PathBuf::from("/project"),
2668 OutputFormat::Human,
2669 1,
2670 true,
2671 true,
2672 None,
2673 );
2674 }
2675
2676 #[test]
2677 fn resolve_override_with_empty_files_skipped() {
2678 let mut config = make_config(false);
2679 config.overrides = vec![ConfigOverride {
2680 files: vec![],
2681 rules: PartialRulesConfig {
2682 unused_files: Some(Severity::Off),
2683 ..Default::default()
2684 },
2685 }];
2686 let resolved = config.resolve(
2687 PathBuf::from("/project"),
2688 OutputFormat::Human,
2689 1,
2690 true,
2691 true,
2692 None,
2693 );
2694 assert!(
2695 resolved.overrides.is_empty(),
2696 "override with no file patterns should be skipped"
2697 );
2698 }
2699
2700 #[test]
2701 fn resolve_multiple_valid_overrides() {
2702 let mut config = make_config(false);
2703 config.overrides = vec![
2704 ConfigOverride {
2705 files: vec!["*.test.ts".to_string()],
2706 rules: PartialRulesConfig {
2707 unused_exports: Some(Severity::Off),
2708 ..Default::default()
2709 },
2710 },
2711 ConfigOverride {
2712 files: vec!["*.stories.tsx".to_string()],
2713 rules: PartialRulesConfig {
2714 unused_files: Some(Severity::Off),
2715 ..Default::default()
2716 },
2717 },
2718 ];
2719 let resolved = config.resolve(
2720 PathBuf::from("/project"),
2721 OutputFormat::Human,
2722 1,
2723 true,
2724 true,
2725 None,
2726 );
2727 assert_eq!(resolved.overrides.len(), 2);
2728 }
2729
2730 #[test]
2731 fn ignore_export_rule_deserialize() {
2732 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
2733 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
2734 assert_eq!(rule.file, "src/types/*.ts");
2735 assert_eq!(rule.exports, vec!["*"]);
2736 }
2737
2738 #[test]
2739 fn ignore_export_rule_specific_exports() {
2740 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
2741 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
2742 assert_eq!(rule.exports.len(), 3);
2743 assert!(rule.exports.contains(&"FOO".to_string()));
2744 }
2745
2746 mod proptests {
2747 use super::*;
2748 use proptest::prelude::*;
2749
2750 fn arb_resolved_config(production: bool) -> ResolvedConfig {
2751 make_config(production).resolve(
2752 PathBuf::from("/project"),
2753 OutputFormat::Human,
2754 1,
2755 true,
2756 true,
2757 None,
2758 )
2759 }
2760
2761 proptest! {
2762 #[test]
2764 fn resolved_config_has_default_ignores(production in any::<bool>()) {
2765 let resolved = arb_resolved_config(production);
2766 prop_assert!(
2767 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2768 "Default ignore should match node_modules"
2769 );
2770 prop_assert!(
2771 resolved.ignore_patterns.is_match("dist/bundle.js"),
2772 "Default ignore should match dist"
2773 );
2774 }
2775
2776 #[test]
2778 fn production_forces_dev_deps_off(_unused in Just(())) {
2779 let resolved = arb_resolved_config(true);
2780 prop_assert_eq!(
2781 resolved.rules.unused_dev_dependencies,
2782 Severity::Off,
2783 "Production should force unused_dev_dependencies off"
2784 );
2785 prop_assert_eq!(
2786 resolved.rules.unused_optional_dependencies,
2787 Severity::Off,
2788 "Production should force unused_optional_dependencies off"
2789 );
2790 }
2791
2792 #[test]
2794 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
2795 let resolved = arb_resolved_config(false);
2796 prop_assert_eq!(
2797 resolved.rules.unused_dev_dependencies,
2798 Severity::Warn,
2799 "Non-production should keep default dev dep severity"
2800 );
2801 }
2802
2803 #[test]
2805 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
2806 let root = PathBuf::from(format!("/project/{dir_suffix}"));
2807 let expected_cache = root.join(".fallow");
2808 let resolved = make_config(false).resolve(
2809 root,
2810 OutputFormat::Human,
2811 1,
2812 true,
2813 true,
2814 None,
2815 );
2816 prop_assert_eq!(
2817 resolved.cache_dir, expected_cache,
2818 "Default cache dir should be root/.fallow"
2819 );
2820 }
2821
2822 #[test]
2824 fn threads_passed_through(threads in 1..64usize) {
2825 let resolved = make_config(false).resolve(
2826 PathBuf::from("/project"),
2827 OutputFormat::Human,
2828 threads,
2829 true,
2830 true, None,
2831 );
2832 prop_assert_eq!(
2833 resolved.threads, threads,
2834 "Thread count should be passed through"
2835 );
2836 }
2837
2838 #[test]
2842 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2843 let mut config = make_config(false);
2844 config.ignore_patterns = vec![pattern];
2845 let resolved = config.resolve(
2846 PathBuf::from("/project"),
2847 OutputFormat::Human,
2848 1,
2849 true,
2850 true, None,
2851 );
2852 prop_assert!(
2853 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2854 "Default node_modules ignore should still be active"
2855 );
2856 }
2857 }
2858 }
2859
2860 #[test]
2861 fn resolve_expands_boundary_preset() {
2862 use crate::config::boundaries::BoundaryPreset;
2863
2864 let mut config = make_config(false);
2865 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2866 let resolved = config.resolve(
2867 PathBuf::from("/project"),
2868 OutputFormat::Human,
2869 1,
2870 true,
2871 true,
2872 None,
2873 );
2874 assert_eq!(resolved.boundaries.zones.len(), 3);
2875 assert_eq!(resolved.boundaries.rules.len(), 3);
2876 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2877 assert_eq!(
2878 resolved.boundaries.classify_zone("src/adapters/http.ts"),
2879 Some("adapters")
2880 );
2881 }
2882
2883 #[test]
2884 fn resolve_boundary_preset_with_user_override() {
2885 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2886
2887 let mut config = make_config(false);
2888 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2889 config.boundaries.zones = vec![BoundaryZone {
2890 name: "domain".to_string(),
2891 patterns: vec!["src/core/**".to_string()],
2892 auto_discover: vec![],
2893 root: None,
2894 }];
2895 let resolved = config.resolve(
2896 PathBuf::from("/project"),
2897 OutputFormat::Human,
2898 1,
2899 true,
2900 true,
2901 None,
2902 );
2903 assert_eq!(resolved.boundaries.zones.len(), 3);
2904 assert_eq!(
2905 resolved.boundaries.classify_zone("src/core/user.ts"),
2906 Some("domain")
2907 );
2908 assert_eq!(
2909 resolved.boundaries.classify_zone("src/domain/user.ts"),
2910 None
2911 );
2912 }
2913
2914 #[test]
2915 fn resolve_no_preset_unchanged() {
2916 let config = make_config(false);
2917 let resolved = config.resolve(
2918 PathBuf::from("/project"),
2919 OutputFormat::Human,
2920 1,
2921 true,
2922 true,
2923 None,
2924 );
2925 assert!(resolved.boundaries.is_empty());
2926 }
2927}