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::used_class_members::UsedClassMemberRule;
19use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
20
21use super::IgnoreExportsUsedInFileConfig;
22use super::{BoundaryConfig, FallowConfig, ProductionConfig, SecurityConfig};
23
24static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
26
27fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
29 let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
30 sorted.sort_unstable();
31 let mut hasher = DefaultHasher::new();
32 rule_name.hash(&mut hasher);
33 for s in &sorted {
34 s.hash(&mut hasher);
35 }
36 hasher.finish()
37}
38
39fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
41 let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
42 let key = inter_file_warn_key(rule_name, files);
43 seen.lock().map_or(true, |mut set| set.insert(key))
44}
45
46#[cfg(test)]
47fn reset_inter_file_warn_dedup_for_test() {
48 if let Some(seen) = INTER_FILE_WARN_SEEN.get()
49 && let Ok(mut set) = seen.lock()
50 {
51 set.clear();
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
57pub struct IgnoreExportRule {
58 pub file: String,
60 pub exports: Vec<String>,
62}
63
64#[derive(Debug)]
66pub struct CompiledIgnoreExportRule {
67 pub matcher: globset::GlobMatcher,
68 pub exports: Vec<String>,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct IgnoreCatalogReferenceRule {
75 pub package: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub catalog: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub consumer: Option<String>,
80}
81
82#[derive(Debug)]
84pub struct CompiledIgnoreCatalogReferenceRule {
85 pub package: String,
86 pub catalog: Option<String>,
87 pub consumer_matcher: Option<globset::GlobMatcher>,
88}
89
90impl CompiledIgnoreCatalogReferenceRule {
91 #[must_use]
93 pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
94 if self.package != package {
95 return false;
96 }
97 if let Some(catalog_filter) = &self.catalog
98 && catalog_filter != catalog
99 {
100 return false;
101 }
102 if let Some(matcher) = &self.consumer_matcher
103 && !matcher.is_match(consumer_path)
104 {
105 return false;
106 }
107 true
108 }
109}
110
111#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114pub struct IgnoreDependencyOverrideRule {
115 pub package: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub source: Option<String>,
118}
119
120#[derive(Debug)]
122pub struct CompiledIgnoreDependencyOverrideRule {
123 pub package: String,
124 pub source: Option<String>,
125}
126
127impl CompiledIgnoreDependencyOverrideRule {
128 #[must_use]
130 pub fn matches(&self, package: &str, source_label: &str) -> bool {
131 if self.package != package {
132 return false;
133 }
134 if let Some(source_filter) = &self.source
135 && source_filter != source_label
136 {
137 return false;
138 }
139 true
140 }
141}
142
143#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
145#[serde(rename_all = "camelCase")]
146pub struct ConfigOverride {
147 pub files: Vec<String>,
148 #[serde(default)]
149 pub rules: PartialRulesConfig,
150}
151
152#[derive(Debug)]
154pub struct ResolvedOverride {
155 pub matchers: Vec<globset::GlobMatcher>,
156 pub rules: PartialRulesConfig,
157}
158
159#[derive(Debug)]
161pub struct ResolvedConfig {
162 pub root: PathBuf,
163 pub entry_patterns: Vec<String>,
164 pub ignore_patterns: GlobSet,
165 pub output: OutputFormat,
166 pub cache_dir: PathBuf,
167 pub threads: usize,
168 pub no_cache: bool,
169 pub cache_max_size_mb: Option<u32>,
170 pub cache_config_hash: u64,
171 pub ignore_dependencies: Vec<String>,
172 pub ignore_unresolved_imports: Vec<GlobMatcher>,
173 pub ignore_export_rules: Vec<IgnoreExportRule>,
174 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
175 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
176 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
177 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
178 pub used_class_members: Vec<UsedClassMemberRule>,
179 pub ignore_decorators: Vec<String>,
180 pub duplicates: DuplicatesConfig,
181 pub health: HealthConfig,
182 pub rules: RulesConfig,
183 pub boundaries: ResolvedBoundaryConfig,
184 pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
189 pub production: bool,
190 pub quiet: bool,
191 pub external_plugins: Vec<ExternalPluginDef>,
192 pub dynamically_loaded: Vec<String>,
193 pub overrides: Vec<ResolvedOverride>,
194 pub regression: Option<super::RegressionConfig>,
195 pub audit: super::AuditConfig,
196 pub codeowners: Option<String>,
197 pub public_packages: Vec<String>,
198 pub flags: FlagsConfig,
199 pub security: SecurityConfig,
200 pub fix: super::FixConfig,
201 pub resolve: ResolveConfig,
202 pub include_entry_exports: bool,
203 pub auto_imports: bool,
204 pub max_file_size_bytes: Option<u64>,
213}
214
215pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
219
220pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
222
223#[must_use]
228pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
229 match max_file_size_mb {
230 None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
231 Some(0) => None,
232 Some(mb) => Some(u64::from(mb) * 1024 * 1024),
233 }
234}
235
236fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
238 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
239 names.sort_unstable();
240 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
241 for name in names {
242 hasher.update(&(name.len() as u32).to_le_bytes());
243 hasher.update(name.as_bytes());
244 }
245 hasher.digest()
246}
247
248fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
249 let Some(dir) = configured else {
250 return root.join(".fallow");
251 };
252 if dir.is_absolute() {
253 dir
254 } else {
255 root.join(dir)
256 }
257}
258
259#[expect(
260 clippy::expect_used,
261 reason = "user glob patterns are validated before config resolution"
262)]
263fn compile_ignore_patterns(ignore_patterns: &[String]) -> GlobSet {
264 let mut ignore_builder = GlobSetBuilder::new();
265 for pattern in ignore_patterns {
266 ignore_builder.add(
267 Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
268 );
269 }
270
271 let default_ignores = [
272 "**/node_modules/**",
273 "**/dist/**",
274 "build/**",
275 "**/.git/**",
276 "**/coverage/**",
277 "**/*.min.js",
278 "**/*.min.mjs",
279 "**/*.min.cjs",
280 "**/*.bundle.js",
281 ];
282 for pattern in &default_ignores {
283 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
284 }
285
286 ignore_builder.build().unwrap_or_default()
287}
288
289#[expect(
290 clippy::expect_used,
291 reason = "user glob patterns are validated before config resolution"
292)]
293fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
294 patterns
295 .iter()
296 .map(|pattern| {
297 Glob::new(pattern)
298 .expect("ignoreUnresolvedImports entry was validated at config load time")
299 .compile_matcher()
300 })
301 .collect()
302}
303
304fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
305 if production {
306 rules.unused_dev_dependencies = Severity::Off;
307 rules.unused_optional_dependencies = Severity::Off;
308 }
309 rules
310}
311
312fn resolve_boundaries(
313 mut boundaries: super::boundaries::BoundaryConfig,
314 root: &Path,
315) -> ResolvedBoundaryConfig {
316 if boundaries.preset.is_some() {
317 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
318 .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
319 .unwrap_or_else(|| "src".to_owned());
320 if source_root != "src" {
321 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
322 }
323 boundaries.expand(&source_root);
324 }
325 let logical_groups = boundaries.expand_auto_discover(root);
326 let mut resolved = boundaries.resolve();
327 resolved.logical_groups = logical_groups;
328 resolved
329}
330
331fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
332 if rules.duplicate_exports.is_some() && record_inter_file_warn_seen("duplicate-exports", files)
333 {
334 let files = files.join(", ");
335 tracing::warn!(
336 "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."
337 );
338 }
339 if rules.circular_dependencies.is_some()
340 && record_inter_file_warn_seen("circular-dependency", files)
341 {
342 let files = files.join(", ");
343 tracing::warn!(
344 "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."
345 );
346 }
347 if rules.re_export_cycle.is_some() && record_inter_file_warn_seen("re-export-cycle", files) {
348 let files = files.join(", ");
349 tracing::warn!(
350 "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."
351 );
352 }
353}
354
355#[expect(
356 clippy::expect_used,
357 reason = "override glob patterns are validated before config resolution"
358)]
359fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
360 overrides
361 .into_iter()
362 .filter_map(|override_entry| {
363 warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
364 let matchers: Vec<globset::GlobMatcher> = override_entry
365 .files
366 .iter()
367 .map(|pattern| {
368 Glob::new(pattern)
369 .expect("overrides[].files pattern was validated at config load time")
370 .compile_matcher()
371 })
372 .collect();
373 if matchers.is_empty() {
374 None
375 } else {
376 Some(ResolvedOverride {
377 matchers,
378 rules: override_entry.rules,
379 })
380 }
381 })
382 .collect()
383}
384
385#[expect(
387 clippy::expect_used,
388 reason = "user glob patterns are validated before config resolution"
389)]
390fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
391 rules
392 .iter()
393 .map(|rule| CompiledIgnoreExportRule {
394 matcher: Glob::new(&rule.file)
395 .expect("ignoreExports[].file was validated at config load time")
396 .compile_matcher(),
397 exports: rule.exports.clone(),
398 })
399 .collect()
400}
401
402#[expect(
404 clippy::expect_used,
405 reason = "user glob patterns are validated before config resolution"
406)]
407fn compile_ignore_catalog_reference_rules(
408 rules: &[IgnoreCatalogReferenceRule],
409) -> Vec<CompiledIgnoreCatalogReferenceRule> {
410 rules
411 .iter()
412 .map(|rule| CompiledIgnoreCatalogReferenceRule {
413 package: rule.package.clone(),
414 catalog: rule.catalog.clone(),
415 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
416 Glob::new(pattern)
417 .expect("ignoreCatalogReferences[].consumer was validated at config load time")
418 .compile_matcher()
419 }),
420 })
421 .collect()
422}
423
424fn compile_ignore_dependency_override_rules(
426 rules: &[IgnoreDependencyOverrideRule],
427) -> Vec<CompiledIgnoreDependencyOverrideRule> {
428 rules
429 .iter()
430 .map(|rule| CompiledIgnoreDependencyOverrideRule {
431 package: rule.package.clone(),
432 source: rule.source.clone(),
433 })
434 .collect()
435}
436
437struct CompiledIgnoreSettings {
438 patterns: GlobSet,
439 unresolved_imports: Vec<GlobMatcher>,
440 exports: Vec<CompiledIgnoreExportRule>,
441 catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
442 dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
443}
444
445fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
446 CompiledIgnoreSettings {
447 patterns: compile_ignore_patterns(&config.ignore_patterns),
448 unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
449 exports: compile_ignore_export_rules(&config.ignore_exports),
450 catalog_references: compile_ignore_catalog_reference_rules(
451 &config.ignore_catalog_references,
452 ),
453 dependency_overrides: compile_ignore_dependency_override_rules(
454 &config.ignore_dependency_overrides,
455 ),
456 }
457}
458
459struct ResolvedPluginSettings {
460 external_plugins: Vec<ExternalPluginDef>,
461 rule_packs: Vec<crate::rule_pack::RulePackDef>,
462}
463
464fn resolve_plugin_settings(
465 root: &Path,
466 configured_plugins: &[String],
467 framework: Vec<ExternalPluginDef>,
468 rule_packs: &[String],
469) -> ResolvedPluginSettings {
470 let mut external_plugins = discover_external_plugins(root, configured_plugins);
471 external_plugins.extend(framework);
472
473 let rule_packs = crate::rule_pack::load_rule_packs(root, rule_packs).unwrap_or_else(|errors| {
474 for error in &errors {
475 tracing::error!("invalid rule pack: {error}");
476 }
477 Vec::new()
478 });
479
480 ResolvedPluginSettings {
481 external_plugins,
482 rule_packs,
483 }
484}
485
486struct ResolvedCacheSettings {
487 dir: PathBuf,
488 max_size_mb: Option<u32>,
489 config_hash: u64,
490}
491
492struct ResolvedProductionRules {
493 production: bool,
494 rules: RulesConfig,
495}
496
497fn resolve_production_rules(
498 production_config: ProductionConfig,
499 rules: RulesConfig,
500) -> ResolvedProductionRules {
501 let production = production_config.global();
502 ResolvedProductionRules {
503 production,
504 rules: resolve_rules_for_production(rules, production),
505 }
506}
507
508fn resolve_cache_settings(
509 root: &Path,
510 configured_dir: Option<PathBuf>,
511 configured_max_size_mb: Option<u32>,
512 override_max_size_mb: Option<u32>,
513 no_cache: bool,
514 external_plugins: &[ExternalPluginDef],
515) -> ResolvedCacheSettings {
516 ResolvedCacheSettings {
517 dir: resolve_cache_dir(root, configured_dir),
518 max_size_mb: override_max_size_mb.or(configured_max_size_mb),
519 config_hash: if no_cache {
520 0
521 } else {
522 compute_cache_config_hash(external_plugins)
523 },
524 }
525}
526
527fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
528 SecurityConfig {
529 request_receivers: security.normalized_request_receivers(),
530 ..security
531 }
532}
533
534struct ResolvedPathPolicySettings {
535 boundaries: ResolvedBoundaryConfig,
536 overrides: Vec<ResolvedOverride>,
537}
538
539fn resolve_path_policy_settings(
540 boundaries: BoundaryConfig,
541 overrides: Vec<ConfigOverride>,
542 root: &Path,
543) -> ResolvedPathPolicySettings {
544 ResolvedPathPolicySettings {
545 boundaries: resolve_boundaries(boundaries, root),
546 overrides: compile_overrides(overrides),
547 }
548}
549
550impl FallowConfig {
551 pub fn resolve(
553 self,
554 root: PathBuf,
555 output: OutputFormat,
556 threads: usize,
557 no_cache: bool,
558 quiet: bool,
559 cache_max_size_mb: Option<u32>,
560 ) -> ResolvedConfig {
561 let compiled_ignores = compile_ignore_settings(&self);
562
563 let production_rules = resolve_production_rules(self.production, self.rules);
564
565 let plugins =
566 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
567
568 let cache = resolve_cache_settings(
569 &root,
570 self.cache.dir,
571 self.cache.max_size_mb,
572 cache_max_size_mb,
573 no_cache,
574 &plugins.external_plugins,
575 );
576
577 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
578
579 ResolvedConfig {
580 root,
581 entry_patterns: self.entry,
582 ignore_patterns: compiled_ignores.patterns,
583 output,
584 cache_dir: cache.dir,
585 threads,
586 no_cache,
587 cache_max_size_mb: cache.max_size_mb,
588 cache_config_hash: cache.config_hash,
589 ignore_dependencies: self.ignore_dependencies,
590 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
591 ignore_export_rules: self.ignore_exports,
592 compiled_ignore_exports: compiled_ignores.exports,
593 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
594 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
595 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
596 used_class_members: self.used_class_members,
597 ignore_decorators: self.ignore_decorators,
598 duplicates: self.duplicates,
599 health: self.health,
600 rules: production_rules.rules,
601 boundaries: path_policy.boundaries,
602 rule_packs: plugins.rule_packs,
603 production: production_rules.production,
604 quiet,
605 external_plugins: plugins.external_plugins,
606 dynamically_loaded: self.dynamically_loaded,
607 overrides: path_policy.overrides,
608 regression: self.regression,
609 audit: self.audit,
610 codeowners: self.codeowners,
611 public_packages: self.public_packages,
612 flags: self.flags,
613 security: normalize_security_config(self.security),
614 fix: self.fix,
615 resolve: self.resolve,
616 include_entry_exports: self.include_entry_exports,
617 auto_imports: self.auto_imports,
618 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
619 }
620 }
621}
622
623impl ResolvedConfig {
624 #[must_use]
627 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
628 if self.overrides.is_empty() {
629 return self.rules.clone();
630 }
631
632 let relative = path.strip_prefix(&self.root).unwrap_or(path);
633 let relative_str = relative.to_string_lossy();
634
635 let mut rules = self.rules.clone();
636 for override_entry in &self.overrides {
637 let matches = override_entry
638 .matchers
639 .iter()
640 .any(|m| m.is_match(relative_str.as_ref()));
641 if matches {
642 rules.apply_partial(&override_entry.rules);
643 }
644 }
645 rules
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::CacheConfig;
653 use crate::config::boundaries::BoundaryConfig;
654 use crate::config::health::HealthConfig;
655
656 #[test]
657 fn overrides_deserialize() {
658 let json_str = r#"{
659 "overrides": [{
660 "files": ["*.test.ts"],
661 "rules": {
662 "unused-exports": "off"
663 }
664 }]
665 }"#;
666 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
667 assert_eq!(config.overrides.len(), 1);
668 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
669 assert_eq!(
670 config.overrides[0].rules.unused_exports,
671 Some(Severity::Off)
672 );
673 assert_eq!(config.overrides[0].rules.unused_files, None);
674 }
675
676 #[test]
677 fn resolve_rules_for_path_no_overrides() {
678 let config = FallowConfig {
679 schema: None,
680 extends: vec![],
681 entry: vec![],
682 ignore_patterns: vec![],
683 framework: vec![],
684 workspaces: None,
685 ignore_dependencies: vec![],
686 ignore_unresolved_imports: vec![],
687 ignore_exports: vec![],
688 ignore_catalog_references: vec![],
689 ignore_dependency_overrides: vec![],
690 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
691 used_class_members: vec![],
692 ignore_decorators: vec![],
693 duplicates: DuplicatesConfig::default(),
694 health: HealthConfig::default(),
695 rules: RulesConfig::default(),
696 boundaries: BoundaryConfig::default(),
697 production: false.into(),
698 plugins: vec![],
699 rule_packs: vec![],
700 dynamically_loaded: vec![],
701 overrides: vec![],
702 regression: None,
703 audit: crate::config::AuditConfig::default(),
704 codeowners: None,
705 public_packages: vec![],
706 flags: FlagsConfig::default(),
707 security: SecurityConfig::default(),
708 fix: crate::config::FixConfig::default(),
709 resolve: ResolveConfig::default(),
710 sealed: false,
711 include_entry_exports: false,
712 auto_imports: false,
713 cache: CacheConfig::default(),
714 };
715 let resolved = config.resolve(
716 PathBuf::from("/project"),
717 OutputFormat::Human,
718 1,
719 true,
720 true,
721 None,
722 );
723 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
724 assert_eq!(rules.unused_files, Severity::Error);
725 }
726
727 #[test]
728 fn resolve_rules_for_path_with_matching_override() {
729 let config = FallowConfig {
730 schema: None,
731 extends: vec![],
732 entry: vec![],
733 ignore_patterns: vec![],
734 framework: vec![],
735 workspaces: None,
736 ignore_dependencies: vec![],
737 ignore_unresolved_imports: vec![],
738 ignore_exports: vec![],
739 ignore_catalog_references: vec![],
740 ignore_dependency_overrides: vec![],
741 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
742 used_class_members: vec![],
743 ignore_decorators: vec![],
744 duplicates: DuplicatesConfig::default(),
745 health: HealthConfig::default(),
746 rules: RulesConfig::default(),
747 boundaries: BoundaryConfig::default(),
748 production: false.into(),
749 plugins: vec![],
750 rule_packs: vec![],
751 dynamically_loaded: vec![],
752 overrides: vec![ConfigOverride {
753 files: vec!["*.test.ts".to_string()],
754 rules: PartialRulesConfig {
755 unused_exports: Some(Severity::Off),
756 ..Default::default()
757 },
758 }],
759 regression: None,
760 audit: crate::config::AuditConfig::default(),
761 codeowners: None,
762 public_packages: vec![],
763 flags: FlagsConfig::default(),
764 security: SecurityConfig::default(),
765 fix: crate::config::FixConfig::default(),
766 resolve: ResolveConfig::default(),
767 sealed: false,
768 include_entry_exports: false,
769 auto_imports: false,
770 cache: CacheConfig::default(),
771 };
772 let resolved = config.resolve(
773 PathBuf::from("/project"),
774 OutputFormat::Human,
775 1,
776 true,
777 true,
778 None,
779 );
780
781 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
782 assert_eq!(test_rules.unused_exports, Severity::Off);
783 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
786 assert_eq!(src_rules.unused_exports, Severity::Error);
787 }
788
789 #[test]
790 fn resolve_rules_for_path_later_override_wins() {
791 let config = FallowConfig {
792 schema: None,
793 extends: vec![],
794 entry: vec![],
795 ignore_patterns: vec![],
796 framework: vec![],
797 workspaces: None,
798 ignore_dependencies: vec![],
799 ignore_unresolved_imports: vec![],
800 ignore_exports: vec![],
801 ignore_catalog_references: vec![],
802 ignore_dependency_overrides: vec![],
803 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
804 used_class_members: vec![],
805 ignore_decorators: vec![],
806 duplicates: DuplicatesConfig::default(),
807 health: HealthConfig::default(),
808 rules: RulesConfig::default(),
809 boundaries: BoundaryConfig::default(),
810 production: false.into(),
811 plugins: vec![],
812 rule_packs: vec![],
813 dynamically_loaded: vec![],
814 overrides: vec![
815 ConfigOverride {
816 files: vec!["*.ts".to_string()],
817 rules: PartialRulesConfig {
818 unused_files: Some(Severity::Warn),
819 ..Default::default()
820 },
821 },
822 ConfigOverride {
823 files: vec!["*.test.ts".to_string()],
824 rules: PartialRulesConfig {
825 unused_files: Some(Severity::Off),
826 ..Default::default()
827 },
828 },
829 ],
830 regression: None,
831 audit: crate::config::AuditConfig::default(),
832 codeowners: None,
833 public_packages: vec![],
834 flags: FlagsConfig::default(),
835 security: SecurityConfig::default(),
836 fix: crate::config::FixConfig::default(),
837 resolve: ResolveConfig::default(),
838 sealed: false,
839 include_entry_exports: false,
840 auto_imports: false,
841 cache: CacheConfig::default(),
842 };
843 let resolved = config.resolve(
844 PathBuf::from("/project"),
845 OutputFormat::Human,
846 1,
847 true,
848 true,
849 None,
850 );
851
852 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
853 assert_eq!(rules.unused_files, Severity::Off);
854
855 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
856 assert_eq!(rules2.unused_files, Severity::Warn);
857 }
858
859 #[test]
860 fn resolve_keeps_inter_file_rule_override_after_warning() {
861 let config = FallowConfig {
862 schema: None,
863 extends: vec![],
864 entry: vec![],
865 ignore_patterns: vec![],
866 framework: vec![],
867 workspaces: None,
868 ignore_dependencies: vec![],
869 ignore_unresolved_imports: vec![],
870 ignore_exports: vec![],
871 ignore_catalog_references: vec![],
872 ignore_dependency_overrides: vec![],
873 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
874 used_class_members: vec![],
875 ignore_decorators: vec![],
876 duplicates: DuplicatesConfig::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![ConfigOverride {
885 files: vec!["**/ui/**".to_string()],
886 rules: PartialRulesConfig {
887 duplicate_exports: Some(Severity::Off),
888 unused_files: Some(Severity::Warn),
889 ..Default::default()
890 },
891 }],
892 regression: None,
893 audit: crate::config::AuditConfig::default(),
894 codeowners: None,
895 public_packages: vec![],
896 flags: FlagsConfig::default(),
897 security: SecurityConfig::default(),
898 fix: crate::config::FixConfig::default(),
899 resolve: ResolveConfig::default(),
900 sealed: false,
901 include_entry_exports: false,
902 auto_imports: false,
903 cache: CacheConfig::default(),
904 };
905 let resolved = config.resolve(
906 PathBuf::from("/project"),
907 OutputFormat::Human,
908 1,
909 true,
910 true,
911 None,
912 );
913 assert_eq!(
914 resolved.overrides.len(),
915 1,
916 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
917 );
918 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
919 assert_eq!(rules.unused_files, Severity::Warn);
920 }
921
922 #[test]
923 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
924 reset_inter_file_warn_dedup_for_test();
925 let files_a = vec!["__test_dedup_a/*".to_string()];
926 let files_b = vec!["__test_dedup_b/*".to_string()];
927
928 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
929 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
930 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
931
932 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
933 assert!(!record_inter_file_warn_seen(
934 "circular-dependency",
935 &files_a
936 ));
937
938 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
939
940 let files_reordered = vec![
941 "__test_dedup_b/*".to_string(),
942 "__test_dedup_a/*".to_string(),
943 ];
944 let files_natural = vec![
945 "__test_dedup_a/*".to_string(),
946 "__test_dedup_b/*".to_string(),
947 ];
948 reset_inter_file_warn_dedup_for_test();
949 assert!(record_inter_file_warn_seen(
950 "duplicate-exports",
951 &files_natural
952 ));
953 assert!(!record_inter_file_warn_seen(
954 "duplicate-exports",
955 &files_reordered
956 ));
957 }
958
959 #[test]
960 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
961 reset_inter_file_warn_dedup_for_test();
962 let files = vec!["__test_resolve_dedup/**".to_string()];
963 let build_config = || FallowConfig {
964 schema: None,
965 extends: vec![],
966 entry: vec![],
967 ignore_patterns: vec![],
968 framework: vec![],
969 workspaces: None,
970 ignore_dependencies: vec![],
971 ignore_unresolved_imports: vec![],
972 ignore_exports: vec![],
973 ignore_catalog_references: vec![],
974 ignore_dependency_overrides: vec![],
975 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
976 used_class_members: vec![],
977 ignore_decorators: vec![],
978 duplicates: DuplicatesConfig::default(),
979 health: HealthConfig::default(),
980 rules: RulesConfig::default(),
981 boundaries: BoundaryConfig::default(),
982 production: false.into(),
983 plugins: vec![],
984 rule_packs: vec![],
985 dynamically_loaded: vec![],
986 overrides: vec![ConfigOverride {
987 files: files.clone(),
988 rules: PartialRulesConfig {
989 duplicate_exports: Some(Severity::Off),
990 ..Default::default()
991 },
992 }],
993 regression: None,
994 audit: crate::config::AuditConfig::default(),
995 codeowners: None,
996 public_packages: vec![],
997 flags: FlagsConfig::default(),
998 security: SecurityConfig::default(),
999 fix: crate::config::FixConfig::default(),
1000 resolve: ResolveConfig::default(),
1001 sealed: false,
1002 include_entry_exports: false,
1003 auto_imports: false,
1004 cache: CacheConfig::default(),
1005 };
1006 for _ in 0..10 {
1007 let _ = build_config().resolve(
1008 PathBuf::from("/project"),
1009 OutputFormat::Human,
1010 1,
1011 true,
1012 true,
1013 None,
1014 );
1015 }
1016 assert!(
1017 !record_inter_file_warn_seen("duplicate-exports", &files),
1018 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1019 );
1020 }
1021
1022 fn make_config(production: bool) -> FallowConfig {
1024 FallowConfig {
1025 schema: None,
1026 extends: vec![],
1027 entry: vec![],
1028 ignore_patterns: vec![],
1029 framework: vec![],
1030 workspaces: None,
1031 ignore_dependencies: vec![],
1032 ignore_unresolved_imports: vec![],
1033 ignore_exports: vec![],
1034 ignore_catalog_references: vec![],
1035 ignore_dependency_overrides: vec![],
1036 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1037 used_class_members: vec![],
1038 ignore_decorators: vec![],
1039 duplicates: DuplicatesConfig::default(),
1040 health: HealthConfig::default(),
1041 rules: RulesConfig::default(),
1042 boundaries: BoundaryConfig::default(),
1043 production: production.into(),
1044 plugins: vec![],
1045 rule_packs: vec![],
1046 dynamically_loaded: vec![],
1047 overrides: vec![],
1048 regression: None,
1049 audit: crate::config::AuditConfig::default(),
1050 codeowners: None,
1051 public_packages: vec![],
1052 flags: FlagsConfig::default(),
1053 security: SecurityConfig::default(),
1054 fix: crate::config::FixConfig::default(),
1055 resolve: ResolveConfig::default(),
1056 sealed: false,
1057 include_entry_exports: false,
1058 auto_imports: false,
1059 cache: CacheConfig::default(),
1060 }
1061 }
1062
1063 #[test]
1064 fn resolve_production_forces_dev_deps_off() {
1065 let resolved = make_config(true).resolve(
1066 PathBuf::from("/project"),
1067 OutputFormat::Human,
1068 1,
1069 true,
1070 true,
1071 None,
1072 );
1073 assert_eq!(
1074 resolved.rules.unused_dev_dependencies,
1075 Severity::Off,
1076 "production mode should force unused_dev_dependencies to off"
1077 );
1078 }
1079
1080 #[test]
1081 fn resolve_production_forces_optional_deps_off() {
1082 let resolved = make_config(true).resolve(
1083 PathBuf::from("/project"),
1084 OutputFormat::Human,
1085 1,
1086 true,
1087 true,
1088 None,
1089 );
1090 assert_eq!(
1091 resolved.rules.unused_optional_dependencies,
1092 Severity::Off,
1093 "production mode should force unused_optional_dependencies to off"
1094 );
1095 }
1096
1097 #[test]
1098 fn resolve_production_preserves_other_rules() {
1099 let resolved = make_config(true).resolve(
1100 PathBuf::from("/project"),
1101 OutputFormat::Human,
1102 1,
1103 true,
1104 true,
1105 None,
1106 );
1107 assert_eq!(resolved.rules.unused_files, Severity::Error);
1108 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1109 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1110 }
1111
1112 #[test]
1113 fn resolve_non_production_keeps_dev_deps_default() {
1114 let resolved = make_config(false).resolve(
1115 PathBuf::from("/project"),
1116 OutputFormat::Human,
1117 1,
1118 true,
1119 true,
1120 None,
1121 );
1122 assert_eq!(
1123 resolved.rules.unused_dev_dependencies,
1124 Severity::Warn,
1125 "non-production should keep default severity"
1126 );
1127 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1128 }
1129
1130 #[test]
1131 fn resolve_production_flag_stored() {
1132 let resolved = make_config(true).resolve(
1133 PathBuf::from("/project"),
1134 OutputFormat::Human,
1135 1,
1136 true,
1137 true,
1138 None,
1139 );
1140 assert!(resolved.production);
1141
1142 let resolved2 = make_config(false).resolve(
1143 PathBuf::from("/project"),
1144 OutputFormat::Human,
1145 1,
1146 true,
1147 true,
1148 None,
1149 );
1150 assert!(!resolved2.production);
1151 }
1152
1153 #[test]
1154 fn resolve_default_ignores_node_modules() {
1155 let resolved = make_config(false).resolve(
1156 PathBuf::from("/project"),
1157 OutputFormat::Human,
1158 1,
1159 true,
1160 true,
1161 None,
1162 );
1163 assert!(
1164 resolved
1165 .ignore_patterns
1166 .is_match("node_modules/lodash/index.js")
1167 );
1168 assert!(
1169 resolved
1170 .ignore_patterns
1171 .is_match("packages/a/node_modules/react/index.js")
1172 );
1173 }
1174
1175 #[test]
1176 fn resolve_default_ignores_dist() {
1177 let resolved = make_config(false).resolve(
1178 PathBuf::from("/project"),
1179 OutputFormat::Human,
1180 1,
1181 true,
1182 true,
1183 None,
1184 );
1185 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1186 assert!(
1187 resolved
1188 .ignore_patterns
1189 .is_match("packages/ui/dist/index.js")
1190 );
1191 }
1192
1193 #[test]
1194 fn resolve_default_ignores_root_build_only() {
1195 let resolved = make_config(false).resolve(
1196 PathBuf::from("/project"),
1197 OutputFormat::Human,
1198 1,
1199 true,
1200 true,
1201 None,
1202 );
1203 assert!(
1204 resolved.ignore_patterns.is_match("build/output.js"),
1205 "root build/ should be ignored"
1206 );
1207 assert!(
1208 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1209 "nested build/ should NOT be ignored by default"
1210 );
1211 }
1212
1213 #[test]
1214 fn resolve_default_ignores_minified_files() {
1215 let resolved = make_config(false).resolve(
1216 PathBuf::from("/project"),
1217 OutputFormat::Human,
1218 1,
1219 true,
1220 true,
1221 None,
1222 );
1223 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1224 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1225 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1226 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1227 assert!(
1228 resolved
1229 .ignore_patterns
1230 .is_match("src/vendor/app.bundle.js")
1231 );
1232 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1234 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1235 }
1236
1237 #[test]
1238 fn resolve_max_file_size_bytes_default_and_unlimited() {
1239 assert_eq!(
1241 resolve_max_file_size_bytes(None),
1242 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1243 );
1244 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1246 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1248 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1249 }
1250
1251 #[test]
1252 fn resolve_sets_default_max_file_size() {
1253 let resolved = make_config(false).resolve(
1254 PathBuf::from("/project"),
1255 OutputFormat::Human,
1256 1,
1257 true,
1258 true,
1259 None,
1260 );
1261 assert_eq!(
1262 resolved.max_file_size_bytes,
1263 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1264 );
1265 }
1266
1267 #[test]
1268 fn resolve_default_ignores_git() {
1269 let resolved = make_config(false).resolve(
1270 PathBuf::from("/project"),
1271 OutputFormat::Human,
1272 1,
1273 true,
1274 true,
1275 None,
1276 );
1277 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1278 }
1279
1280 #[test]
1281 fn resolve_default_ignores_coverage() {
1282 let resolved = make_config(false).resolve(
1283 PathBuf::from("/project"),
1284 OutputFormat::Human,
1285 1,
1286 true,
1287 true,
1288 None,
1289 );
1290 assert!(
1291 resolved
1292 .ignore_patterns
1293 .is_match("coverage/lcov-report/index.js")
1294 );
1295 }
1296
1297 #[test]
1298 fn resolve_source_files_not_ignored_by_default() {
1299 let resolved = make_config(false).resolve(
1300 PathBuf::from("/project"),
1301 OutputFormat::Human,
1302 1,
1303 true,
1304 true,
1305 None,
1306 );
1307 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1308 assert!(
1309 !resolved
1310 .ignore_patterns
1311 .is_match("src/components/Button.tsx")
1312 );
1313 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1314 }
1315
1316 #[test]
1317 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1318 let mut config = make_config(false);
1319 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1320 let resolved = config.resolve(
1321 PathBuf::from("/project"),
1322 OutputFormat::Human,
1323 1,
1324 true,
1325 true,
1326 None,
1327 );
1328 assert!(
1329 resolved
1330 .ignore_patterns
1331 .is_match("src/__generated__/types.ts")
1332 );
1333 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1334 }
1335
1336 #[test]
1337 fn resolve_passes_through_entry_patterns() {
1338 let mut config = make_config(false);
1339 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1340 let resolved = config.resolve(
1341 PathBuf::from("/project"),
1342 OutputFormat::Human,
1343 1,
1344 true,
1345 true,
1346 None,
1347 );
1348 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1349 }
1350
1351 #[test]
1352 fn resolve_passes_through_ignore_dependencies() {
1353 let mut config = make_config(false);
1354 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1355 let resolved = config.resolve(
1356 PathBuf::from("/project"),
1357 OutputFormat::Human,
1358 1,
1359 true,
1360 true,
1361 None,
1362 );
1363 assert_eq!(
1364 resolved.ignore_dependencies,
1365 vec!["postcss", "autoprefixer"]
1366 );
1367 }
1368
1369 #[test]
1370 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1371 let mut config = make_config(false);
1372 config.ignore_unresolved_imports = vec![
1373 "@example/icons".to_string(),
1374 "@example/icons/**".to_string(),
1375 "../generated/**".to_string(),
1376 ];
1377 let resolved = config.resolve(
1378 PathBuf::from("/project"),
1379 OutputFormat::Human,
1380 1,
1381 true,
1382 true,
1383 None,
1384 );
1385
1386 assert!(
1387 resolved
1388 .ignore_unresolved_imports
1389 .iter()
1390 .any(|matcher| matcher.is_match("@example/icons"))
1391 );
1392 assert!(
1393 resolved
1394 .ignore_unresolved_imports
1395 .iter()
1396 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1397 );
1398 assert!(
1399 resolved
1400 .ignore_unresolved_imports
1401 .iter()
1402 .any(|matcher| matcher.is_match("../generated/client"))
1403 );
1404 }
1405
1406 #[test]
1407 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1408 let mut config = make_config(false);
1409 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1410 let resolved = config.resolve(
1411 PathBuf::from("/project"),
1412 OutputFormat::Human,
1413 1,
1414 true,
1415 true,
1416 None,
1417 );
1418
1419 assert!(
1420 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1421 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1422 );
1423 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1424 }
1425
1426 #[test]
1427 fn resolve_sets_cache_dir() {
1428 let resolved = make_config(false).resolve(
1429 PathBuf::from("/my/project"),
1430 OutputFormat::Human,
1431 1,
1432 true,
1433 true,
1434 None,
1435 );
1436 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1437 }
1438
1439 #[test]
1440 fn resolve_uses_relative_configured_cache_dir_from_root() {
1441 let config = FallowConfig {
1442 cache: crate::CacheConfig {
1443 dir: Some(PathBuf::from(".cache/fallow")),
1444 ..Default::default()
1445 },
1446 ..make_config(false)
1447 };
1448 let resolved = config.resolve(
1449 PathBuf::from("/my/project"),
1450 OutputFormat::Human,
1451 1,
1452 false,
1453 true,
1454 None,
1455 );
1456 assert_eq!(
1457 resolved.cache_dir,
1458 PathBuf::from("/my/project/.cache/fallow")
1459 );
1460 }
1461
1462 #[test]
1463 fn resolve_keeps_absolute_configured_cache_dir() {
1464 let config = FallowConfig {
1465 cache: crate::CacheConfig {
1466 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1467 ..Default::default()
1468 },
1469 ..make_config(false)
1470 };
1471 let resolved = config.resolve(
1472 PathBuf::from("/my/project"),
1473 OutputFormat::Human,
1474 1,
1475 false,
1476 true,
1477 None,
1478 );
1479 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1480 }
1481
1482 #[test]
1483 fn resolve_passes_through_thread_count() {
1484 let resolved = make_config(false).resolve(
1485 PathBuf::from("/project"),
1486 OutputFormat::Human,
1487 8,
1488 true,
1489 true,
1490 None,
1491 );
1492 assert_eq!(resolved.threads, 8);
1493 }
1494
1495 #[test]
1496 fn resolve_passes_through_quiet_flag() {
1497 let resolved = make_config(false).resolve(
1498 PathBuf::from("/project"),
1499 OutputFormat::Human,
1500 1,
1501 true,
1502 false,
1503 None,
1504 );
1505 assert!(!resolved.quiet);
1506
1507 let resolved2 = make_config(false).resolve(
1508 PathBuf::from("/project"),
1509 OutputFormat::Human,
1510 1,
1511 true,
1512 true,
1513 None,
1514 );
1515 assert!(resolved2.quiet);
1516 }
1517
1518 #[test]
1519 fn resolve_passes_through_no_cache_flag() {
1520 let resolved_no_cache = make_config(false).resolve(
1521 PathBuf::from("/project"),
1522 OutputFormat::Human,
1523 1,
1524 true,
1525 true,
1526 None,
1527 );
1528 assert!(resolved_no_cache.no_cache);
1529
1530 let resolved_with_cache = make_config(false).resolve(
1531 PathBuf::from("/project"),
1532 OutputFormat::Human,
1533 1,
1534 false,
1535 true,
1536 None,
1537 );
1538 assert!(!resolved_with_cache.no_cache);
1539 }
1540
1541 #[test]
1542 #[should_panic(expected = "validated at config load time")]
1543 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1544 let mut config = make_config(false);
1545 config.overrides = vec![ConfigOverride {
1546 files: vec!["[invalid".to_string()],
1547 rules: PartialRulesConfig {
1548 unused_files: Some(Severity::Off),
1549 ..Default::default()
1550 },
1551 }];
1552 let _ = config.resolve(
1553 PathBuf::from("/project"),
1554 OutputFormat::Human,
1555 1,
1556 true,
1557 true,
1558 None,
1559 );
1560 }
1561
1562 #[test]
1563 fn resolve_override_with_empty_files_skipped() {
1564 let mut config = make_config(false);
1565 config.overrides = vec![ConfigOverride {
1566 files: vec![],
1567 rules: PartialRulesConfig {
1568 unused_files: Some(Severity::Off),
1569 ..Default::default()
1570 },
1571 }];
1572 let resolved = config.resolve(
1573 PathBuf::from("/project"),
1574 OutputFormat::Human,
1575 1,
1576 true,
1577 true,
1578 None,
1579 );
1580 assert!(
1581 resolved.overrides.is_empty(),
1582 "override with no file patterns should be skipped"
1583 );
1584 }
1585
1586 #[test]
1587 fn resolve_multiple_valid_overrides() {
1588 let mut config = make_config(false);
1589 config.overrides = vec![
1590 ConfigOverride {
1591 files: vec!["*.test.ts".to_string()],
1592 rules: PartialRulesConfig {
1593 unused_exports: Some(Severity::Off),
1594 ..Default::default()
1595 },
1596 },
1597 ConfigOverride {
1598 files: vec!["*.stories.tsx".to_string()],
1599 rules: PartialRulesConfig {
1600 unused_files: Some(Severity::Off),
1601 ..Default::default()
1602 },
1603 },
1604 ];
1605 let resolved = config.resolve(
1606 PathBuf::from("/project"),
1607 OutputFormat::Human,
1608 1,
1609 true,
1610 true,
1611 None,
1612 );
1613 assert_eq!(resolved.overrides.len(), 2);
1614 }
1615
1616 #[test]
1617 fn ignore_export_rule_deserialize() {
1618 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1619 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1620 assert_eq!(rule.file, "src/types/*.ts");
1621 assert_eq!(rule.exports, vec!["*"]);
1622 }
1623
1624 #[test]
1625 fn ignore_export_rule_specific_exports() {
1626 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1627 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1628 assert_eq!(rule.exports.len(), 3);
1629 assert!(rule.exports.contains(&"FOO".to_string()));
1630 }
1631
1632 mod proptests {
1633 use super::*;
1634 use proptest::prelude::*;
1635
1636 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1637 make_config(production).resolve(
1638 PathBuf::from("/project"),
1639 OutputFormat::Human,
1640 1,
1641 true,
1642 true,
1643 None,
1644 )
1645 }
1646
1647 proptest! {
1648 #[test]
1650 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1651 let resolved = arb_resolved_config(production);
1652 prop_assert!(
1653 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1654 "Default ignore should match node_modules"
1655 );
1656 prop_assert!(
1657 resolved.ignore_patterns.is_match("dist/bundle.js"),
1658 "Default ignore should match dist"
1659 );
1660 }
1661
1662 #[test]
1664 fn production_forces_dev_deps_off(_unused in Just(())) {
1665 let resolved = arb_resolved_config(true);
1666 prop_assert_eq!(
1667 resolved.rules.unused_dev_dependencies,
1668 Severity::Off,
1669 "Production should force unused_dev_dependencies off"
1670 );
1671 prop_assert_eq!(
1672 resolved.rules.unused_optional_dependencies,
1673 Severity::Off,
1674 "Production should force unused_optional_dependencies off"
1675 );
1676 }
1677
1678 #[test]
1680 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1681 let resolved = arb_resolved_config(false);
1682 prop_assert_eq!(
1683 resolved.rules.unused_dev_dependencies,
1684 Severity::Warn,
1685 "Non-production should keep default dev dep severity"
1686 );
1687 }
1688
1689 #[test]
1691 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1692 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1693 let expected_cache = root.join(".fallow");
1694 let resolved = make_config(false).resolve(
1695 root,
1696 OutputFormat::Human,
1697 1,
1698 true,
1699 true,
1700 None,
1701 );
1702 prop_assert_eq!(
1703 resolved.cache_dir, expected_cache,
1704 "Default cache dir should be root/.fallow"
1705 );
1706 }
1707
1708 #[test]
1710 fn threads_passed_through(threads in 1..64usize) {
1711 let resolved = make_config(false).resolve(
1712 PathBuf::from("/project"),
1713 OutputFormat::Human,
1714 threads,
1715 true,
1716 true, None,
1717 );
1718 prop_assert_eq!(
1719 resolved.threads, threads,
1720 "Thread count should be passed through"
1721 );
1722 }
1723
1724 #[test]
1728 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1729 let mut config = make_config(false);
1730 config.ignore_patterns = vec![pattern];
1731 let resolved = config.resolve(
1732 PathBuf::from("/project"),
1733 OutputFormat::Human,
1734 1,
1735 true,
1736 true, None,
1737 );
1738 prop_assert!(
1739 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1740 "Default node_modules ignore should still be active"
1741 );
1742 }
1743 }
1744 }
1745
1746 #[test]
1747 fn resolve_expands_boundary_preset() {
1748 use crate::config::boundaries::BoundaryPreset;
1749
1750 let mut config = make_config(false);
1751 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1752 let resolved = config.resolve(
1753 PathBuf::from("/project"),
1754 OutputFormat::Human,
1755 1,
1756 true,
1757 true,
1758 None,
1759 );
1760 assert_eq!(resolved.boundaries.zones.len(), 3);
1761 assert_eq!(resolved.boundaries.rules.len(), 3);
1762 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1763 assert_eq!(
1764 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1765 Some("adapters")
1766 );
1767 }
1768
1769 #[test]
1770 fn resolve_boundary_preset_with_user_override() {
1771 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1772
1773 let mut config = make_config(false);
1774 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1775 config.boundaries.zones = vec![BoundaryZone {
1776 name: "domain".to_string(),
1777 patterns: vec!["src/core/**".to_string()],
1778 auto_discover: vec![],
1779 root: None,
1780 }];
1781 let resolved = config.resolve(
1782 PathBuf::from("/project"),
1783 OutputFormat::Human,
1784 1,
1785 true,
1786 true,
1787 None,
1788 );
1789 assert_eq!(resolved.boundaries.zones.len(), 3);
1790 assert_eq!(
1791 resolved.boundaries.classify_zone("src/core/user.ts"),
1792 Some("domain")
1793 );
1794 assert_eq!(
1795 resolved.boundaries.classify_zone("src/domain/user.ts"),
1796 None
1797 );
1798 }
1799
1800 #[test]
1801 fn resolve_no_preset_unchanged() {
1802 let config = make_config(false);
1803 let resolved = config.resolve(
1804 PathBuf::from("/project"),
1805 OutputFormat::Human,
1806 1,
1807 true,
1808 true,
1809 None,
1810 );
1811 assert!(resolved.boundaries.is_empty());
1812 }
1813}