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 markup_extensions: Vec<String>,
336 pub overrides: Vec<ResolvedOverride>,
338 pub workspace_changed_since: BTreeMap<String, String>,
341 pub workspace_patterns: Vec<String>,
344 pub regression: Option<super::RegressionConfig>,
346 pub audit: super::AuditConfig,
348 pub codeowners: Option<String>,
351 pub public_packages: Vec<String>,
355 pub flags: FlagsConfig,
357 pub security: SecurityConfig,
360 pub fix: super::FixConfig,
362 pub resolve: ResolveConfig,
365 pub include_entry_exports: bool,
368 pub show_cascade: bool,
371 pub auto_imports: bool,
375 pub fail_on_parse_error: bool,
379 pub max_file_size_bytes: Option<u64>,
388 pub analysis_snapshot: AnalysisSnapshot,
393 pub detection_config_digest: String,
401 pub legacy_glob_groups: Vec<super::LegacyGlobGroup>,
405}
406
407const NON_DETECTION_CONFIG_KEYS: &[&str] = &[
411 "minimumVersion",
412 "duplicates",
413 "similarCode",
414 "mutation",
415 "health",
416 "markupExtensions",
417 "security",
418 "fix",
419 "codeowners",
420 "regression",
421 "audit",
422 "failOnParseError",
423 "cache",
424];
425
426fn canonical_json(value: serde_json::Value) -> serde_json::Value {
429 match value {
430 serde_json::Value::Object(map) => {
431 let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
432 entries.sort_by(|a, b| a.0.cmp(&b.0));
433 serde_json::Value::Object(
434 entries
435 .into_iter()
436 .map(|(key, value)| (key, canonical_json(value)))
437 .collect(),
438 )
439 }
440 serde_json::Value::Array(items) => {
441 serde_json::Value::Array(items.into_iter().map(canonical_json).collect())
442 }
443 other => other,
444 }
445}
446
447fn canonical_json_string<T: Serialize>(value: &T) -> String {
448 serde_json::to_value(value)
449 .map(canonical_json)
450 .map(|value| value.to_string())
451 .unwrap_or_default()
452}
453
454fn detection_config_json(config: &FallowConfig) -> String {
456 let mut value = serde_json::to_value(config).unwrap_or_default();
457 if let serde_json::Value::Object(map) = &mut value {
458 for key in NON_DETECTION_CONFIG_KEYS {
459 map.shift_remove(*key);
460 }
461 }
462 canonical_json(value).to_string()
463}
464
465pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
469
470pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
472
473#[must_use]
478pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
479 match max_file_size_mb {
480 None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
481 Some(0) => None,
482 Some(mb) => Some(u64::from(mb) * 1024 * 1024),
483 }
484}
485
486#[must_use]
496pub fn cache_config_hash(external_plugins: &[ExternalPluginDef], flags: &FlagsConfig) -> u64 {
497 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
498 names.sort_unstable();
499 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
500 for name in names {
501 hash_str(&mut hasher, name);
502 }
503 let patterns = flags.patterns();
504 if !patterns.is_builtin_only() {
505 hasher.update(b"\0flags");
506 hasher.update(&(patterns.sdk_patterns.len() as u64).to_le_bytes());
507 for (function, name_arg, provider) in &patterns.sdk_patterns {
508 hash_str(&mut hasher, function);
509 hasher.update(&(*name_arg as u64).to_le_bytes());
510 hash_str(&mut hasher, provider);
511 }
512 hasher.update(&(patterns.env_prefixes.len() as u64).to_le_bytes());
513 for prefix in &patterns.env_prefixes {
514 hash_str(&mut hasher, prefix);
515 }
516 hasher.update(&[u8::from(patterns.config_object_heuristics)]);
517 }
518 hasher.digest()
519}
520
521fn hash_str(hasher: &mut xxhash_rust::xxh3::Xxh3, value: &str) {
522 hasher.update(&(value.len() as u32).to_le_bytes());
523 hasher.update(value.as_bytes());
524}
525
526pub const CACHE_DIR_ENV: &str = "FALLOW_CACHE_DIR";
528
529#[must_use]
535pub fn cache_dir_env_override() -> Option<PathBuf> {
536 std::env::var_os(CACHE_DIR_ENV).and_then(cache_dir_from_env_value)
537}
538
539#[must_use]
541pub fn cache_dir_from_env_value(raw: OsString) -> Option<PathBuf> {
542 Some(PathBuf::from(raw)).filter(|path| !path.as_os_str().is_empty())
543}
544
545pub const CACHE_MAX_SIZE_ENV: &str = "FALLOW_CACHE_MAX_SIZE";
549
550#[must_use]
553pub fn cache_max_size_env_override() -> Option<u32> {
554 std::env::var_os(CACHE_MAX_SIZE_ENV)
555 .as_deref()
556 .and_then(cache_max_size_from_env_value)
557}
558
559#[must_use]
562pub fn cache_max_size_from_env_value(raw: &OsStr) -> Option<u32> {
563 raw.to_str()?
564 .trim()
565 .parse::<u32>()
566 .ok()
567 .filter(|mb| *mb > 0)
568}
569
570pub const PACKAGE_BASELINES_ENV: &str = "FALLOW_PACKAGE_BASELINES";
574
575#[must_use]
579pub fn package_baselines_disabled_by_env_value(raw: &OsStr) -> bool {
580 raw.to_str().is_some_and(|value| {
581 matches!(
582 value.trim().to_ascii_lowercase().as_str(),
583 "false" | "0" | "no" | "off"
584 )
585 })
586}
587
588fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
589 let Some(dir) = configured else {
590 return root.join(".fallow");
591 };
592 if dir.is_absolute() {
593 dir
594 } else {
595 root.join(dir)
596 }
597}
598
599fn cache_dir_is_inside_root(root: &Path, cache_dir: &Path) -> bool {
600 if cache_dir.starts_with(root) {
601 return true;
602 }
603 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
604 let canonical_cache =
605 dunce::canonicalize(cache_dir).unwrap_or_else(|_| cache_dir.to_path_buf());
606 canonical_cache.starts_with(&canonical_root)
607}
608
609fn per_root_cache_subdir(root: &Path) -> String {
613 let path = root.to_string_lossy().replace('\\', "/");
614 let hash = xxhash_rust::xxh3::xxh3_64(path.as_bytes());
615 let name: String = root
616 .file_name()
617 .map(|name| name.to_string_lossy())
618 .unwrap_or_default()
619 .chars()
620 .map(|c| {
621 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
622 c
623 } else {
624 '_'
625 }
626 })
627 .collect();
628 if name.is_empty() {
629 format!("{hash:016x}")
630 } else {
631 format!("{name}-{hash:016x}")
632 }
633}
634
635pub(super) fn normalize_user_glob_pattern(pattern: &str) -> &str {
636 pattern.strip_prefix("./").unwrap_or(pattern)
637}
638
639pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
654 "**/node_modules/**",
655 "**/dist/**",
656 "**/build/**",
657 "**/.git/**",
658 "**/coverage/**",
659 "**/*.min.js",
660 "**/*.min.mjs",
661 "**/*.min.cjs",
662 "**/*.bundle.js",
663];
664
665#[expect(
666 clippy::expect_used,
667 reason = "user glob patterns are validated before config resolution"
668)]
669fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
670 patterns
671 .iter()
672 .map(|pattern| {
673 let normalized = normalize_user_glob_pattern(pattern);
674 Glob::new(normalized)
675 .expect("ignoreUnresolvedImports entry was validated at config load time")
676 .compile_matcher()
677 })
678 .collect()
679}
680
681fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
682 if production {
683 rules.unused_dev_dependencies = Severity::Off;
684 rules.unused_optional_dependencies = Severity::Off;
685 }
686 rules
687}
688
689fn resolve_boundaries(
690 mut boundaries: super::boundaries::BoundaryConfig,
691 root: &Path,
692) -> ResolvedBoundaryConfig {
693 expand_boundary_preset(&mut boundaries, root);
694 let logical_groups = boundaries.expand_auto_discover(root);
695 let mut resolved = boundaries.resolve();
696 resolved.logical_groups = logical_groups;
697 resolved
698}
699
700pub(super) fn expand_boundary_preset(
703 boundaries: &mut super::boundaries::BoundaryConfig,
704 root: &Path,
705) {
706 if boundaries.preset.is_some() {
707 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
708 .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
709 .unwrap_or_else(|| "src".to_owned());
710 if source_root != "src" {
711 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
712 }
713 boundaries.expand(&source_root);
714 }
715}
716
717fn ineffective_inter_file_override_rules(rules: &PartialRulesConfig) -> Vec<&'static str> {
723 let mut names = Vec::new();
724 if rules.duplicate_exports.is_some() {
725 names.push("duplicate-exports");
726 }
727 if rules.re_export_cycle.is_some() {
728 names.push("re-export-cycle");
729 }
730 names
731}
732
733fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
734 let ineffective = ineffective_inter_file_override_rules(rules);
735 if ineffective.contains(&"duplicate-exports")
736 && record_inter_file_warn_seen("duplicate-exports", files)
737 {
738 let files = files.join(", ");
739 tracing::warn!(
740 "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."
741 );
742 }
743 if ineffective.contains(&"re-export-cycle")
744 && record_inter_file_warn_seen("re-export-cycle", files)
745 {
746 let files = files.join(", ");
747 tracing::warn!(
748 "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."
749 );
750 }
751}
752
753#[expect(
754 clippy::expect_used,
755 reason = "override glob patterns are validated before config resolution"
756)]
757fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
758 overrides
759 .into_iter()
760 .filter_map(|override_entry| {
761 warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
762 let matchers: Vec<globset::GlobMatcher> = override_entry
763 .files
764 .iter()
765 .map(|pattern| {
766 compile_path_glob(pattern)
767 .expect("overrides[].files pattern was validated at config load time")
768 .compile_matcher()
769 })
770 .collect();
771 if matchers.is_empty() {
772 None
773 } else {
774 Some(ResolvedOverride {
775 matchers,
776 rules: override_entry.rules,
777 })
778 }
779 })
780 .collect()
781}
782
783#[expect(
785 clippy::expect_used,
786 reason = "user glob patterns are validated before config resolution"
787)]
788fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
789 rules
790 .iter()
791 .map(|rule| CompiledIgnoreExportRule {
792 matcher: compile_path_glob(&rule.file)
793 .expect("ignoreExports[].file was validated at config load time")
794 .compile_matcher(),
795 exports: rule.exports.clone(),
796 })
797 .collect()
798}
799
800#[expect(
802 clippy::expect_used,
803 reason = "user glob patterns are validated before config resolution"
804)]
805fn compile_ignore_catalog_reference_rules(
806 rules: &[IgnoreCatalogReferenceRule],
807) -> Vec<CompiledIgnoreCatalogReferenceRule> {
808 rules
809 .iter()
810 .map(|rule| CompiledIgnoreCatalogReferenceRule {
811 package: rule.package.clone(),
812 catalog: rule.catalog.clone(),
813 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
814 compile_path_glob(pattern)
815 .expect("ignoreCatalogReferences[].consumer was validated at config load time")
816 .compile_matcher()
817 }),
818 })
819 .collect()
820}
821
822fn compile_ignore_dependency_override_rules(
824 rules: &[IgnoreDependencyOverrideRule],
825) -> Vec<CompiledIgnoreDependencyOverrideRule> {
826 rules
827 .iter()
828 .map(|rule| CompiledIgnoreDependencyOverrideRule {
829 package: rule.package.clone(),
830 source: rule.source.clone(),
831 })
832 .collect()
833}
834
835struct CompiledIgnoreSettings {
836 patterns: IgnorePatternSet,
837 user_pattern_count: usize,
838 findings: FindingIgnoreMatcher,
839 unresolved_imports: Vec<GlobMatcher>,
840 exports: Vec<CompiledIgnoreExportRule>,
841 catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
842 dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
843}
844
845fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
846 let (patterns, user_pattern_count) = compile_ignore_patterns(&config.ignore_patterns);
847 CompiledIgnoreSettings {
848 patterns,
849 user_pattern_count,
850 findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
851 unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
852 exports: compile_ignore_export_rules(&config.ignore_exports),
853 catalog_references: compile_ignore_catalog_reference_rules(
854 &config.ignore_catalog_references,
855 ),
856 dependency_overrides: compile_ignore_dependency_override_rules(
857 &config.ignore_dependency_overrides,
858 ),
859 }
860}
861
862struct ResolvedPluginSettings {
863 external_plugins: Vec<ExternalPluginDef>,
864 rule_packs: Vec<crate::rule_pack::RulePackDef>,
865 rule_pack_sources: Vec<PathBuf>,
866}
867
868fn resolve_plugin_settings(
869 root: &Path,
870 configured_plugins: &[String],
871 framework: Vec<ExternalPluginDef>,
872 rule_packs: &[String],
873) -> ResolvedPluginSettings {
874 let mut external_plugins = discover_external_plugins(root, configured_plugins);
875 external_plugins.extend(framework);
876
877 let configured_rule_packs = rule_packs;
878 let rule_packs =
879 crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
880 for error in &errors {
881 tracing::error!("invalid rule pack: {error}");
882 }
883 Vec::new()
884 });
885 let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
886 configured_rule_packs.iter().map(PathBuf::from).collect()
887 } else {
888 Vec::new()
889 };
890
891 ResolvedPluginSettings {
892 external_plugins,
893 rule_packs,
894 rule_pack_sources,
895 }
896}
897
898struct ResolvedCacheSettings {
899 dir: PathBuf,
900 max_size_mb: Option<u32>,
901 config_hash: u64,
902}
903
904struct ResolvedProductionRules {
905 production: bool,
906 rules: RulesConfig,
907}
908
909fn resolve_production_rules(
910 production_config: ProductionConfig,
911 rules: RulesConfig,
912) -> ResolvedProductionRules {
913 let production = production_config.global();
914 ResolvedProductionRules {
915 production,
916 rules: resolve_rules_for_production(rules, production),
917 }
918}
919
920fn resolve_cache_settings(
921 root: &Path,
922 configured_dir: Option<PathBuf>,
923 configured_max_size_mb: Option<u32>,
924 override_max_size_mb: Option<u32>,
925 no_cache: bool,
926 config_hash: impl FnOnce() -> u64,
927) -> ResolvedCacheSettings {
928 ResolvedCacheSettings {
929 dir: resolve_cache_dir(root, configured_dir),
930 max_size_mb: override_max_size_mb.or(configured_max_size_mb),
931 config_hash: if no_cache { 0 } else { config_hash() },
932 }
933}
934
935fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
936 SecurityConfig {
937 request_receivers: security.normalized_request_receivers(),
938 ..security
939 }
940}
941
942struct ResolvedPathPolicySettings {
943 boundaries: ResolvedBoundaryConfig,
944 overrides: Vec<ResolvedOverride>,
945}
946
947fn resolve_path_policy_settings(
948 boundaries: BoundaryConfig,
949 overrides: Vec<ConfigOverride>,
950 root: &Path,
951) -> ResolvedPathPolicySettings {
952 ResolvedPathPolicySettings {
953 boundaries: resolve_boundaries(boundaries, root),
954 overrides: compile_overrides(overrides),
955 }
956}
957
958fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
959 pattern.and_then(|pattern| match regex::Regex::new(pattern) {
960 Ok(re) => Some(re),
961 Err(error) => {
962 tracing::warn!(
963 %error,
964 "ignoring invalid unusedComponentProps.ignorePattern; this config was \
965 not validated through FallowConfig::load"
966 );
967 None
968 }
969 })
970}
971
972impl FallowConfig {
973 #[expect(
975 clippy::too_many_arguments,
976 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"
977 )]
978 pub fn resolve(
979 self,
980 root: PathBuf,
981 output: OutputFormat,
982 threads: usize,
983 no_cache: bool,
984 quiet: bool,
985 cache_max_size_mb: Option<u32>,
986 ) -> ResolvedConfig {
987 let mut legacy_glob_groups = super::legacy_glob::collect_config_legacy_glob_groups(&self);
988 let compiled_ignores = compile_ignore_settings(&self);
989 let config_json = detection_config_json(&self);
990
991 let production_rules = resolve_production_rules(self.production, self.rules);
992
993 let plugins =
994 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
995
996 let cache = resolve_cache_settings(
997 &root,
998 self.cache.dir,
999 self.cache.max_size_mb,
1000 cache_max_size_mb,
1001 no_cache,
1002 || cache_config_hash(&plugins.external_plugins, &self.flags),
1003 );
1004
1005 let detection_config_digest = fallow_types::identity::fnv1a64_parts(&[
1006 "config",
1007 &config_json,
1008 &canonical_json_string(&{
1009 let mut sorted: Vec<&ExternalPluginDef> = plugins.external_plugins.iter().collect();
1010 sorted.sort_by(|a, b| a.name.cmp(&b.name));
1011 sorted
1012 }),
1013 &canonical_json_string(&plugins.rule_packs),
1014 ]);
1015
1016 super::legacy_glob::extend_legacy_glob_groups(
1017 &mut legacy_glob_groups,
1018 &plugins.external_plugins,
1019 &plugins.rule_packs,
1020 );
1021
1022 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
1023 let (workspace_patterns, workspace_changed_since) = self
1024 .workspaces
1025 .map(|workspaces| (workspaces.patterns, workspaces.changed_since))
1026 .unwrap_or_default();
1027
1028 let unused_component_props_ignore = compile_unused_component_props_ignore(
1029 self.unused_component_props.ignore_pattern.as_deref(),
1030 );
1031
1032 ResolvedConfig {
1033 root,
1034 entry_patterns: self.entry,
1035 ignore_patterns: compiled_ignores.patterns,
1036 user_ignore_pattern_count: compiled_ignores.user_pattern_count,
1037 entry_pattern_effect: EntryEffectRecord::default(),
1038 ignore_findings: compiled_ignores.findings,
1039 output,
1040 cache_dir: cache.dir,
1041 threads,
1042 no_cache,
1043 cache_max_size_mb: cache.max_size_mb,
1044 cache_config_hash: cache.config_hash,
1045 ignore_dependencies: IgnoreDependencyMatcher::compile(&self.ignore_dependencies),
1046 ignore_command_entries: self.ignore_command_entries,
1047 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
1048 ignore_export_rules: self.ignore_exports,
1049 compiled_ignore_exports: compiled_ignores.exports,
1050 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
1051 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
1052 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
1053 used_class_members: self.used_class_members,
1054 ignore_decorators: self.ignore_decorators,
1055 unused_component_props_ignore,
1056 circular_dependencies: self.circular_dependencies,
1057 duplicates: self.duplicates,
1058 similar_code: self.similar_code,
1059 mutation: self.mutation,
1060 health: self.health,
1061 type_aware: self.type_aware,
1062 rules: production_rules.rules,
1063 boundaries: path_policy.boundaries,
1064 rule_packs: plugins.rule_packs,
1065 rule_pack_sources: plugins.rule_pack_sources,
1066 production: production_rules.production,
1067 quiet,
1068 external_plugins: plugins.external_plugins,
1069 dynamically_loaded: self.dynamically_loaded,
1070 markup_extensions: super::resolve_markup_extensions(&self.markup_extensions),
1071 overrides: path_policy.overrides,
1072 workspace_changed_since,
1073 workspace_patterns,
1074 regression: self.regression,
1075 audit: self.audit,
1076 codeowners: self.codeowners,
1077 public_packages: self.public_packages,
1078 flags: self.flags,
1079 security: normalize_security_config(self.security),
1080 fix: self.fix,
1081 resolve: self.resolve,
1082 include_entry_exports: self.include_entry_exports,
1083 show_cascade: self.show_cascade,
1084 auto_imports: self.auto_imports,
1085 fail_on_parse_error: self.fail_on_parse_error,
1086 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
1087 analysis_snapshot: AnalysisSnapshot::Current,
1088 detection_config_digest,
1089 legacy_glob_groups,
1090 }
1091 }
1092}
1093
1094impl ResolvedConfig {
1095 pub fn apply_package_baselines_env(&mut self, raw: Option<&OsStr>) {
1098 if raw.is_some_and(package_baselines_disabled_by_env_value) {
1099 self.workspace_changed_since.clear();
1100 }
1101 }
1102
1103 pub fn override_cache_dir(&mut self, dir: PathBuf) {
1107 self.cache_dir = resolve_cache_dir(&self.root, Some(dir));
1108 }
1109
1110 pub fn scope_shared_cache_dir_to_root(&mut self) {
1121 if cache_dir_is_inside_root(&self.root, &self.cache_dir) {
1122 return;
1123 }
1124 self.cache_dir = self.cache_dir.join(per_root_cache_subdir(&self.root));
1125 }
1126
1127 #[must_use]
1130 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
1131 if self.overrides.is_empty() {
1132 return self.rules.clone();
1133 }
1134
1135 let relative = path.strip_prefix(&self.root).unwrap_or(path);
1136 let relative_str = relative.to_string_lossy();
1137
1138 let mut rules = self.rules.clone();
1139 for override_entry in &self.overrides {
1140 let matches = override_entry
1141 .matchers
1142 .iter()
1143 .any(|m| m.is_match(relative_str.as_ref()));
1144 if matches {
1145 rules.apply_partial(&override_entry.rules);
1146 }
1147 }
1148 rules
1149 }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155 use crate::CacheConfig;
1156 use crate::config::boundaries::BoundaryConfig;
1157 use crate::config::health::HealthConfig;
1158
1159 #[test]
1160 fn mutation_section_reaches_resolved_config_and_leaves_the_detection_digest() {
1161 let resolve = |json: &str| {
1162 let authored: FallowConfig = serde_json::from_str(json).expect("config deserializes");
1163 authored.resolve(
1164 PathBuf::from("/project"),
1165 OutputFormat::Json,
1166 1,
1167 true,
1168 true,
1169 None,
1170 )
1171 };
1172 let plain = resolve("{}");
1173 let with_mutation = resolve(r#"{"mutation":{"minScore":75,"operators":["removed-call"]}}"#);
1174 assert_eq!(with_mutation.mutation.min_score, Some(75.0));
1175 assert_eq!(
1176 with_mutation.mutation.operators,
1177 Some(vec![crate::MutationOperatorName::RemovedCall])
1178 );
1179 assert_eq!(
1180 plain.detection_config_digest,
1181 with_mutation.detection_config_digest
1182 );
1183 let detecting = resolve(r#"{"ignorePatterns":["gen/**"]}"#);
1184 assert_ne!(
1185 plain.detection_config_digest,
1186 detecting.detection_config_digest
1187 );
1188 }
1189
1190 #[test]
1193 fn single_star_stays_within_one_segment_in_config_path_globs() {
1194 let authored: FallowConfig = serde_json::from_str(
1195 r#"{
1196 "ignorePatterns": ["gen/*.ts"],
1197 "ignoreFindings": ["legacy/*.ts"],
1198 "overrides": [{ "files": ["old/*.ts"], "rules": { "unused-files": "off" } }],
1199 "ignoreExports": [{ "file": "api/*.ts", "exports": ["*"] }],
1200 "ignoreCatalogReferences": [{ "package": "react", "consumer": "apps/*/package.json" }],
1201 "boundaries": {
1202 "zones": [{ "name": "ui", "patterns": ["ui/*.ts"] }],
1203 "coverage": { "allowUnmatched": ["scripts/*.ts"] }
1204 }
1205 }"#,
1206 )
1207 .expect("config deserializes");
1208 let resolved = authored.resolve(
1209 PathBuf::from("/project"),
1210 OutputFormat::Json,
1211 1,
1212 true,
1213 true,
1214 None,
1215 );
1216
1217 assert!(resolved.ignore_patterns.is_match("gen/a.ts"));
1218 assert!(!resolved.ignore_patterns.is_match("gen/deep/a.ts"));
1219
1220 assert!(resolved.ignore_findings.is_ignored("legacy/a.ts"));
1221 assert!(!resolved.ignore_findings.is_ignored("legacy/deep/a.ts"));
1222
1223 let off = |path: &str| {
1224 resolved
1225 .resolve_rules_for_path(Path::new(path))
1226 .unused_files
1227 == Severity::Off
1228 };
1229 assert!(off("/project/old/a.ts"));
1230 assert!(!off("/project/old/deep/a.ts"));
1231
1232 let export_rule = &resolved.compiled_ignore_exports[0].matcher;
1233 assert!(export_rule.is_match("api/a.ts"));
1234 assert!(!export_rule.is_match("api/deep/a.ts"));
1235
1236 let catalog_rule = &resolved.compiled_ignore_catalog_references[0];
1237 assert!(catalog_rule.matches("react", "default", "apps/web/package.json"));
1238 assert!(!catalog_rule.matches("react", "default", "apps/web/nested/package.json"));
1239
1240 assert_eq!(resolved.boundaries.classify_zone("ui/a.ts"), Some("ui"));
1241 assert_eq!(resolved.boundaries.classify_zone("ui/deep/a.ts"), None);
1242
1243 let allow_unmatched = &resolved.boundaries.coverage.allow_unmatched[0];
1244 assert!(allow_unmatched.is_match("scripts/a.ts"));
1245 assert!(!allow_unmatched.is_match("scripts/deep/a.ts"));
1246 }
1247
1248 #[test]
1249 fn workspace_patterns_reach_resolved_config() {
1250 let authored: FallowConfig =
1251 serde_json::from_str(r#"{"workspaces":{"patterns":["apps/*/*"]}}"#)
1252 .expect("workspace patterns deserialize");
1253 let resolved = authored.resolve(
1254 PathBuf::from("/project"),
1255 OutputFormat::Json,
1256 1,
1257 true,
1258 true,
1259 None,
1260 );
1261
1262 assert_eq!(resolved.workspace_patterns, vec!["apps/*/*".to_string()]);
1263 }
1264
1265 #[test]
1266 fn workspace_changed_since_reaches_resolved_config() {
1267 let authored: FallowConfig = serde_json::from_str(
1268 r#"{"workspaces":{"changedSince":{"packages/web":"main","packages/legacy":"release/2024.10"}}}"#,
1269 )
1270 .expect("workspace baselines deserialize");
1271 let resolved = authored.resolve(
1272 PathBuf::from("/project"),
1273 OutputFormat::Json,
1274 1,
1275 true,
1276 true,
1277 None,
1278 );
1279
1280 assert_eq!(resolved.workspace_changed_since.len(), 2);
1281 assert_eq!(resolved.workspace_changed_since["packages/web"], "main");
1282 assert_eq!(
1283 resolved.workspace_changed_since["packages/legacy"],
1284 "release/2024.10"
1285 );
1286 }
1287
1288 #[test]
1289 fn cache_config_hash_keys_on_user_flag_patterns() {
1290 let builtin = cache_config_hash(&[], &FlagsConfig::default());
1291 let with_pattern = |function: &str| FlagsConfig {
1292 sdk_patterns: vec![super::super::flags::SdkPattern {
1293 function: function.to_string(),
1294 name_arg: 0,
1295 provider: None,
1296 }],
1297 ..FlagsConfig::default()
1298 };
1299
1300 assert_eq!(
1301 builtin,
1302 xxhash_rust::xxh3::Xxh3::new().digest(),
1303 "a config without flag patterns keeps the plugin-only hash"
1304 );
1305 assert_ne!(builtin, cache_config_hash(&[], &with_pattern("isOn")));
1306 assert_ne!(
1307 cache_config_hash(&[], &with_pattern("isOn")),
1308 cache_config_hash(&[], &with_pattern("isOff"))
1309 );
1310 assert_ne!(
1311 builtin,
1312 cache_config_hash(
1313 &[],
1314 &FlagsConfig {
1315 config_object_heuristics: true,
1316 ..FlagsConfig::default()
1317 }
1318 )
1319 );
1320 assert_ne!(
1321 builtin,
1322 cache_config_hash(
1323 &[],
1324 &FlagsConfig {
1325 env_prefixes: vec!["MYAPP_".to_string()],
1326 ..FlagsConfig::default()
1327 }
1328 )
1329 );
1330 }
1331
1332 #[test]
1333 fn overrides_deserialize() {
1334 let json_str = r#"{
1335 "overrides": [{
1336 "files": ["*.test.ts"],
1337 "rules": {
1338 "unused-exports": "off"
1339 }
1340 }]
1341 }"#;
1342 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1343 assert_eq!(config.overrides.len(), 1);
1344 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
1345 assert_eq!(
1346 config.overrides[0].rules.unused_exports,
1347 Some(Severity::Off)
1348 );
1349 assert_eq!(config.overrides[0].rules.unused_files, None);
1350 }
1351
1352 #[test]
1353 fn resolve_rules_for_path_no_overrides() {
1354 let config = FallowConfig {
1355 schema: None,
1356 minimum_version: None,
1357 extends: vec![],
1358 entry: vec![],
1359 ignore_patterns: vec![],
1360 ignore_findings: vec![],
1361 framework: vec![],
1362 workspaces: None,
1363 ignore_dependencies: vec![],
1364 ignore_command_entries: vec![],
1365 ignore_unresolved_imports: vec![],
1366 ignore_exports: vec![],
1367 ignore_catalog_references: vec![],
1368 ignore_dependency_overrides: vec![],
1369 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1370 used_class_members: vec![],
1371 ignore_decorators: vec![],
1372 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1373 circular_dependencies: crate::CircularDependenciesConfig::default(),
1374 duplicates: DuplicatesConfig::default(),
1375 similar_code: SimilarCodeConfig::default(),
1376 mutation: MutationConfig::default(),
1377 health: HealthConfig::default(),
1378 rules: RulesConfig::default(),
1379 boundaries: BoundaryConfig::default(),
1380 production: false.into(),
1381 plugins: vec![],
1382 overrides: vec![],
1383 regression: None,
1384 type_aware: crate::TypeAwareConfig::default(),
1385 audit: crate::config::AuditConfig::default(),
1386 codeowners: None,
1387 public_packages: vec![],
1388 flags: FlagsConfig::default(),
1389 security: SecurityConfig::default(),
1390 fix: crate::config::FixConfig::default(),
1391 resolve: ResolveConfig::default(),
1392 sealed: false,
1393 include_entry_exports: false,
1394 show_cascade: false,
1395 auto_imports: false,
1396 fail_on_parse_error: false,
1397 cache: CacheConfig::default(),
1398 ..Default::default()
1399 };
1400 let resolved = config.resolve(
1401 PathBuf::from("/project"),
1402 OutputFormat::Human,
1403 1,
1404 true,
1405 true,
1406 None,
1407 );
1408 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1409 assert_eq!(rules.unused_files, Severity::Error);
1410 }
1411
1412 #[test]
1413 fn resolve_rules_for_path_with_matching_override() {
1414 let config = FallowConfig {
1415 schema: None,
1416 minimum_version: None,
1417 extends: vec![],
1418 entry: vec![],
1419 ignore_patterns: vec![],
1420 ignore_findings: vec![],
1421 framework: vec![],
1422 workspaces: None,
1423 ignore_dependencies: vec![],
1424 ignore_command_entries: vec![],
1425 ignore_unresolved_imports: vec![],
1426 ignore_exports: vec![],
1427 ignore_catalog_references: vec![],
1428 ignore_dependency_overrides: vec![],
1429 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1430 used_class_members: vec![],
1431 ignore_decorators: vec![],
1432 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1433 circular_dependencies: crate::CircularDependenciesConfig::default(),
1434 duplicates: DuplicatesConfig::default(),
1435 similar_code: SimilarCodeConfig::default(),
1436 mutation: MutationConfig::default(),
1437 health: HealthConfig::default(),
1438 rules: RulesConfig::default(),
1439 boundaries: BoundaryConfig::default(),
1440 production: false.into(),
1441 plugins: vec![],
1442 overrides: vec![ConfigOverride {
1443 files: vec!["**/*.test.ts".to_string()],
1444 rules: PartialRulesConfig {
1445 unused_exports: Some(Severity::Off),
1446 ..Default::default()
1447 },
1448 }],
1449 regression: None,
1450 type_aware: crate::TypeAwareConfig::default(),
1451 audit: crate::config::AuditConfig::default(),
1452 codeowners: None,
1453 public_packages: vec![],
1454 flags: FlagsConfig::default(),
1455 security: SecurityConfig::default(),
1456 fix: crate::config::FixConfig::default(),
1457 resolve: ResolveConfig::default(),
1458 sealed: false,
1459 include_entry_exports: false,
1460 show_cascade: false,
1461 auto_imports: false,
1462 fail_on_parse_error: false,
1463 cache: CacheConfig::default(),
1464 ..Default::default()
1465 };
1466 let resolved = config.resolve(
1467 PathBuf::from("/project"),
1468 OutputFormat::Human,
1469 1,
1470 true,
1471 true,
1472 None,
1473 );
1474
1475 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1476 assert_eq!(test_rules.unused_exports, Severity::Off);
1477 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1480 assert_eq!(src_rules.unused_exports, Severity::Error);
1481 }
1482
1483 #[test]
1484 fn resolve_rules_for_path_later_override_wins() {
1485 let config = FallowConfig {
1486 schema: None,
1487 minimum_version: None,
1488 extends: vec![],
1489 entry: vec![],
1490 ignore_patterns: vec![],
1491 ignore_findings: vec![],
1492 framework: vec![],
1493 workspaces: None,
1494 ignore_dependencies: vec![],
1495 ignore_command_entries: vec![],
1496 ignore_unresolved_imports: vec![],
1497 ignore_exports: vec![],
1498 ignore_catalog_references: vec![],
1499 ignore_dependency_overrides: vec![],
1500 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1501 used_class_members: vec![],
1502 ignore_decorators: vec![],
1503 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1504 circular_dependencies: crate::CircularDependenciesConfig::default(),
1505 duplicates: DuplicatesConfig::default(),
1506 similar_code: SimilarCodeConfig::default(),
1507 mutation: MutationConfig::default(),
1508 health: HealthConfig::default(),
1509 rules: RulesConfig::default(),
1510 boundaries: BoundaryConfig::default(),
1511 production: false.into(),
1512 plugins: vec![],
1513 overrides: vec![
1514 ConfigOverride {
1515 files: vec!["*.ts".to_string()],
1516 rules: PartialRulesConfig {
1517 unused_files: Some(Severity::Warn),
1518 ..Default::default()
1519 },
1520 },
1521 ConfigOverride {
1522 files: vec!["*.test.ts".to_string()],
1523 rules: PartialRulesConfig {
1524 unused_files: Some(Severity::Off),
1525 ..Default::default()
1526 },
1527 },
1528 ],
1529 regression: None,
1530 type_aware: crate::TypeAwareConfig::default(),
1531 audit: crate::config::AuditConfig::default(),
1532 codeowners: None,
1533 public_packages: vec![],
1534 flags: FlagsConfig::default(),
1535 security: SecurityConfig::default(),
1536 fix: crate::config::FixConfig::default(),
1537 resolve: ResolveConfig::default(),
1538 sealed: false,
1539 include_entry_exports: false,
1540 show_cascade: false,
1541 auto_imports: false,
1542 fail_on_parse_error: false,
1543 cache: CacheConfig::default(),
1544 ..Default::default()
1545 };
1546 let resolved = config.resolve(
1547 PathBuf::from("/project"),
1548 OutputFormat::Human,
1549 1,
1550 true,
1551 true,
1552 None,
1553 );
1554
1555 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1556 assert_eq!(rules.unused_files, Severity::Off);
1557
1558 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1559 assert_eq!(rules2.unused_files, Severity::Warn);
1560 }
1561
1562 #[test]
1563 fn resolve_keeps_inter_file_rule_override_after_warning() {
1564 let config = FallowConfig {
1565 schema: None,
1566 minimum_version: None,
1567 extends: vec![],
1568 entry: vec![],
1569 ignore_patterns: vec![],
1570 ignore_findings: vec![],
1571 framework: vec![],
1572 workspaces: None,
1573 ignore_dependencies: vec![],
1574 ignore_command_entries: vec![],
1575 ignore_unresolved_imports: vec![],
1576 ignore_exports: vec![],
1577 ignore_catalog_references: vec![],
1578 ignore_dependency_overrides: vec![],
1579 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1580 used_class_members: vec![],
1581 ignore_decorators: vec![],
1582 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1583 circular_dependencies: crate::CircularDependenciesConfig::default(),
1584 duplicates: DuplicatesConfig::default(),
1585 similar_code: SimilarCodeConfig::default(),
1586 mutation: MutationConfig::default(),
1587 health: HealthConfig::default(),
1588 rules: RulesConfig::default(),
1589 boundaries: BoundaryConfig::default(),
1590 production: false.into(),
1591 plugins: vec![],
1592 overrides: vec![ConfigOverride {
1593 files: vec!["**/ui/**".to_string()],
1594 rules: PartialRulesConfig {
1595 duplicate_exports: Some(Severity::Off),
1596 unused_files: Some(Severity::Warn),
1597 ..Default::default()
1598 },
1599 }],
1600 regression: None,
1601 type_aware: crate::TypeAwareConfig::default(),
1602 audit: crate::config::AuditConfig::default(),
1603 codeowners: None,
1604 public_packages: vec![],
1605 flags: FlagsConfig::default(),
1606 security: SecurityConfig::default(),
1607 fix: crate::config::FixConfig::default(),
1608 resolve: ResolveConfig::default(),
1609 sealed: false,
1610 include_entry_exports: false,
1611 show_cascade: false,
1612 auto_imports: false,
1613 fail_on_parse_error: false,
1614 cache: CacheConfig::default(),
1615 ..Default::default()
1616 };
1617 let resolved = config.resolve(
1618 PathBuf::from("/project"),
1619 OutputFormat::Human,
1620 1,
1621 true,
1622 true,
1623 None,
1624 );
1625 assert_eq!(
1626 resolved.overrides.len(),
1627 1,
1628 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1629 );
1630 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1631 assert_eq!(rules.unused_files, Severity::Warn);
1632 }
1633
1634 #[test]
1635 fn circular_dependency_override_is_not_reported_as_ineffective() {
1636 let rules = PartialRulesConfig {
1637 circular_dependencies: Some(Severity::Off),
1638 duplicate_exports: Some(Severity::Off),
1639 re_export_cycle: Some(Severity::Off),
1640 ..PartialRulesConfig::default()
1641 };
1642 assert_eq!(
1643 ineffective_inter_file_override_rules(&rules),
1644 vec!["duplicate-exports", "re-export-cycle"]
1645 );
1646
1647 let only_circular = PartialRulesConfig {
1648 circular_dependencies: Some(Severity::Off),
1649 ..PartialRulesConfig::default()
1650 };
1651 assert!(ineffective_inter_file_override_rules(&only_circular).is_empty());
1652 }
1653
1654 #[test]
1655 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1656 reset_inter_file_warn_dedup_for_test();
1657 let files_a = vec!["__test_dedup_a/*".to_string()];
1658 let files_b = vec!["__test_dedup_b/*".to_string()];
1659
1660 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1661 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1662 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1663
1664 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1665 assert!(!record_inter_file_warn_seen(
1666 "circular-dependency",
1667 &files_a
1668 ));
1669
1670 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1671
1672 let files_reordered = vec![
1673 "__test_dedup_b/*".to_string(),
1674 "__test_dedup_a/*".to_string(),
1675 ];
1676 let files_natural = vec![
1677 "__test_dedup_a/*".to_string(),
1678 "__test_dedup_b/*".to_string(),
1679 ];
1680 reset_inter_file_warn_dedup_for_test();
1681 assert!(record_inter_file_warn_seen(
1682 "duplicate-exports",
1683 &files_natural
1684 ));
1685 assert!(!record_inter_file_warn_seen(
1686 "duplicate-exports",
1687 &files_reordered
1688 ));
1689 }
1690
1691 #[test]
1692 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1693 reset_inter_file_warn_dedup_for_test();
1694 let files = vec!["__test_resolve_dedup/**".to_string()];
1695 let build_config = || FallowConfig {
1696 schema: None,
1697 minimum_version: None,
1698 extends: vec![],
1699 entry: vec![],
1700 ignore_patterns: vec![],
1701 ignore_findings: vec![],
1702 framework: vec![],
1703 workspaces: None,
1704 ignore_dependencies: vec![],
1705 ignore_command_entries: vec![],
1706 ignore_unresolved_imports: vec![],
1707 ignore_exports: vec![],
1708 ignore_catalog_references: vec![],
1709 ignore_dependency_overrides: vec![],
1710 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1711 used_class_members: vec![],
1712 ignore_decorators: vec![],
1713 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1714 circular_dependencies: crate::CircularDependenciesConfig::default(),
1715 duplicates: DuplicatesConfig::default(),
1716 similar_code: SimilarCodeConfig::default(),
1717 mutation: MutationConfig::default(),
1718 health: HealthConfig::default(),
1719 rules: RulesConfig::default(),
1720 boundaries: BoundaryConfig::default(),
1721 production: false.into(),
1722 plugins: 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 ..Default::default()
1746 };
1747 for _ in 0..10 {
1748 let _ = build_config().resolve(
1749 PathBuf::from("/project"),
1750 OutputFormat::Human,
1751 1,
1752 true,
1753 true,
1754 None,
1755 );
1756 }
1757 assert!(
1758 !record_inter_file_warn_seen("duplicate-exports", &files),
1759 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1760 );
1761 }
1762
1763 fn make_config(production: bool) -> FallowConfig {
1765 FallowConfig {
1766 schema: None,
1767 minimum_version: None,
1768 extends: vec![],
1769 entry: vec![],
1770 ignore_patterns: vec![],
1771 ignore_findings: vec![],
1772 framework: vec![],
1773 workspaces: None,
1774 ignore_dependencies: vec![],
1775 ignore_command_entries: vec![],
1776 ignore_unresolved_imports: vec![],
1777 ignore_exports: vec![],
1778 ignore_catalog_references: vec![],
1779 ignore_dependency_overrides: vec![],
1780 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1781 used_class_members: vec![],
1782 ignore_decorators: vec![],
1783 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1784 circular_dependencies: crate::CircularDependenciesConfig::default(),
1785 duplicates: DuplicatesConfig::default(),
1786 similar_code: SimilarCodeConfig::default(),
1787 mutation: MutationConfig::default(),
1788 health: HealthConfig::default(),
1789 rules: RulesConfig::default(),
1790 boundaries: BoundaryConfig::default(),
1791 production: production.into(),
1792 plugins: vec![],
1793 overrides: vec![],
1794 regression: None,
1795 type_aware: crate::TypeAwareConfig::default(),
1796 audit: crate::config::AuditConfig::default(),
1797 codeowners: None,
1798 public_packages: vec![],
1799 flags: FlagsConfig::default(),
1800 security: SecurityConfig::default(),
1801 fix: crate::config::FixConfig::default(),
1802 resolve: ResolveConfig::default(),
1803 sealed: false,
1804 include_entry_exports: false,
1805 show_cascade: false,
1806 auto_imports: false,
1807 fail_on_parse_error: false,
1808 cache: CacheConfig::default(),
1809 ..Default::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}