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 extends: vec![],
860 entry: vec![],
861 ignore_patterns: vec![],
862 ignore_findings: vec![],
863 framework: vec![],
864 workspaces: None,
865 ignore_dependencies: vec![],
866 ignore_unresolved_imports: vec![],
867 ignore_exports: vec![],
868 ignore_catalog_references: vec![],
869 ignore_dependency_overrides: vec![],
870 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
871 used_class_members: vec![],
872 ignore_decorators: vec![],
873 unused_component_props: crate::UnusedComponentPropsConfig::default(),
874 duplicates: DuplicatesConfig::default(),
875 similar_code: SimilarCodeConfig::default(),
876 health: HealthConfig::default(),
877 rules: RulesConfig::default(),
878 boundaries: BoundaryConfig::default(),
879 production: false.into(),
880 plugins: vec![],
881 rule_packs: vec![],
882 dynamically_loaded: vec![],
883 overrides: vec![],
884 regression: None,
885 type_aware: crate::TypeAwareConfig::default(),
886 audit: crate::config::AuditConfig::default(),
887 codeowners: None,
888 public_packages: vec![],
889 flags: FlagsConfig::default(),
890 security: SecurityConfig::default(),
891 fix: crate::config::FixConfig::default(),
892 resolve: ResolveConfig::default(),
893 sealed: false,
894 include_entry_exports: false,
895 auto_imports: false,
896 cache: CacheConfig::default(),
897 };
898 let resolved = config.resolve(
899 PathBuf::from("/project"),
900 OutputFormat::Human,
901 1,
902 true,
903 true,
904 None,
905 );
906 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
907 assert_eq!(rules.unused_files, Severity::Error);
908 }
909
910 #[test]
911 fn resolve_rules_for_path_with_matching_override() {
912 let config = FallowConfig {
913 schema: None,
914 extends: vec![],
915 entry: vec![],
916 ignore_patterns: vec![],
917 ignore_findings: vec![],
918 framework: vec![],
919 workspaces: None,
920 ignore_dependencies: vec![],
921 ignore_unresolved_imports: vec![],
922 ignore_exports: vec![],
923 ignore_catalog_references: vec![],
924 ignore_dependency_overrides: vec![],
925 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
926 used_class_members: vec![],
927 ignore_decorators: vec![],
928 unused_component_props: crate::UnusedComponentPropsConfig::default(),
929 duplicates: DuplicatesConfig::default(),
930 similar_code: SimilarCodeConfig::default(),
931 health: HealthConfig::default(),
932 rules: RulesConfig::default(),
933 boundaries: BoundaryConfig::default(),
934 production: false.into(),
935 plugins: vec![],
936 rule_packs: vec![],
937 dynamically_loaded: vec![],
938 overrides: vec![ConfigOverride {
939 files: vec!["*.test.ts".to_string()],
940 rules: PartialRulesConfig {
941 unused_exports: Some(Severity::Off),
942 ..Default::default()
943 },
944 }],
945 regression: None,
946 type_aware: crate::TypeAwareConfig::default(),
947 audit: crate::config::AuditConfig::default(),
948 codeowners: None,
949 public_packages: vec![],
950 flags: FlagsConfig::default(),
951 security: SecurityConfig::default(),
952 fix: crate::config::FixConfig::default(),
953 resolve: ResolveConfig::default(),
954 sealed: false,
955 include_entry_exports: false,
956 auto_imports: false,
957 cache: CacheConfig::default(),
958 };
959 let resolved = config.resolve(
960 PathBuf::from("/project"),
961 OutputFormat::Human,
962 1,
963 true,
964 true,
965 None,
966 );
967
968 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
969 assert_eq!(test_rules.unused_exports, Severity::Off);
970 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
973 assert_eq!(src_rules.unused_exports, Severity::Error);
974 }
975
976 #[test]
977 fn resolve_rules_for_path_later_override_wins() {
978 let config = FallowConfig {
979 schema: None,
980 extends: vec![],
981 entry: vec![],
982 ignore_patterns: vec![],
983 ignore_findings: vec![],
984 framework: vec![],
985 workspaces: None,
986 ignore_dependencies: vec![],
987 ignore_unresolved_imports: vec![],
988 ignore_exports: vec![],
989 ignore_catalog_references: vec![],
990 ignore_dependency_overrides: vec![],
991 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
992 used_class_members: vec![],
993 ignore_decorators: vec![],
994 unused_component_props: crate::UnusedComponentPropsConfig::default(),
995 duplicates: DuplicatesConfig::default(),
996 similar_code: SimilarCodeConfig::default(),
997 health: HealthConfig::default(),
998 rules: RulesConfig::default(),
999 boundaries: BoundaryConfig::default(),
1000 production: false.into(),
1001 plugins: vec![],
1002 rule_packs: vec![],
1003 dynamically_loaded: vec![],
1004 overrides: vec![
1005 ConfigOverride {
1006 files: vec!["*.ts".to_string()],
1007 rules: PartialRulesConfig {
1008 unused_files: Some(Severity::Warn),
1009 ..Default::default()
1010 },
1011 },
1012 ConfigOverride {
1013 files: vec!["*.test.ts".to_string()],
1014 rules: PartialRulesConfig {
1015 unused_files: Some(Severity::Off),
1016 ..Default::default()
1017 },
1018 },
1019 ],
1020 regression: None,
1021 type_aware: crate::TypeAwareConfig::default(),
1022 audit: crate::config::AuditConfig::default(),
1023 codeowners: None,
1024 public_packages: vec![],
1025 flags: FlagsConfig::default(),
1026 security: SecurityConfig::default(),
1027 fix: crate::config::FixConfig::default(),
1028 resolve: ResolveConfig::default(),
1029 sealed: false,
1030 include_entry_exports: false,
1031 auto_imports: false,
1032 cache: CacheConfig::default(),
1033 };
1034 let resolved = config.resolve(
1035 PathBuf::from("/project"),
1036 OutputFormat::Human,
1037 1,
1038 true,
1039 true,
1040 None,
1041 );
1042
1043 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1044 assert_eq!(rules.unused_files, Severity::Off);
1045
1046 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1047 assert_eq!(rules2.unused_files, Severity::Warn);
1048 }
1049
1050 #[test]
1051 fn resolve_keeps_inter_file_rule_override_after_warning() {
1052 let config = FallowConfig {
1053 schema: None,
1054 extends: vec![],
1055 entry: vec![],
1056 ignore_patterns: vec![],
1057 ignore_findings: vec![],
1058 framework: vec![],
1059 workspaces: None,
1060 ignore_dependencies: vec![],
1061 ignore_unresolved_imports: vec![],
1062 ignore_exports: vec![],
1063 ignore_catalog_references: vec![],
1064 ignore_dependency_overrides: vec![],
1065 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1066 used_class_members: vec![],
1067 ignore_decorators: vec![],
1068 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1069 duplicates: DuplicatesConfig::default(),
1070 similar_code: SimilarCodeConfig::default(),
1071 health: HealthConfig::default(),
1072 rules: RulesConfig::default(),
1073 boundaries: BoundaryConfig::default(),
1074 production: false.into(),
1075 plugins: vec![],
1076 rule_packs: vec![],
1077 dynamically_loaded: vec![],
1078 overrides: vec![ConfigOverride {
1079 files: vec!["**/ui/**".to_string()],
1080 rules: PartialRulesConfig {
1081 duplicate_exports: Some(Severity::Off),
1082 unused_files: Some(Severity::Warn),
1083 ..Default::default()
1084 },
1085 }],
1086 regression: None,
1087 type_aware: crate::TypeAwareConfig::default(),
1088 audit: crate::config::AuditConfig::default(),
1089 codeowners: None,
1090 public_packages: vec![],
1091 flags: FlagsConfig::default(),
1092 security: SecurityConfig::default(),
1093 fix: crate::config::FixConfig::default(),
1094 resolve: ResolveConfig::default(),
1095 sealed: false,
1096 include_entry_exports: false,
1097 auto_imports: false,
1098 cache: CacheConfig::default(),
1099 };
1100 let resolved = config.resolve(
1101 PathBuf::from("/project"),
1102 OutputFormat::Human,
1103 1,
1104 true,
1105 true,
1106 None,
1107 );
1108 assert_eq!(
1109 resolved.overrides.len(),
1110 1,
1111 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1112 );
1113 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1114 assert_eq!(rules.unused_files, Severity::Warn);
1115 }
1116
1117 #[test]
1118 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1119 reset_inter_file_warn_dedup_for_test();
1120 let files_a = vec!["__test_dedup_a/*".to_string()];
1121 let files_b = vec!["__test_dedup_b/*".to_string()];
1122
1123 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1124 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1125 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1126
1127 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1128 assert!(!record_inter_file_warn_seen(
1129 "circular-dependency",
1130 &files_a
1131 ));
1132
1133 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1134
1135 let files_reordered = vec![
1136 "__test_dedup_b/*".to_string(),
1137 "__test_dedup_a/*".to_string(),
1138 ];
1139 let files_natural = vec![
1140 "__test_dedup_a/*".to_string(),
1141 "__test_dedup_b/*".to_string(),
1142 ];
1143 reset_inter_file_warn_dedup_for_test();
1144 assert!(record_inter_file_warn_seen(
1145 "duplicate-exports",
1146 &files_natural
1147 ));
1148 assert!(!record_inter_file_warn_seen(
1149 "duplicate-exports",
1150 &files_reordered
1151 ));
1152 }
1153
1154 #[test]
1155 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1156 reset_inter_file_warn_dedup_for_test();
1157 let files = vec!["__test_resolve_dedup/**".to_string()];
1158 let build_config = || FallowConfig {
1159 schema: None,
1160 extends: vec![],
1161 entry: vec![],
1162 ignore_patterns: vec![],
1163 ignore_findings: vec![],
1164 framework: vec![],
1165 workspaces: None,
1166 ignore_dependencies: vec![],
1167 ignore_unresolved_imports: vec![],
1168 ignore_exports: vec![],
1169 ignore_catalog_references: vec![],
1170 ignore_dependency_overrides: vec![],
1171 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1172 used_class_members: vec![],
1173 ignore_decorators: vec![],
1174 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1175 duplicates: DuplicatesConfig::default(),
1176 similar_code: SimilarCodeConfig::default(),
1177 health: HealthConfig::default(),
1178 rules: RulesConfig::default(),
1179 boundaries: BoundaryConfig::default(),
1180 production: false.into(),
1181 plugins: vec![],
1182 rule_packs: vec![],
1183 dynamically_loaded: vec![],
1184 overrides: vec![ConfigOverride {
1185 files: files.clone(),
1186 rules: PartialRulesConfig {
1187 duplicate_exports: Some(Severity::Off),
1188 ..Default::default()
1189 },
1190 }],
1191 regression: None,
1192 type_aware: crate::TypeAwareConfig::default(),
1193 audit: crate::config::AuditConfig::default(),
1194 codeowners: None,
1195 public_packages: vec![],
1196 flags: FlagsConfig::default(),
1197 security: SecurityConfig::default(),
1198 fix: crate::config::FixConfig::default(),
1199 resolve: ResolveConfig::default(),
1200 sealed: false,
1201 include_entry_exports: false,
1202 auto_imports: false,
1203 cache: CacheConfig::default(),
1204 };
1205 for _ in 0..10 {
1206 let _ = build_config().resolve(
1207 PathBuf::from("/project"),
1208 OutputFormat::Human,
1209 1,
1210 true,
1211 true,
1212 None,
1213 );
1214 }
1215 assert!(
1216 !record_inter_file_warn_seen("duplicate-exports", &files),
1217 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1218 );
1219 }
1220
1221 fn make_config(production: bool) -> FallowConfig {
1223 FallowConfig {
1224 schema: None,
1225 extends: vec![],
1226 entry: vec![],
1227 ignore_patterns: vec![],
1228 ignore_findings: vec![],
1229 framework: vec![],
1230 workspaces: None,
1231 ignore_dependencies: vec![],
1232 ignore_unresolved_imports: vec![],
1233 ignore_exports: vec![],
1234 ignore_catalog_references: vec![],
1235 ignore_dependency_overrides: vec![],
1236 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1237 used_class_members: vec![],
1238 ignore_decorators: vec![],
1239 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1240 duplicates: DuplicatesConfig::default(),
1241 similar_code: SimilarCodeConfig::default(),
1242 health: HealthConfig::default(),
1243 rules: RulesConfig::default(),
1244 boundaries: BoundaryConfig::default(),
1245 production: production.into(),
1246 plugins: vec![],
1247 rule_packs: vec![],
1248 dynamically_loaded: vec![],
1249 overrides: vec![],
1250 regression: None,
1251 type_aware: crate::TypeAwareConfig::default(),
1252 audit: crate::config::AuditConfig::default(),
1253 codeowners: None,
1254 public_packages: vec![],
1255 flags: FlagsConfig::default(),
1256 security: SecurityConfig::default(),
1257 fix: crate::config::FixConfig::default(),
1258 resolve: ResolveConfig::default(),
1259 sealed: false,
1260 include_entry_exports: false,
1261 auto_imports: false,
1262 cache: CacheConfig::default(),
1263 }
1264 }
1265
1266 #[test]
1267 fn resolve_tracks_rule_pack_sources_in_config_order() {
1268 let dir = tempfile::tempdir().unwrap();
1269 std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1270 std::fs::write(
1271 dir.path().join("rule-packs/team-policy.jsonc"),
1272 r#"{
1273 "version": 1,
1274 "name": "team-policy",
1275 "rules": [
1276 {
1277 "id": "no-moment",
1278 "kind": "banned-import",
1279 "specifiers": ["moment"]
1280 }
1281 ]
1282}
1283"#,
1284 )
1285 .unwrap();
1286
1287 let mut config = make_config(false);
1288 config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1289
1290 let resolved = config.resolve(
1291 dir.path().to_path_buf(),
1292 OutputFormat::Human,
1293 1,
1294 true,
1295 true,
1296 None,
1297 );
1298
1299 assert_eq!(resolved.rule_packs.len(), 1);
1300 assert_eq!(resolved.rule_packs[0].name, "team-policy");
1301 assert_eq!(
1302 resolved.rule_pack_sources,
1303 vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1304 );
1305 }
1306
1307 #[test]
1308 fn resolve_production_forces_dev_deps_off() {
1309 let resolved = make_config(true).resolve(
1310 PathBuf::from("/project"),
1311 OutputFormat::Human,
1312 1,
1313 true,
1314 true,
1315 None,
1316 );
1317 assert_eq!(
1318 resolved.rules.unused_dev_dependencies,
1319 Severity::Off,
1320 "production mode should force unused_dev_dependencies to off"
1321 );
1322 }
1323
1324 #[test]
1325 fn resolve_production_forces_optional_deps_off() {
1326 let resolved = make_config(true).resolve(
1327 PathBuf::from("/project"),
1328 OutputFormat::Human,
1329 1,
1330 true,
1331 true,
1332 None,
1333 );
1334 assert_eq!(
1335 resolved.rules.unused_optional_dependencies,
1336 Severity::Off,
1337 "production mode should force unused_optional_dependencies to off"
1338 );
1339 }
1340
1341 #[test]
1342 fn resolve_production_preserves_other_rules() {
1343 let resolved = make_config(true).resolve(
1344 PathBuf::from("/project"),
1345 OutputFormat::Human,
1346 1,
1347 true,
1348 true,
1349 None,
1350 );
1351 assert_eq!(resolved.rules.unused_files, Severity::Error);
1352 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1353 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1354 }
1355
1356 #[test]
1357 fn resolve_non_production_keeps_dev_deps_default() {
1358 let resolved = make_config(false).resolve(
1359 PathBuf::from("/project"),
1360 OutputFormat::Human,
1361 1,
1362 true,
1363 true,
1364 None,
1365 );
1366 assert_eq!(
1367 resolved.rules.unused_dev_dependencies,
1368 Severity::Warn,
1369 "non-production should keep default severity"
1370 );
1371 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1372 }
1373
1374 #[test]
1375 fn resolve_production_flag_stored() {
1376 let resolved = make_config(true).resolve(
1377 PathBuf::from("/project"),
1378 OutputFormat::Human,
1379 1,
1380 true,
1381 true,
1382 None,
1383 );
1384 assert!(resolved.production);
1385
1386 let resolved2 = make_config(false).resolve(
1387 PathBuf::from("/project"),
1388 OutputFormat::Human,
1389 1,
1390 true,
1391 true,
1392 None,
1393 );
1394 assert!(!resolved2.production);
1395 }
1396
1397 #[test]
1398 fn resolve_default_ignores_node_modules() {
1399 let resolved = make_config(false).resolve(
1400 PathBuf::from("/project"),
1401 OutputFormat::Human,
1402 1,
1403 true,
1404 true,
1405 None,
1406 );
1407 assert!(
1408 resolved
1409 .ignore_patterns
1410 .is_match("node_modules/lodash/index.js")
1411 );
1412 assert!(
1413 resolved
1414 .ignore_patterns
1415 .is_match("packages/a/node_modules/react/index.js")
1416 );
1417 }
1418
1419 #[test]
1420 fn resolve_default_ignores_dist() {
1421 let resolved = make_config(false).resolve(
1422 PathBuf::from("/project"),
1423 OutputFormat::Human,
1424 1,
1425 true,
1426 true,
1427 None,
1428 );
1429 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1430 assert!(
1431 resolved
1432 .ignore_patterns
1433 .is_match("packages/ui/dist/index.js")
1434 );
1435 }
1436
1437 #[test]
1438 fn resolve_default_ignores_root_build_only() {
1439 let resolved = make_config(false).resolve(
1440 PathBuf::from("/project"),
1441 OutputFormat::Human,
1442 1,
1443 true,
1444 true,
1445 None,
1446 );
1447 assert!(
1448 resolved.ignore_patterns.is_match("build/output.js"),
1449 "root build/ should be ignored"
1450 );
1451 assert!(
1452 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1453 "nested build/ should NOT be ignored by default"
1454 );
1455 }
1456
1457 #[test]
1458 fn resolve_default_ignores_minified_files() {
1459 let resolved = make_config(false).resolve(
1460 PathBuf::from("/project"),
1461 OutputFormat::Human,
1462 1,
1463 true,
1464 true,
1465 None,
1466 );
1467 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1468 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1469 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1470 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1471 assert!(
1472 resolved
1473 .ignore_patterns
1474 .is_match("src/vendor/app.bundle.js")
1475 );
1476 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1478 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1479 }
1480
1481 #[test]
1482 fn resolve_max_file_size_bytes_default_and_unlimited() {
1483 assert_eq!(
1485 resolve_max_file_size_bytes(None),
1486 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1487 );
1488 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1490 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1492 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1493 }
1494
1495 #[test]
1496 fn resolve_sets_default_max_file_size() {
1497 let resolved = make_config(false).resolve(
1498 PathBuf::from("/project"),
1499 OutputFormat::Human,
1500 1,
1501 true,
1502 true,
1503 None,
1504 );
1505 assert_eq!(
1506 resolved.max_file_size_bytes,
1507 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1508 );
1509 }
1510
1511 #[test]
1512 fn resolve_default_ignores_git() {
1513 let resolved = make_config(false).resolve(
1514 PathBuf::from("/project"),
1515 OutputFormat::Human,
1516 1,
1517 true,
1518 true,
1519 None,
1520 );
1521 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1522 }
1523
1524 #[test]
1525 fn resolve_default_ignores_coverage() {
1526 let resolved = make_config(false).resolve(
1527 PathBuf::from("/project"),
1528 OutputFormat::Human,
1529 1,
1530 true,
1531 true,
1532 None,
1533 );
1534 assert!(
1535 resolved
1536 .ignore_patterns
1537 .is_match("coverage/lcov-report/index.js")
1538 );
1539 }
1540
1541 #[test]
1542 fn resolve_source_files_not_ignored_by_default() {
1543 let resolved = make_config(false).resolve(
1544 PathBuf::from("/project"),
1545 OutputFormat::Human,
1546 1,
1547 true,
1548 true,
1549 None,
1550 );
1551 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1552 assert!(
1553 !resolved
1554 .ignore_patterns
1555 .is_match("src/components/Button.tsx")
1556 );
1557 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1558 }
1559
1560 #[test]
1561 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1562 let mut config = make_config(false);
1563 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1564 let resolved = config.resolve(
1565 PathBuf::from("/project"),
1566 OutputFormat::Human,
1567 1,
1568 true,
1569 true,
1570 None,
1571 );
1572 assert!(
1573 resolved
1574 .ignore_patterns
1575 .is_match("src/__generated__/types.ts")
1576 );
1577 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1578 }
1579
1580 #[test]
1581 fn resolve_normalizes_leading_dot_ignore_patterns() {
1582 let mut config = make_config(false);
1583 config.ignore_patterns = vec!["./src/generated/**".to_string()];
1584 let resolved = config.resolve(
1585 PathBuf::from("/project"),
1586 OutputFormat::Human,
1587 1,
1588 true,
1589 true,
1590 None,
1591 );
1592
1593 assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1594 assert!(
1595 !resolved
1596 .ignore_patterns
1597 .is_match("./src/generated/client.ts")
1598 );
1599 }
1600
1601 #[test]
1602 fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1603 let mut config = make_config(false);
1604 config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1605 let resolved = config.resolve(
1606 PathBuf::from("/project"),
1607 OutputFormat::Human,
1608 1,
1609 true,
1610 true,
1611 None,
1612 );
1613
1614 assert!(
1615 resolved
1616 .ignore_unresolved_imports
1617 .iter()
1618 .any(|matcher| matcher.is_match("src/generated/client"))
1619 );
1620 assert!(
1621 !resolved
1622 .ignore_unresolved_imports
1623 .iter()
1624 .any(|matcher| matcher.is_match("./src/generated/client"))
1625 );
1626 }
1627
1628 #[test]
1629 fn resolve_passes_through_entry_patterns() {
1630 let mut config = make_config(false);
1631 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1632 let resolved = config.resolve(
1633 PathBuf::from("/project"),
1634 OutputFormat::Human,
1635 1,
1636 true,
1637 true,
1638 None,
1639 );
1640 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1641 }
1642
1643 #[test]
1644 fn resolve_passes_through_ignore_dependencies() {
1645 let mut config = make_config(false);
1646 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1647 let resolved = config.resolve(
1648 PathBuf::from("/project"),
1649 OutputFormat::Human,
1650 1,
1651 true,
1652 true,
1653 None,
1654 );
1655 assert_eq!(
1656 resolved.ignore_dependencies,
1657 vec!["postcss", "autoprefixer"]
1658 );
1659 }
1660
1661 #[test]
1662 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1663 let mut config = make_config(false);
1664 config.ignore_unresolved_imports = vec![
1665 "@example/icons".to_string(),
1666 "@example/icons/**".to_string(),
1667 "../generated/**".to_string(),
1668 ];
1669 let resolved = config.resolve(
1670 PathBuf::from("/project"),
1671 OutputFormat::Human,
1672 1,
1673 true,
1674 true,
1675 None,
1676 );
1677
1678 assert!(
1679 resolved
1680 .ignore_unresolved_imports
1681 .iter()
1682 .any(|matcher| matcher.is_match("@example/icons"))
1683 );
1684 assert!(
1685 resolved
1686 .ignore_unresolved_imports
1687 .iter()
1688 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1689 );
1690 assert!(
1691 resolved
1692 .ignore_unresolved_imports
1693 .iter()
1694 .any(|matcher| matcher.is_match("../generated/client"))
1695 );
1696 }
1697
1698 #[test]
1699 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1700 let mut config = make_config(false);
1701 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1702 let resolved = config.resolve(
1703 PathBuf::from("/project"),
1704 OutputFormat::Human,
1705 1,
1706 true,
1707 true,
1708 None,
1709 );
1710
1711 assert!(
1712 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1713 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1714 );
1715 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1716 }
1717
1718 #[test]
1719 fn resolve_sets_cache_dir() {
1720 let resolved = make_config(false).resolve(
1721 PathBuf::from("/my/project"),
1722 OutputFormat::Human,
1723 1,
1724 true,
1725 true,
1726 None,
1727 );
1728 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1729 }
1730
1731 #[test]
1732 fn resolve_uses_relative_configured_cache_dir_from_root() {
1733 let config = FallowConfig {
1734 cache: crate::CacheConfig {
1735 dir: Some(PathBuf::from(".cache/fallow")),
1736 ..Default::default()
1737 },
1738 ..make_config(false)
1739 };
1740 let resolved = config.resolve(
1741 PathBuf::from("/my/project"),
1742 OutputFormat::Human,
1743 1,
1744 false,
1745 true,
1746 None,
1747 );
1748 assert_eq!(
1749 resolved.cache_dir,
1750 PathBuf::from("/my/project/.cache/fallow")
1751 );
1752 }
1753
1754 #[test]
1755 fn resolve_keeps_absolute_configured_cache_dir() {
1756 let config = FallowConfig {
1757 cache: crate::CacheConfig {
1758 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1759 ..Default::default()
1760 },
1761 ..make_config(false)
1762 };
1763 let resolved = config.resolve(
1764 PathBuf::from("/my/project"),
1765 OutputFormat::Human,
1766 1,
1767 false,
1768 true,
1769 None,
1770 );
1771 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1772 }
1773
1774 #[test]
1775 fn resolve_passes_through_thread_count() {
1776 let resolved = make_config(false).resolve(
1777 PathBuf::from("/project"),
1778 OutputFormat::Human,
1779 8,
1780 true,
1781 true,
1782 None,
1783 );
1784 assert_eq!(resolved.threads, 8);
1785 }
1786
1787 #[test]
1788 fn resolve_passes_through_quiet_flag() {
1789 let resolved = make_config(false).resolve(
1790 PathBuf::from("/project"),
1791 OutputFormat::Human,
1792 1,
1793 true,
1794 false,
1795 None,
1796 );
1797 assert!(!resolved.quiet);
1798
1799 let resolved2 = make_config(false).resolve(
1800 PathBuf::from("/project"),
1801 OutputFormat::Human,
1802 1,
1803 true,
1804 true,
1805 None,
1806 );
1807 assert!(resolved2.quiet);
1808 }
1809
1810 #[test]
1811 fn resolve_passes_through_no_cache_flag() {
1812 let resolved_no_cache = make_config(false).resolve(
1813 PathBuf::from("/project"),
1814 OutputFormat::Human,
1815 1,
1816 true,
1817 true,
1818 None,
1819 );
1820 assert!(resolved_no_cache.no_cache);
1821
1822 let resolved_with_cache = make_config(false).resolve(
1823 PathBuf::from("/project"),
1824 OutputFormat::Human,
1825 1,
1826 false,
1827 true,
1828 None,
1829 );
1830 assert!(!resolved_with_cache.no_cache);
1831 }
1832
1833 #[test]
1834 #[should_panic(expected = "validated at config load time")]
1835 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1836 let mut config = make_config(false);
1837 config.overrides = vec![ConfigOverride {
1838 files: vec!["[invalid".to_string()],
1839 rules: PartialRulesConfig {
1840 unused_files: Some(Severity::Off),
1841 ..Default::default()
1842 },
1843 }];
1844 let _ = config.resolve(
1845 PathBuf::from("/project"),
1846 OutputFormat::Human,
1847 1,
1848 true,
1849 true,
1850 None,
1851 );
1852 }
1853
1854 #[test]
1855 fn resolve_override_with_empty_files_skipped() {
1856 let mut config = make_config(false);
1857 config.overrides = vec![ConfigOverride {
1858 files: vec![],
1859 rules: PartialRulesConfig {
1860 unused_files: Some(Severity::Off),
1861 ..Default::default()
1862 },
1863 }];
1864 let resolved = config.resolve(
1865 PathBuf::from("/project"),
1866 OutputFormat::Human,
1867 1,
1868 true,
1869 true,
1870 None,
1871 );
1872 assert!(
1873 resolved.overrides.is_empty(),
1874 "override with no file patterns should be skipped"
1875 );
1876 }
1877
1878 #[test]
1879 fn resolve_multiple_valid_overrides() {
1880 let mut config = make_config(false);
1881 config.overrides = vec![
1882 ConfigOverride {
1883 files: vec!["*.test.ts".to_string()],
1884 rules: PartialRulesConfig {
1885 unused_exports: Some(Severity::Off),
1886 ..Default::default()
1887 },
1888 },
1889 ConfigOverride {
1890 files: vec!["*.stories.tsx".to_string()],
1891 rules: PartialRulesConfig {
1892 unused_files: Some(Severity::Off),
1893 ..Default::default()
1894 },
1895 },
1896 ];
1897 let resolved = config.resolve(
1898 PathBuf::from("/project"),
1899 OutputFormat::Human,
1900 1,
1901 true,
1902 true,
1903 None,
1904 );
1905 assert_eq!(resolved.overrides.len(), 2);
1906 }
1907
1908 #[test]
1909 fn ignore_export_rule_deserialize() {
1910 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1911 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1912 assert_eq!(rule.file, "src/types/*.ts");
1913 assert_eq!(rule.exports, vec!["*"]);
1914 }
1915
1916 #[test]
1917 fn ignore_export_rule_specific_exports() {
1918 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1919 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1920 assert_eq!(rule.exports.len(), 3);
1921 assert!(rule.exports.contains(&"FOO".to_string()));
1922 }
1923
1924 mod proptests {
1925 use super::*;
1926 use proptest::prelude::*;
1927
1928 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1929 make_config(production).resolve(
1930 PathBuf::from("/project"),
1931 OutputFormat::Human,
1932 1,
1933 true,
1934 true,
1935 None,
1936 )
1937 }
1938
1939 proptest! {
1940 #[test]
1942 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1943 let resolved = arb_resolved_config(production);
1944 prop_assert!(
1945 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1946 "Default ignore should match node_modules"
1947 );
1948 prop_assert!(
1949 resolved.ignore_patterns.is_match("dist/bundle.js"),
1950 "Default ignore should match dist"
1951 );
1952 }
1953
1954 #[test]
1956 fn production_forces_dev_deps_off(_unused in Just(())) {
1957 let resolved = arb_resolved_config(true);
1958 prop_assert_eq!(
1959 resolved.rules.unused_dev_dependencies,
1960 Severity::Off,
1961 "Production should force unused_dev_dependencies off"
1962 );
1963 prop_assert_eq!(
1964 resolved.rules.unused_optional_dependencies,
1965 Severity::Off,
1966 "Production should force unused_optional_dependencies off"
1967 );
1968 }
1969
1970 #[test]
1972 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1973 let resolved = arb_resolved_config(false);
1974 prop_assert_eq!(
1975 resolved.rules.unused_dev_dependencies,
1976 Severity::Warn,
1977 "Non-production should keep default dev dep severity"
1978 );
1979 }
1980
1981 #[test]
1983 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1984 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1985 let expected_cache = root.join(".fallow");
1986 let resolved = make_config(false).resolve(
1987 root,
1988 OutputFormat::Human,
1989 1,
1990 true,
1991 true,
1992 None,
1993 );
1994 prop_assert_eq!(
1995 resolved.cache_dir, expected_cache,
1996 "Default cache dir should be root/.fallow"
1997 );
1998 }
1999
2000 #[test]
2002 fn threads_passed_through(threads in 1..64usize) {
2003 let resolved = make_config(false).resolve(
2004 PathBuf::from("/project"),
2005 OutputFormat::Human,
2006 threads,
2007 true,
2008 true, None,
2009 );
2010 prop_assert_eq!(
2011 resolved.threads, threads,
2012 "Thread count should be passed through"
2013 );
2014 }
2015
2016 #[test]
2020 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2021 let mut config = make_config(false);
2022 config.ignore_patterns = vec![pattern];
2023 let resolved = config.resolve(
2024 PathBuf::from("/project"),
2025 OutputFormat::Human,
2026 1,
2027 true,
2028 true, None,
2029 );
2030 prop_assert!(
2031 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2032 "Default node_modules ignore should still be active"
2033 );
2034 }
2035 }
2036 }
2037
2038 #[test]
2039 fn resolve_expands_boundary_preset() {
2040 use crate::config::boundaries::BoundaryPreset;
2041
2042 let mut config = make_config(false);
2043 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2044 let resolved = config.resolve(
2045 PathBuf::from("/project"),
2046 OutputFormat::Human,
2047 1,
2048 true,
2049 true,
2050 None,
2051 );
2052 assert_eq!(resolved.boundaries.zones.len(), 3);
2053 assert_eq!(resolved.boundaries.rules.len(), 3);
2054 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2055 assert_eq!(
2056 resolved.boundaries.classify_zone("src/adapters/http.ts"),
2057 Some("adapters")
2058 );
2059 }
2060
2061 #[test]
2062 fn resolve_boundary_preset_with_user_override() {
2063 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2064
2065 let mut config = make_config(false);
2066 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2067 config.boundaries.zones = vec![BoundaryZone {
2068 name: "domain".to_string(),
2069 patterns: vec!["src/core/**".to_string()],
2070 auto_discover: vec![],
2071 root: None,
2072 }];
2073 let resolved = config.resolve(
2074 PathBuf::from("/project"),
2075 OutputFormat::Human,
2076 1,
2077 true,
2078 true,
2079 None,
2080 );
2081 assert_eq!(resolved.boundaries.zones.len(), 3);
2082 assert_eq!(
2083 resolved.boundaries.classify_zone("src/core/user.ts"),
2084 Some("domain")
2085 );
2086 assert_eq!(
2087 resolved.boundaries.classify_zone("src/domain/user.ts"),
2088 None
2089 );
2090 }
2091
2092 #[test]
2093 fn resolve_no_preset_unchanged() {
2094 let config = make_config(false);
2095 let resolved = config.resolve(
2096 PathBuf::from("/project"),
2097 OutputFormat::Human,
2098 1,
2099 true,
2100 true,
2101 None,
2102 );
2103 assert!(resolved.boundaries.is_empty());
2104 }
2105}