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