1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::boundaries::ResolvedBoundaryConfig;
12use super::duplicates_config::DuplicatesConfig;
13use super::flags::FlagsConfig;
14use super::format::OutputFormat;
15use super::health::HealthConfig;
16use super::resolve::ResolveConfig;
17use super::rules::{PartialRulesConfig, RulesConfig, Severity};
18use super::similar_code::SimilarCodeConfig;
19use super::used_class_members::UsedClassMemberRule;
20use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
21
22use super::{
23 BoundaryConfig, FallowConfig, FindingIgnoreMatcher, IgnoreExportsUsedInFileConfig,
24 ProductionConfig, SecurityConfig, TypeAwareConfig,
25};
26
27static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
29
30fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
32 let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
33 sorted.sort_unstable();
34 let mut hasher = DefaultHasher::new();
35 rule_name.hash(&mut hasher);
36 for s in &sorted {
37 s.hash(&mut hasher);
38 }
39 hasher.finish()
40}
41
42fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
44 let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
45 let key = inter_file_warn_key(rule_name, files);
46 seen.lock().map_or(true, |mut set| set.insert(key))
47}
48
49#[cfg(test)]
50fn reset_inter_file_warn_dedup_for_test() {
51 if let Some(seen) = INTER_FILE_WARN_SEEN.get()
52 && let Ok(mut set) = seen.lock()
53 {
54 set.clear();
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
60#[serde(deny_unknown_fields)]
61pub struct IgnoreExportRule {
62 pub file: String,
64 pub exports: Vec<String>,
66}
67
68#[derive(Debug, Clone)]
70pub struct CompiledIgnoreExportRule {
71 pub matcher: globset::GlobMatcher,
73 pub exports: Vec<String>,
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
79#[serde(deny_unknown_fields)]
80pub struct IgnoreCatalogReferenceRule {
81 pub package: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub catalog: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub consumer: Option<String>,
89}
90
91#[derive(Debug, Clone)]
93pub struct CompiledIgnoreCatalogReferenceRule {
94 pub package: String,
96 pub catalog: Option<String>,
98 pub consumer_matcher: Option<globset::GlobMatcher>,
101}
102
103impl CompiledIgnoreCatalogReferenceRule {
104 #[must_use]
106 pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
107 if self.package != package {
108 return false;
109 }
110 if let Some(catalog_filter) = &self.catalog
111 && catalog_filter != catalog
112 {
113 return false;
114 }
115 if let Some(matcher) = &self.consumer_matcher
116 && !matcher.is_match(consumer_path)
117 {
118 return false;
119 }
120 true
121 }
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct IgnoreDependencyOverrideRule {
128 pub package: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub source: Option<String>,
133}
134
135#[derive(Debug, Clone)]
137pub struct CompiledIgnoreDependencyOverrideRule {
138 pub package: String,
140 pub source: Option<String>,
143}
144
145impl CompiledIgnoreDependencyOverrideRule {
146 #[must_use]
148 pub fn matches(&self, package: &str, source_label: &str) -> bool {
149 if self.package != package {
150 return false;
151 }
152 if let Some(source_filter) = &self.source
153 && source_filter != source_label
154 {
155 return false;
156 }
157 true
158 }
159}
160
161#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
163#[serde(rename_all = "camelCase", deny_unknown_fields)]
164pub struct ConfigOverride {
165 pub files: Vec<String>,
167 #[serde(default)]
169 pub rules: PartialRulesConfig,
170}
171
172#[derive(Debug, Clone)]
174pub struct ResolvedOverride {
175 pub matchers: Vec<globset::GlobMatcher>,
178 pub rules: PartialRulesConfig,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum AnalysisSnapshot {
190 #[default]
192 Current,
193 Base,
195}
196
197impl AnalysisSnapshot {
198 #[must_use]
200 pub const fn is_base(self) -> bool {
201 matches!(self, Self::Base)
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct ResolvedConfig {
208 pub root: PathBuf,
210 pub entry_patterns: Vec<String>,
213 pub ignore_patterns: GlobSet,
217 pub ignore_findings: FindingIgnoreMatcher,
220 pub output: OutputFormat,
222 pub cache_dir: PathBuf,
225 pub threads: usize,
227 pub no_cache: bool,
229 pub cache_max_size_mb: Option<u32>,
232 pub cache_config_hash: u64,
236 pub ignore_dependencies: Vec<String>,
239 pub ignore_unresolved_imports: Vec<GlobMatcher>,
242 pub ignore_export_rules: Vec<IgnoreExportRule>,
246 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
248 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
250 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
252 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
254 pub used_class_members: Vec<UsedClassMemberRule>,
257 pub ignore_decorators: Vec<String>,
260 pub unused_component_props_ignore: Option<regex::Regex>,
265 pub duplicates: DuplicatesConfig,
267 pub similar_code: SimilarCodeConfig,
269 pub health: HealthConfig,
271 pub type_aware: TypeAwareConfig,
273 pub rules: RulesConfig,
277 pub boundaries: ResolvedBoundaryConfig,
280 pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
285 pub rule_pack_sources: Vec<PathBuf>,
288 pub production: bool,
293 pub quiet: bool,
296 pub external_plugins: Vec<ExternalPluginDef>,
300 pub dynamically_loaded: Vec<String>,
303 pub overrides: Vec<ResolvedOverride>,
305 pub regression: Option<super::RegressionConfig>,
307 pub audit: super::AuditConfig,
309 pub codeowners: Option<String>,
312 pub public_packages: Vec<String>,
316 pub flags: FlagsConfig,
318 pub security: SecurityConfig,
321 pub fix: super::FixConfig,
323 pub resolve: ResolveConfig,
326 pub include_entry_exports: bool,
329 pub auto_imports: bool,
333 pub max_file_size_bytes: Option<u64>,
342 pub analysis_snapshot: AnalysisSnapshot,
347}
348
349pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
353
354pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
356
357#[must_use]
362pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
363 match max_file_size_mb {
364 None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
365 Some(0) => None,
366 Some(mb) => Some(u64::from(mb) * 1024 * 1024),
367 }
368}
369
370fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
372 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
373 names.sort_unstable();
374 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
375 for name in names {
376 hasher.update(&(name.len() as u32).to_le_bytes());
377 hasher.update(name.as_bytes());
378 }
379 hasher.digest()
380}
381
382fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
383 let Some(dir) = configured else {
384 return root.join(".fallow");
385 };
386 if dir.is_absolute() {
387 dir
388 } else {
389 root.join(dir)
390 }
391}
392
393fn normalize_user_glob_pattern(pattern: &str) -> &str {
394 pattern.strip_prefix("./").unwrap_or(pattern)
395}
396
397#[expect(
398 clippy::expect_used,
399 reason = "user glob patterns are validated before config resolution"
400)]
401fn compile_ignore_patterns(ignore_patterns: &[String]) -> GlobSet {
402 let mut ignore_builder = GlobSetBuilder::new();
403 for pattern in ignore_patterns {
404 let normalized = normalize_user_glob_pattern(pattern);
405 ignore_builder.add(
406 Glob::new(normalized).expect("ignorePatterns entry was validated at config load time"),
407 );
408 }
409
410 let default_ignores = [
411 "**/node_modules/**",
412 "**/dist/**",
413 "build/**",
414 "**/.git/**",
415 "**/coverage/**",
416 "**/*.min.js",
417 "**/*.min.mjs",
418 "**/*.min.cjs",
419 "**/*.bundle.js",
420 ];
421 for pattern in &default_ignores {
422 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
423 }
424
425 ignore_builder.build().unwrap_or_default()
426}
427
428#[expect(
429 clippy::expect_used,
430 reason = "user glob patterns are validated before config resolution"
431)]
432fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
433 patterns
434 .iter()
435 .map(|pattern| {
436 let normalized = normalize_user_glob_pattern(pattern);
437 Glob::new(normalized)
438 .expect("ignoreUnresolvedImports entry was validated at config load time")
439 .compile_matcher()
440 })
441 .collect()
442}
443
444fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
445 if production {
446 rules.unused_dev_dependencies = Severity::Off;
447 rules.unused_optional_dependencies = Severity::Off;
448 }
449 rules
450}
451
452fn resolve_boundaries(
453 mut boundaries: super::boundaries::BoundaryConfig,
454 root: &Path,
455) -> ResolvedBoundaryConfig {
456 if boundaries.preset.is_some() {
457 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
458 .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
459 .unwrap_or_else(|| "src".to_owned());
460 if source_root != "src" {
461 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
462 }
463 boundaries.expand(&source_root);
464 }
465 let logical_groups = boundaries.expand_auto_discover(root);
466 let mut resolved = boundaries.resolve();
467 resolved.logical_groups = logical_groups;
468 resolved
469}
470
471fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
472 if rules.duplicate_exports.is_some() && record_inter_file_warn_seen("duplicate-exports", files)
473 {
474 let files = files.join(", ");
475 tracing::warn!(
476 "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."
477 );
478 }
479 if rules.circular_dependencies.is_some()
480 && record_inter_file_warn_seen("circular-dependency", files)
481 {
482 let files = files.join(", ");
483 tracing::warn!(
484 "overrides.rules.circular-dependency has no effect for files matching [{files}]: circular-dependency is an inter-file rule. Use a file-level `// fallow-ignore-file circular-dependency` comment in one participating file instead."
485 );
486 }
487 if rules.re_export_cycle.is_some() && record_inter_file_warn_seen("re-export-cycle", files) {
488 let files = files.join(", ");
489 tracing::warn!(
490 "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."
491 );
492 }
493}
494
495#[expect(
496 clippy::expect_used,
497 reason = "override glob patterns are validated before config resolution"
498)]
499fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
500 overrides
501 .into_iter()
502 .filter_map(|override_entry| {
503 warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
504 let matchers: Vec<globset::GlobMatcher> = override_entry
505 .files
506 .iter()
507 .map(|pattern| {
508 Glob::new(pattern)
509 .expect("overrides[].files pattern was validated at config load time")
510 .compile_matcher()
511 })
512 .collect();
513 if matchers.is_empty() {
514 None
515 } else {
516 Some(ResolvedOverride {
517 matchers,
518 rules: override_entry.rules,
519 })
520 }
521 })
522 .collect()
523}
524
525#[expect(
527 clippy::expect_used,
528 reason = "user glob patterns are validated before config resolution"
529)]
530fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
531 rules
532 .iter()
533 .map(|rule| CompiledIgnoreExportRule {
534 matcher: Glob::new(&rule.file)
535 .expect("ignoreExports[].file was validated at config load time")
536 .compile_matcher(),
537 exports: rule.exports.clone(),
538 })
539 .collect()
540}
541
542#[expect(
544 clippy::expect_used,
545 reason = "user glob patterns are validated before config resolution"
546)]
547fn compile_ignore_catalog_reference_rules(
548 rules: &[IgnoreCatalogReferenceRule],
549) -> Vec<CompiledIgnoreCatalogReferenceRule> {
550 rules
551 .iter()
552 .map(|rule| CompiledIgnoreCatalogReferenceRule {
553 package: rule.package.clone(),
554 catalog: rule.catalog.clone(),
555 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
556 Glob::new(pattern)
557 .expect("ignoreCatalogReferences[].consumer was validated at config load time")
558 .compile_matcher()
559 }),
560 })
561 .collect()
562}
563
564fn compile_ignore_dependency_override_rules(
566 rules: &[IgnoreDependencyOverrideRule],
567) -> Vec<CompiledIgnoreDependencyOverrideRule> {
568 rules
569 .iter()
570 .map(|rule| CompiledIgnoreDependencyOverrideRule {
571 package: rule.package.clone(),
572 source: rule.source.clone(),
573 })
574 .collect()
575}
576
577struct CompiledIgnoreSettings {
578 patterns: GlobSet,
579 findings: FindingIgnoreMatcher,
580 unresolved_imports: Vec<GlobMatcher>,
581 exports: Vec<CompiledIgnoreExportRule>,
582 catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
583 dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
584}
585
586fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
587 CompiledIgnoreSettings {
588 patterns: compile_ignore_patterns(&config.ignore_patterns),
589 findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
590 unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
591 exports: compile_ignore_export_rules(&config.ignore_exports),
592 catalog_references: compile_ignore_catalog_reference_rules(
593 &config.ignore_catalog_references,
594 ),
595 dependency_overrides: compile_ignore_dependency_override_rules(
596 &config.ignore_dependency_overrides,
597 ),
598 }
599}
600
601struct ResolvedPluginSettings {
602 external_plugins: Vec<ExternalPluginDef>,
603 rule_packs: Vec<crate::rule_pack::RulePackDef>,
604 rule_pack_sources: Vec<PathBuf>,
605}
606
607fn resolve_plugin_settings(
608 root: &Path,
609 configured_plugins: &[String],
610 framework: Vec<ExternalPluginDef>,
611 rule_packs: &[String],
612) -> ResolvedPluginSettings {
613 let mut external_plugins = discover_external_plugins(root, configured_plugins);
614 external_plugins.extend(framework);
615
616 let configured_rule_packs = rule_packs;
617 let rule_packs =
618 crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
619 for error in &errors {
620 tracing::error!("invalid rule pack: {error}");
621 }
622 Vec::new()
623 });
624 let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
625 configured_rule_packs.iter().map(PathBuf::from).collect()
626 } else {
627 Vec::new()
628 };
629
630 ResolvedPluginSettings {
631 external_plugins,
632 rule_packs,
633 rule_pack_sources,
634 }
635}
636
637struct ResolvedCacheSettings {
638 dir: PathBuf,
639 max_size_mb: Option<u32>,
640 config_hash: u64,
641}
642
643struct ResolvedProductionRules {
644 production: bool,
645 rules: RulesConfig,
646}
647
648fn resolve_production_rules(
649 production_config: ProductionConfig,
650 rules: RulesConfig,
651) -> ResolvedProductionRules {
652 let production = production_config.global();
653 ResolvedProductionRules {
654 production,
655 rules: resolve_rules_for_production(rules, production),
656 }
657}
658
659fn resolve_cache_settings(
660 root: &Path,
661 configured_dir: Option<PathBuf>,
662 configured_max_size_mb: Option<u32>,
663 override_max_size_mb: Option<u32>,
664 no_cache: bool,
665 external_plugins: &[ExternalPluginDef],
666) -> ResolvedCacheSettings {
667 ResolvedCacheSettings {
668 dir: resolve_cache_dir(root, configured_dir),
669 max_size_mb: override_max_size_mb.or(configured_max_size_mb),
670 config_hash: if no_cache {
671 0
672 } else {
673 compute_cache_config_hash(external_plugins)
674 },
675 }
676}
677
678fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
679 SecurityConfig {
680 request_receivers: security.normalized_request_receivers(),
681 ..security
682 }
683}
684
685struct ResolvedPathPolicySettings {
686 boundaries: ResolvedBoundaryConfig,
687 overrides: Vec<ResolvedOverride>,
688}
689
690fn resolve_path_policy_settings(
691 boundaries: BoundaryConfig,
692 overrides: Vec<ConfigOverride>,
693 root: &Path,
694) -> ResolvedPathPolicySettings {
695 ResolvedPathPolicySettings {
696 boundaries: resolve_boundaries(boundaries, root),
697 overrides: compile_overrides(overrides),
698 }
699}
700
701fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
702 pattern.and_then(|pattern| match regex::Regex::new(pattern) {
703 Ok(re) => Some(re),
704 Err(error) => {
705 tracing::warn!(
706 %error,
707 "ignoring invalid unusedComponentProps.ignorePattern; this config was \
708 not validated through FallowConfig::load"
709 );
710 None
711 }
712 })
713}
714
715impl FallowConfig {
716 #[expect(
718 clippy::too_many_arguments,
719 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"
720 )]
721 pub fn resolve(
722 self,
723 root: PathBuf,
724 output: OutputFormat,
725 threads: usize,
726 no_cache: bool,
727 quiet: bool,
728 cache_max_size_mb: Option<u32>,
729 ) -> ResolvedConfig {
730 let compiled_ignores = compile_ignore_settings(&self);
731
732 let production_rules = resolve_production_rules(self.production, self.rules);
733
734 let plugins =
735 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
736
737 let cache = resolve_cache_settings(
738 &root,
739 self.cache.dir,
740 self.cache.max_size_mb,
741 cache_max_size_mb,
742 no_cache,
743 &plugins.external_plugins,
744 );
745
746 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
747
748 let unused_component_props_ignore = compile_unused_component_props_ignore(
749 self.unused_component_props.ignore_pattern.as_deref(),
750 );
751
752 ResolvedConfig {
753 root,
754 entry_patterns: self.entry,
755 ignore_patterns: compiled_ignores.patterns,
756 ignore_findings: compiled_ignores.findings,
757 output,
758 cache_dir: cache.dir,
759 threads,
760 no_cache,
761 cache_max_size_mb: cache.max_size_mb,
762 cache_config_hash: cache.config_hash,
763 ignore_dependencies: self.ignore_dependencies,
764 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
765 ignore_export_rules: self.ignore_exports,
766 compiled_ignore_exports: compiled_ignores.exports,
767 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
768 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
769 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
770 used_class_members: self.used_class_members,
771 ignore_decorators: self.ignore_decorators,
772 unused_component_props_ignore,
773 duplicates: self.duplicates,
774 similar_code: self.similar_code,
775 health: self.health,
776 type_aware: self.type_aware,
777 rules: production_rules.rules,
778 boundaries: path_policy.boundaries,
779 rule_packs: plugins.rule_packs,
780 rule_pack_sources: plugins.rule_pack_sources,
781 production: production_rules.production,
782 quiet,
783 external_plugins: plugins.external_plugins,
784 dynamically_loaded: self.dynamically_loaded,
785 overrides: path_policy.overrides,
786 regression: self.regression,
787 audit: self.audit,
788 codeowners: self.codeowners,
789 public_packages: self.public_packages,
790 flags: self.flags,
791 security: normalize_security_config(self.security),
792 fix: self.fix,
793 resolve: self.resolve,
794 include_entry_exports: self.include_entry_exports,
795 auto_imports: self.auto_imports,
796 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
797 analysis_snapshot: AnalysisSnapshot::Current,
798 }
799 }
800}
801
802impl ResolvedConfig {
803 #[must_use]
806 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
807 if self.overrides.is_empty() {
808 return self.rules.clone();
809 }
810
811 let relative = path.strip_prefix(&self.root).unwrap_or(path);
812 let relative_str = relative.to_string_lossy();
813
814 let mut rules = self.rules.clone();
815 for override_entry in &self.overrides {
816 let matches = override_entry
817 .matchers
818 .iter()
819 .any(|m| m.is_match(relative_str.as_ref()));
820 if matches {
821 rules.apply_partial(&override_entry.rules);
822 }
823 }
824 rules
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831 use crate::CacheConfig;
832 use crate::config::boundaries::BoundaryConfig;
833 use crate::config::health::HealthConfig;
834
835 #[test]
836 fn overrides_deserialize() {
837 let json_str = r#"{
838 "overrides": [{
839 "files": ["*.test.ts"],
840 "rules": {
841 "unused-exports": "off"
842 }
843 }]
844 }"#;
845 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
846 assert_eq!(config.overrides.len(), 1);
847 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
848 assert_eq!(
849 config.overrides[0].rules.unused_exports,
850 Some(Severity::Off)
851 );
852 assert_eq!(config.overrides[0].rules.unused_files, None);
853 }
854
855 #[test]
856 fn resolve_rules_for_path_no_overrides() {
857 let config = FallowConfig {
858 schema: None,
859 minimum_version: None,
860 extends: vec![],
861 entry: vec![],
862 ignore_patterns: vec![],
863 ignore_findings: vec![],
864 framework: vec![],
865 workspaces: None,
866 ignore_dependencies: vec![],
867 ignore_unresolved_imports: vec![],
868 ignore_exports: vec![],
869 ignore_catalog_references: vec![],
870 ignore_dependency_overrides: vec![],
871 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
872 used_class_members: vec![],
873 ignore_decorators: vec![],
874 unused_component_props: crate::UnusedComponentPropsConfig::default(),
875 duplicates: DuplicatesConfig::default(),
876 similar_code: SimilarCodeConfig::default(),
877 health: HealthConfig::default(),
878 rules: RulesConfig::default(),
879 boundaries: BoundaryConfig::default(),
880 production: false.into(),
881 plugins: vec![],
882 rule_packs: vec![],
883 dynamically_loaded: vec![],
884 overrides: vec![],
885 regression: None,
886 type_aware: crate::TypeAwareConfig::default(),
887 audit: crate::config::AuditConfig::default(),
888 codeowners: None,
889 public_packages: vec![],
890 flags: FlagsConfig::default(),
891 security: SecurityConfig::default(),
892 fix: crate::config::FixConfig::default(),
893 resolve: ResolveConfig::default(),
894 sealed: false,
895 include_entry_exports: false,
896 auto_imports: false,
897 cache: CacheConfig::default(),
898 };
899 let resolved = config.resolve(
900 PathBuf::from("/project"),
901 OutputFormat::Human,
902 1,
903 true,
904 true,
905 None,
906 );
907 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
908 assert_eq!(rules.unused_files, Severity::Error);
909 }
910
911 #[test]
912 fn resolve_rules_for_path_with_matching_override() {
913 let config = FallowConfig {
914 schema: None,
915 minimum_version: None,
916 extends: vec![],
917 entry: vec![],
918 ignore_patterns: vec![],
919 ignore_findings: vec![],
920 framework: vec![],
921 workspaces: None,
922 ignore_dependencies: vec![],
923 ignore_unresolved_imports: vec![],
924 ignore_exports: vec![],
925 ignore_catalog_references: vec![],
926 ignore_dependency_overrides: vec![],
927 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
928 used_class_members: vec![],
929 ignore_decorators: vec![],
930 unused_component_props: crate::UnusedComponentPropsConfig::default(),
931 duplicates: DuplicatesConfig::default(),
932 similar_code: SimilarCodeConfig::default(),
933 health: HealthConfig::default(),
934 rules: RulesConfig::default(),
935 boundaries: BoundaryConfig::default(),
936 production: false.into(),
937 plugins: vec![],
938 rule_packs: vec![],
939 dynamically_loaded: vec![],
940 overrides: vec![ConfigOverride {
941 files: vec!["*.test.ts".to_string()],
942 rules: PartialRulesConfig {
943 unused_exports: Some(Severity::Off),
944 ..Default::default()
945 },
946 }],
947 regression: None,
948 type_aware: crate::TypeAwareConfig::default(),
949 audit: crate::config::AuditConfig::default(),
950 codeowners: None,
951 public_packages: vec![],
952 flags: FlagsConfig::default(),
953 security: SecurityConfig::default(),
954 fix: crate::config::FixConfig::default(),
955 resolve: ResolveConfig::default(),
956 sealed: false,
957 include_entry_exports: false,
958 auto_imports: false,
959 cache: CacheConfig::default(),
960 };
961 let resolved = config.resolve(
962 PathBuf::from("/project"),
963 OutputFormat::Human,
964 1,
965 true,
966 true,
967 None,
968 );
969
970 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
971 assert_eq!(test_rules.unused_exports, Severity::Off);
972 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
975 assert_eq!(src_rules.unused_exports, Severity::Error);
976 }
977
978 #[test]
979 fn resolve_rules_for_path_later_override_wins() {
980 let config = FallowConfig {
981 schema: None,
982 minimum_version: None,
983 extends: vec![],
984 entry: vec![],
985 ignore_patterns: vec![],
986 ignore_findings: vec![],
987 framework: vec![],
988 workspaces: None,
989 ignore_dependencies: vec![],
990 ignore_unresolved_imports: vec![],
991 ignore_exports: vec![],
992 ignore_catalog_references: vec![],
993 ignore_dependency_overrides: vec![],
994 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
995 used_class_members: vec![],
996 ignore_decorators: vec![],
997 unused_component_props: crate::UnusedComponentPropsConfig::default(),
998 duplicates: DuplicatesConfig::default(),
999 similar_code: SimilarCodeConfig::default(),
1000 health: HealthConfig::default(),
1001 rules: RulesConfig::default(),
1002 boundaries: BoundaryConfig::default(),
1003 production: false.into(),
1004 plugins: vec![],
1005 rule_packs: vec![],
1006 dynamically_loaded: vec![],
1007 overrides: vec![
1008 ConfigOverride {
1009 files: vec!["*.ts".to_string()],
1010 rules: PartialRulesConfig {
1011 unused_files: Some(Severity::Warn),
1012 ..Default::default()
1013 },
1014 },
1015 ConfigOverride {
1016 files: vec!["*.test.ts".to_string()],
1017 rules: PartialRulesConfig {
1018 unused_files: Some(Severity::Off),
1019 ..Default::default()
1020 },
1021 },
1022 ],
1023 regression: None,
1024 type_aware: crate::TypeAwareConfig::default(),
1025 audit: crate::config::AuditConfig::default(),
1026 codeowners: None,
1027 public_packages: vec![],
1028 flags: FlagsConfig::default(),
1029 security: SecurityConfig::default(),
1030 fix: crate::config::FixConfig::default(),
1031 resolve: ResolveConfig::default(),
1032 sealed: false,
1033 include_entry_exports: false,
1034 auto_imports: false,
1035 cache: CacheConfig::default(),
1036 };
1037 let resolved = config.resolve(
1038 PathBuf::from("/project"),
1039 OutputFormat::Human,
1040 1,
1041 true,
1042 true,
1043 None,
1044 );
1045
1046 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1047 assert_eq!(rules.unused_files, Severity::Off);
1048
1049 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1050 assert_eq!(rules2.unused_files, Severity::Warn);
1051 }
1052
1053 #[test]
1054 fn resolve_keeps_inter_file_rule_override_after_warning() {
1055 let config = FallowConfig {
1056 schema: None,
1057 minimum_version: None,
1058 extends: vec![],
1059 entry: vec![],
1060 ignore_patterns: vec![],
1061 ignore_findings: vec![],
1062 framework: vec![],
1063 workspaces: None,
1064 ignore_dependencies: vec![],
1065 ignore_unresolved_imports: vec![],
1066 ignore_exports: vec![],
1067 ignore_catalog_references: vec![],
1068 ignore_dependency_overrides: vec![],
1069 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1070 used_class_members: vec![],
1071 ignore_decorators: vec![],
1072 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1073 duplicates: DuplicatesConfig::default(),
1074 similar_code: SimilarCodeConfig::default(),
1075 health: HealthConfig::default(),
1076 rules: RulesConfig::default(),
1077 boundaries: BoundaryConfig::default(),
1078 production: false.into(),
1079 plugins: vec![],
1080 rule_packs: vec![],
1081 dynamically_loaded: vec![],
1082 overrides: vec![ConfigOverride {
1083 files: vec!["**/ui/**".to_string()],
1084 rules: PartialRulesConfig {
1085 duplicate_exports: Some(Severity::Off),
1086 unused_files: Some(Severity::Warn),
1087 ..Default::default()
1088 },
1089 }],
1090 regression: None,
1091 type_aware: crate::TypeAwareConfig::default(),
1092 audit: crate::config::AuditConfig::default(),
1093 codeowners: None,
1094 public_packages: vec![],
1095 flags: FlagsConfig::default(),
1096 security: SecurityConfig::default(),
1097 fix: crate::config::FixConfig::default(),
1098 resolve: ResolveConfig::default(),
1099 sealed: false,
1100 include_entry_exports: false,
1101 auto_imports: false,
1102 cache: CacheConfig::default(),
1103 };
1104 let resolved = config.resolve(
1105 PathBuf::from("/project"),
1106 OutputFormat::Human,
1107 1,
1108 true,
1109 true,
1110 None,
1111 );
1112 assert_eq!(
1113 resolved.overrides.len(),
1114 1,
1115 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1116 );
1117 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1118 assert_eq!(rules.unused_files, Severity::Warn);
1119 }
1120
1121 #[test]
1122 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1123 reset_inter_file_warn_dedup_for_test();
1124 let files_a = vec!["__test_dedup_a/*".to_string()];
1125 let files_b = vec!["__test_dedup_b/*".to_string()];
1126
1127 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1128 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1129 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1130
1131 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1132 assert!(!record_inter_file_warn_seen(
1133 "circular-dependency",
1134 &files_a
1135 ));
1136
1137 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1138
1139 let files_reordered = vec![
1140 "__test_dedup_b/*".to_string(),
1141 "__test_dedup_a/*".to_string(),
1142 ];
1143 let files_natural = vec![
1144 "__test_dedup_a/*".to_string(),
1145 "__test_dedup_b/*".to_string(),
1146 ];
1147 reset_inter_file_warn_dedup_for_test();
1148 assert!(record_inter_file_warn_seen(
1149 "duplicate-exports",
1150 &files_natural
1151 ));
1152 assert!(!record_inter_file_warn_seen(
1153 "duplicate-exports",
1154 &files_reordered
1155 ));
1156 }
1157
1158 #[test]
1159 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1160 reset_inter_file_warn_dedup_for_test();
1161 let files = vec!["__test_resolve_dedup/**".to_string()];
1162 let build_config = || FallowConfig {
1163 schema: None,
1164 minimum_version: None,
1165 extends: vec![],
1166 entry: vec![],
1167 ignore_patterns: vec![],
1168 ignore_findings: vec![],
1169 framework: vec![],
1170 workspaces: None,
1171 ignore_dependencies: vec![],
1172 ignore_unresolved_imports: vec![],
1173 ignore_exports: vec![],
1174 ignore_catalog_references: vec![],
1175 ignore_dependency_overrides: vec![],
1176 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1177 used_class_members: vec![],
1178 ignore_decorators: vec![],
1179 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1180 duplicates: DuplicatesConfig::default(),
1181 similar_code: SimilarCodeConfig::default(),
1182 health: HealthConfig::default(),
1183 rules: RulesConfig::default(),
1184 boundaries: BoundaryConfig::default(),
1185 production: false.into(),
1186 plugins: vec![],
1187 rule_packs: vec![],
1188 dynamically_loaded: vec![],
1189 overrides: vec![ConfigOverride {
1190 files: files.clone(),
1191 rules: PartialRulesConfig {
1192 duplicate_exports: Some(Severity::Off),
1193 ..Default::default()
1194 },
1195 }],
1196 regression: None,
1197 type_aware: crate::TypeAwareConfig::default(),
1198 audit: crate::config::AuditConfig::default(),
1199 codeowners: None,
1200 public_packages: vec![],
1201 flags: FlagsConfig::default(),
1202 security: SecurityConfig::default(),
1203 fix: crate::config::FixConfig::default(),
1204 resolve: ResolveConfig::default(),
1205 sealed: false,
1206 include_entry_exports: false,
1207 auto_imports: false,
1208 cache: CacheConfig::default(),
1209 };
1210 for _ in 0..10 {
1211 let _ = build_config().resolve(
1212 PathBuf::from("/project"),
1213 OutputFormat::Human,
1214 1,
1215 true,
1216 true,
1217 None,
1218 );
1219 }
1220 assert!(
1221 !record_inter_file_warn_seen("duplicate-exports", &files),
1222 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1223 );
1224 }
1225
1226 fn make_config(production: bool) -> FallowConfig {
1228 FallowConfig {
1229 schema: None,
1230 minimum_version: None,
1231 extends: vec![],
1232 entry: vec![],
1233 ignore_patterns: vec![],
1234 ignore_findings: vec![],
1235 framework: vec![],
1236 workspaces: None,
1237 ignore_dependencies: vec![],
1238 ignore_unresolved_imports: vec![],
1239 ignore_exports: vec![],
1240 ignore_catalog_references: vec![],
1241 ignore_dependency_overrides: vec![],
1242 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1243 used_class_members: vec![],
1244 ignore_decorators: vec![],
1245 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1246 duplicates: DuplicatesConfig::default(),
1247 similar_code: SimilarCodeConfig::default(),
1248 health: HealthConfig::default(),
1249 rules: RulesConfig::default(),
1250 boundaries: BoundaryConfig::default(),
1251 production: production.into(),
1252 plugins: vec![],
1253 rule_packs: vec![],
1254 dynamically_loaded: vec![],
1255 overrides: vec![],
1256 regression: None,
1257 type_aware: crate::TypeAwareConfig::default(),
1258 audit: crate::config::AuditConfig::default(),
1259 codeowners: None,
1260 public_packages: vec![],
1261 flags: FlagsConfig::default(),
1262 security: SecurityConfig::default(),
1263 fix: crate::config::FixConfig::default(),
1264 resolve: ResolveConfig::default(),
1265 sealed: false,
1266 include_entry_exports: false,
1267 auto_imports: false,
1268 cache: CacheConfig::default(),
1269 }
1270 }
1271
1272 #[test]
1273 fn resolve_tracks_rule_pack_sources_in_config_order() {
1274 let dir = tempfile::tempdir().unwrap();
1275 std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1276 std::fs::write(
1277 dir.path().join("rule-packs/team-policy.jsonc"),
1278 r#"{
1279 "version": 1,
1280 "name": "team-policy",
1281 "rules": [
1282 {
1283 "id": "no-moment",
1284 "kind": "banned-import",
1285 "specifiers": ["moment"]
1286 }
1287 ]
1288}
1289"#,
1290 )
1291 .unwrap();
1292
1293 let mut config = make_config(false);
1294 config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1295
1296 let resolved = config.resolve(
1297 dir.path().to_path_buf(),
1298 OutputFormat::Human,
1299 1,
1300 true,
1301 true,
1302 None,
1303 );
1304
1305 assert_eq!(resolved.rule_packs.len(), 1);
1306 assert_eq!(resolved.rule_packs[0].name, "team-policy");
1307 assert_eq!(
1308 resolved.rule_pack_sources,
1309 vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1310 );
1311 }
1312
1313 #[test]
1314 fn resolve_production_forces_dev_deps_off() {
1315 let resolved = make_config(true).resolve(
1316 PathBuf::from("/project"),
1317 OutputFormat::Human,
1318 1,
1319 true,
1320 true,
1321 None,
1322 );
1323 assert_eq!(
1324 resolved.rules.unused_dev_dependencies,
1325 Severity::Off,
1326 "production mode should force unused_dev_dependencies to off"
1327 );
1328 }
1329
1330 #[test]
1331 fn resolve_production_forces_optional_deps_off() {
1332 let resolved = make_config(true).resolve(
1333 PathBuf::from("/project"),
1334 OutputFormat::Human,
1335 1,
1336 true,
1337 true,
1338 None,
1339 );
1340 assert_eq!(
1341 resolved.rules.unused_optional_dependencies,
1342 Severity::Off,
1343 "production mode should force unused_optional_dependencies to off"
1344 );
1345 }
1346
1347 #[test]
1348 fn resolve_production_preserves_other_rules() {
1349 let resolved = make_config(true).resolve(
1350 PathBuf::from("/project"),
1351 OutputFormat::Human,
1352 1,
1353 true,
1354 true,
1355 None,
1356 );
1357 assert_eq!(resolved.rules.unused_files, Severity::Error);
1358 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1359 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1360 }
1361
1362 #[test]
1363 fn resolve_non_production_keeps_dev_deps_default() {
1364 let resolved = make_config(false).resolve(
1365 PathBuf::from("/project"),
1366 OutputFormat::Human,
1367 1,
1368 true,
1369 true,
1370 None,
1371 );
1372 assert_eq!(
1373 resolved.rules.unused_dev_dependencies,
1374 Severity::Warn,
1375 "non-production should keep default severity"
1376 );
1377 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1378 }
1379
1380 #[test]
1381 fn resolve_production_flag_stored() {
1382 let resolved = make_config(true).resolve(
1383 PathBuf::from("/project"),
1384 OutputFormat::Human,
1385 1,
1386 true,
1387 true,
1388 None,
1389 );
1390 assert!(resolved.production);
1391
1392 let resolved2 = make_config(false).resolve(
1393 PathBuf::from("/project"),
1394 OutputFormat::Human,
1395 1,
1396 true,
1397 true,
1398 None,
1399 );
1400 assert!(!resolved2.production);
1401 }
1402
1403 #[test]
1404 fn resolve_default_ignores_node_modules() {
1405 let resolved = make_config(false).resolve(
1406 PathBuf::from("/project"),
1407 OutputFormat::Human,
1408 1,
1409 true,
1410 true,
1411 None,
1412 );
1413 assert!(
1414 resolved
1415 .ignore_patterns
1416 .is_match("node_modules/lodash/index.js")
1417 );
1418 assert!(
1419 resolved
1420 .ignore_patterns
1421 .is_match("packages/a/node_modules/react/index.js")
1422 );
1423 }
1424
1425 #[test]
1426 fn resolve_default_ignores_dist() {
1427 let resolved = make_config(false).resolve(
1428 PathBuf::from("/project"),
1429 OutputFormat::Human,
1430 1,
1431 true,
1432 true,
1433 None,
1434 );
1435 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1436 assert!(
1437 resolved
1438 .ignore_patterns
1439 .is_match("packages/ui/dist/index.js")
1440 );
1441 }
1442
1443 #[test]
1444 fn resolve_default_ignores_root_build_only() {
1445 let resolved = make_config(false).resolve(
1446 PathBuf::from("/project"),
1447 OutputFormat::Human,
1448 1,
1449 true,
1450 true,
1451 None,
1452 );
1453 assert!(
1454 resolved.ignore_patterns.is_match("build/output.js"),
1455 "root build/ should be ignored"
1456 );
1457 assert!(
1458 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1459 "nested build/ should NOT be ignored by default"
1460 );
1461 }
1462
1463 #[test]
1464 fn resolve_default_ignores_minified_files() {
1465 let resolved = make_config(false).resolve(
1466 PathBuf::from("/project"),
1467 OutputFormat::Human,
1468 1,
1469 true,
1470 true,
1471 None,
1472 );
1473 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1474 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1475 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1476 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1477 assert!(
1478 resolved
1479 .ignore_patterns
1480 .is_match("src/vendor/app.bundle.js")
1481 );
1482 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1484 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1485 }
1486
1487 #[test]
1488 fn resolve_max_file_size_bytes_default_and_unlimited() {
1489 assert_eq!(
1491 resolve_max_file_size_bytes(None),
1492 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1493 );
1494 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1496 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1498 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1499 }
1500
1501 #[test]
1502 fn resolve_sets_default_max_file_size() {
1503 let resolved = make_config(false).resolve(
1504 PathBuf::from("/project"),
1505 OutputFormat::Human,
1506 1,
1507 true,
1508 true,
1509 None,
1510 );
1511 assert_eq!(
1512 resolved.max_file_size_bytes,
1513 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1514 );
1515 }
1516
1517 #[test]
1518 fn resolve_default_ignores_git() {
1519 let resolved = make_config(false).resolve(
1520 PathBuf::from("/project"),
1521 OutputFormat::Human,
1522 1,
1523 true,
1524 true,
1525 None,
1526 );
1527 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1528 }
1529
1530 #[test]
1531 fn resolve_default_ignores_coverage() {
1532 let resolved = make_config(false).resolve(
1533 PathBuf::from("/project"),
1534 OutputFormat::Human,
1535 1,
1536 true,
1537 true,
1538 None,
1539 );
1540 assert!(
1541 resolved
1542 .ignore_patterns
1543 .is_match("coverage/lcov-report/index.js")
1544 );
1545 }
1546
1547 #[test]
1548 fn resolve_source_files_not_ignored_by_default() {
1549 let resolved = make_config(false).resolve(
1550 PathBuf::from("/project"),
1551 OutputFormat::Human,
1552 1,
1553 true,
1554 true,
1555 None,
1556 );
1557 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1558 assert!(
1559 !resolved
1560 .ignore_patterns
1561 .is_match("src/components/Button.tsx")
1562 );
1563 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1564 }
1565
1566 #[test]
1567 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1568 let mut config = make_config(false);
1569 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1570 let resolved = config.resolve(
1571 PathBuf::from("/project"),
1572 OutputFormat::Human,
1573 1,
1574 true,
1575 true,
1576 None,
1577 );
1578 assert!(
1579 resolved
1580 .ignore_patterns
1581 .is_match("src/__generated__/types.ts")
1582 );
1583 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1584 }
1585
1586 #[test]
1587 fn resolve_normalizes_leading_dot_ignore_patterns() {
1588 let mut config = make_config(false);
1589 config.ignore_patterns = vec!["./src/generated/**".to_string()];
1590 let resolved = config.resolve(
1591 PathBuf::from("/project"),
1592 OutputFormat::Human,
1593 1,
1594 true,
1595 true,
1596 None,
1597 );
1598
1599 assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1600 assert!(
1601 !resolved
1602 .ignore_patterns
1603 .is_match("./src/generated/client.ts")
1604 );
1605 }
1606
1607 #[test]
1608 fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1609 let mut config = make_config(false);
1610 config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1611 let resolved = config.resolve(
1612 PathBuf::from("/project"),
1613 OutputFormat::Human,
1614 1,
1615 true,
1616 true,
1617 None,
1618 );
1619
1620 assert!(
1621 resolved
1622 .ignore_unresolved_imports
1623 .iter()
1624 .any(|matcher| matcher.is_match("src/generated/client"))
1625 );
1626 assert!(
1627 !resolved
1628 .ignore_unresolved_imports
1629 .iter()
1630 .any(|matcher| matcher.is_match("./src/generated/client"))
1631 );
1632 }
1633
1634 #[test]
1635 fn resolve_passes_through_entry_patterns() {
1636 let mut config = make_config(false);
1637 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1638 let resolved = config.resolve(
1639 PathBuf::from("/project"),
1640 OutputFormat::Human,
1641 1,
1642 true,
1643 true,
1644 None,
1645 );
1646 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1647 }
1648
1649 #[test]
1650 fn resolve_passes_through_ignore_dependencies() {
1651 let mut config = make_config(false);
1652 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1653 let resolved = config.resolve(
1654 PathBuf::from("/project"),
1655 OutputFormat::Human,
1656 1,
1657 true,
1658 true,
1659 None,
1660 );
1661 assert_eq!(
1662 resolved.ignore_dependencies,
1663 vec!["postcss", "autoprefixer"]
1664 );
1665 }
1666
1667 #[test]
1668 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1669 let mut config = make_config(false);
1670 config.ignore_unresolved_imports = vec![
1671 "@example/icons".to_string(),
1672 "@example/icons/**".to_string(),
1673 "../generated/**".to_string(),
1674 ];
1675 let resolved = config.resolve(
1676 PathBuf::from("/project"),
1677 OutputFormat::Human,
1678 1,
1679 true,
1680 true,
1681 None,
1682 );
1683
1684 assert!(
1685 resolved
1686 .ignore_unresolved_imports
1687 .iter()
1688 .any(|matcher| matcher.is_match("@example/icons"))
1689 );
1690 assert!(
1691 resolved
1692 .ignore_unresolved_imports
1693 .iter()
1694 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1695 );
1696 assert!(
1697 resolved
1698 .ignore_unresolved_imports
1699 .iter()
1700 .any(|matcher| matcher.is_match("../generated/client"))
1701 );
1702 }
1703
1704 #[test]
1705 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1706 let mut config = make_config(false);
1707 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1708 let resolved = config.resolve(
1709 PathBuf::from("/project"),
1710 OutputFormat::Human,
1711 1,
1712 true,
1713 true,
1714 None,
1715 );
1716
1717 assert!(
1718 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1719 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1720 );
1721 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1722 }
1723
1724 #[test]
1725 fn resolve_sets_cache_dir() {
1726 let resolved = make_config(false).resolve(
1727 PathBuf::from("/my/project"),
1728 OutputFormat::Human,
1729 1,
1730 true,
1731 true,
1732 None,
1733 );
1734 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1735 }
1736
1737 #[test]
1738 fn resolve_uses_relative_configured_cache_dir_from_root() {
1739 let config = FallowConfig {
1740 cache: crate::CacheConfig {
1741 dir: Some(PathBuf::from(".cache/fallow")),
1742 ..Default::default()
1743 },
1744 ..make_config(false)
1745 };
1746 let resolved = config.resolve(
1747 PathBuf::from("/my/project"),
1748 OutputFormat::Human,
1749 1,
1750 false,
1751 true,
1752 None,
1753 );
1754 assert_eq!(
1755 resolved.cache_dir,
1756 PathBuf::from("/my/project/.cache/fallow")
1757 );
1758 }
1759
1760 #[test]
1761 fn resolve_keeps_absolute_configured_cache_dir() {
1762 let config = FallowConfig {
1763 cache: crate::CacheConfig {
1764 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1765 ..Default::default()
1766 },
1767 ..make_config(false)
1768 };
1769 let resolved = config.resolve(
1770 PathBuf::from("/my/project"),
1771 OutputFormat::Human,
1772 1,
1773 false,
1774 true,
1775 None,
1776 );
1777 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1778 }
1779
1780 #[test]
1781 fn resolve_passes_through_thread_count() {
1782 let resolved = make_config(false).resolve(
1783 PathBuf::from("/project"),
1784 OutputFormat::Human,
1785 8,
1786 true,
1787 true,
1788 None,
1789 );
1790 assert_eq!(resolved.threads, 8);
1791 }
1792
1793 #[test]
1794 fn resolve_passes_through_quiet_flag() {
1795 let resolved = make_config(false).resolve(
1796 PathBuf::from("/project"),
1797 OutputFormat::Human,
1798 1,
1799 true,
1800 false,
1801 None,
1802 );
1803 assert!(!resolved.quiet);
1804
1805 let resolved2 = make_config(false).resolve(
1806 PathBuf::from("/project"),
1807 OutputFormat::Human,
1808 1,
1809 true,
1810 true,
1811 None,
1812 );
1813 assert!(resolved2.quiet);
1814 }
1815
1816 #[test]
1817 fn resolve_passes_through_no_cache_flag() {
1818 let resolved_no_cache = make_config(false).resolve(
1819 PathBuf::from("/project"),
1820 OutputFormat::Human,
1821 1,
1822 true,
1823 true,
1824 None,
1825 );
1826 assert!(resolved_no_cache.no_cache);
1827
1828 let resolved_with_cache = make_config(false).resolve(
1829 PathBuf::from("/project"),
1830 OutputFormat::Human,
1831 1,
1832 false,
1833 true,
1834 None,
1835 );
1836 assert!(!resolved_with_cache.no_cache);
1837 }
1838
1839 #[test]
1840 #[should_panic(expected = "validated at config load time")]
1841 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1842 let mut config = make_config(false);
1843 config.overrides = vec![ConfigOverride {
1844 files: vec!["[invalid".to_string()],
1845 rules: PartialRulesConfig {
1846 unused_files: Some(Severity::Off),
1847 ..Default::default()
1848 },
1849 }];
1850 let _ = config.resolve(
1851 PathBuf::from("/project"),
1852 OutputFormat::Human,
1853 1,
1854 true,
1855 true,
1856 None,
1857 );
1858 }
1859
1860 #[test]
1861 fn resolve_override_with_empty_files_skipped() {
1862 let mut config = make_config(false);
1863 config.overrides = vec![ConfigOverride {
1864 files: vec![],
1865 rules: PartialRulesConfig {
1866 unused_files: Some(Severity::Off),
1867 ..Default::default()
1868 },
1869 }];
1870 let resolved = config.resolve(
1871 PathBuf::from("/project"),
1872 OutputFormat::Human,
1873 1,
1874 true,
1875 true,
1876 None,
1877 );
1878 assert!(
1879 resolved.overrides.is_empty(),
1880 "override with no file patterns should be skipped"
1881 );
1882 }
1883
1884 #[test]
1885 fn resolve_multiple_valid_overrides() {
1886 let mut config = make_config(false);
1887 config.overrides = vec![
1888 ConfigOverride {
1889 files: vec!["*.test.ts".to_string()],
1890 rules: PartialRulesConfig {
1891 unused_exports: Some(Severity::Off),
1892 ..Default::default()
1893 },
1894 },
1895 ConfigOverride {
1896 files: vec!["*.stories.tsx".to_string()],
1897 rules: PartialRulesConfig {
1898 unused_files: Some(Severity::Off),
1899 ..Default::default()
1900 },
1901 },
1902 ];
1903 let resolved = config.resolve(
1904 PathBuf::from("/project"),
1905 OutputFormat::Human,
1906 1,
1907 true,
1908 true,
1909 None,
1910 );
1911 assert_eq!(resolved.overrides.len(), 2);
1912 }
1913
1914 #[test]
1915 fn ignore_export_rule_deserialize() {
1916 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1917 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1918 assert_eq!(rule.file, "src/types/*.ts");
1919 assert_eq!(rule.exports, vec!["*"]);
1920 }
1921
1922 #[test]
1923 fn ignore_export_rule_specific_exports() {
1924 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1925 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1926 assert_eq!(rule.exports.len(), 3);
1927 assert!(rule.exports.contains(&"FOO".to_string()));
1928 }
1929
1930 mod proptests {
1931 use super::*;
1932 use proptest::prelude::*;
1933
1934 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1935 make_config(production).resolve(
1936 PathBuf::from("/project"),
1937 OutputFormat::Human,
1938 1,
1939 true,
1940 true,
1941 None,
1942 )
1943 }
1944
1945 proptest! {
1946 #[test]
1948 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1949 let resolved = arb_resolved_config(production);
1950 prop_assert!(
1951 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1952 "Default ignore should match node_modules"
1953 );
1954 prop_assert!(
1955 resolved.ignore_patterns.is_match("dist/bundle.js"),
1956 "Default ignore should match dist"
1957 );
1958 }
1959
1960 #[test]
1962 fn production_forces_dev_deps_off(_unused in Just(())) {
1963 let resolved = arb_resolved_config(true);
1964 prop_assert_eq!(
1965 resolved.rules.unused_dev_dependencies,
1966 Severity::Off,
1967 "Production should force unused_dev_dependencies off"
1968 );
1969 prop_assert_eq!(
1970 resolved.rules.unused_optional_dependencies,
1971 Severity::Off,
1972 "Production should force unused_optional_dependencies off"
1973 );
1974 }
1975
1976 #[test]
1978 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1979 let resolved = arb_resolved_config(false);
1980 prop_assert_eq!(
1981 resolved.rules.unused_dev_dependencies,
1982 Severity::Warn,
1983 "Non-production should keep default dev dep severity"
1984 );
1985 }
1986
1987 #[test]
1989 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1990 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1991 let expected_cache = root.join(".fallow");
1992 let resolved = make_config(false).resolve(
1993 root,
1994 OutputFormat::Human,
1995 1,
1996 true,
1997 true,
1998 None,
1999 );
2000 prop_assert_eq!(
2001 resolved.cache_dir, expected_cache,
2002 "Default cache dir should be root/.fallow"
2003 );
2004 }
2005
2006 #[test]
2008 fn threads_passed_through(threads in 1..64usize) {
2009 let resolved = make_config(false).resolve(
2010 PathBuf::from("/project"),
2011 OutputFormat::Human,
2012 threads,
2013 true,
2014 true, None,
2015 );
2016 prop_assert_eq!(
2017 resolved.threads, threads,
2018 "Thread count should be passed through"
2019 );
2020 }
2021
2022 #[test]
2026 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2027 let mut config = make_config(false);
2028 config.ignore_patterns = vec![pattern];
2029 let resolved = config.resolve(
2030 PathBuf::from("/project"),
2031 OutputFormat::Human,
2032 1,
2033 true,
2034 true, None,
2035 );
2036 prop_assert!(
2037 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2038 "Default node_modules ignore should still be active"
2039 );
2040 }
2041 }
2042 }
2043
2044 #[test]
2045 fn resolve_expands_boundary_preset() {
2046 use crate::config::boundaries::BoundaryPreset;
2047
2048 let mut config = make_config(false);
2049 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2050 let resolved = config.resolve(
2051 PathBuf::from("/project"),
2052 OutputFormat::Human,
2053 1,
2054 true,
2055 true,
2056 None,
2057 );
2058 assert_eq!(resolved.boundaries.zones.len(), 3);
2059 assert_eq!(resolved.boundaries.rules.len(), 3);
2060 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2061 assert_eq!(
2062 resolved.boundaries.classify_zone("src/adapters/http.ts"),
2063 Some("adapters")
2064 );
2065 }
2066
2067 #[test]
2068 fn resolve_boundary_preset_with_user_override() {
2069 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2070
2071 let mut config = make_config(false);
2072 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2073 config.boundaries.zones = vec![BoundaryZone {
2074 name: "domain".to_string(),
2075 patterns: vec!["src/core/**".to_string()],
2076 auto_discover: vec![],
2077 root: None,
2078 }];
2079 let resolved = config.resolve(
2080 PathBuf::from("/project"),
2081 OutputFormat::Human,
2082 1,
2083 true,
2084 true,
2085 None,
2086 );
2087 assert_eq!(resolved.boundaries.zones.len(), 3);
2088 assert_eq!(
2089 resolved.boundaries.classify_zone("src/core/user.ts"),
2090 Some("domain")
2091 );
2092 assert_eq!(
2093 resolved.boundaries.classify_zone("src/domain/user.ts"),
2094 None
2095 );
2096 }
2097
2098 #[test]
2099 fn resolve_no_preset_unchanged() {
2100 let config = make_config(false);
2101 let resolved = config.resolve(
2102 PathBuf::from("/project"),
2103 OutputFormat::Human,
2104 1,
2105 true,
2106 true,
2107 None,
2108 );
2109 assert!(resolved.boundaries.is_empty());
2110 }
2111}