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