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::{FallowConfig, 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 production: bool,
185 pub quiet: bool,
186 pub external_plugins: Vec<ExternalPluginDef>,
187 pub dynamically_loaded: Vec<String>,
188 pub overrides: Vec<ResolvedOverride>,
189 pub regression: Option<super::RegressionConfig>,
190 pub audit: super::AuditConfig,
191 pub codeowners: Option<String>,
192 pub public_packages: Vec<String>,
193 pub flags: FlagsConfig,
194 pub security: SecurityConfig,
195 pub fix: super::FixConfig,
196 pub resolve: ResolveConfig,
197 pub include_entry_exports: bool,
198 pub auto_imports: bool,
199}
200
201fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
203 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
204 names.sort_unstable();
205 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
206 for name in names {
207 hasher.update(&(name.len() as u32).to_le_bytes());
208 hasher.update(name.as_bytes());
209 }
210 hasher.digest()
211}
212
213impl FallowConfig {
214 #[expect(
216 clippy::expect_used,
217 reason = "user glob patterns are validated before config resolution"
218 )]
219 pub fn resolve(
220 self,
221 root: PathBuf,
222 output: OutputFormat,
223 threads: usize,
224 no_cache: bool,
225 quiet: bool,
226 cache_max_size_mb: Option<u32>,
227 ) -> ResolvedConfig {
228 let mut ignore_builder = GlobSetBuilder::new();
229 for pattern in &self.ignore_patterns {
230 ignore_builder.add(
231 Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
232 );
233 }
234
235 let default_ignores = [
236 "**/node_modules/**",
237 "**/dist/**",
238 "build/**",
239 "**/.git/**",
240 "**/coverage/**",
241 "**/*.min.js",
242 "**/*.min.mjs",
243 ];
244 for pattern in &default_ignores {
245 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
246 }
247
248 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
249 let ignore_unresolved_imports: Vec<GlobMatcher> = self
250 .ignore_unresolved_imports
251 .iter()
252 .map(|pattern| {
253 Glob::new(pattern)
254 .expect("ignoreUnresolvedImports entry was validated at config load time")
255 .compile_matcher()
256 })
257 .collect();
258 let cache_dir = root.join(".fallow");
259
260 let mut rules = self.rules;
261
262 let production = self.production.global();
263 if production {
264 rules.unused_dev_dependencies = Severity::Off;
265 rules.unused_optional_dependencies = Severity::Off;
266 }
267
268 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
269 external_plugins.extend(self.framework);
270
271 let mut boundaries = self.boundaries;
272 if boundaries.preset.is_some() {
273 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
274 .filter(|r| {
275 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
276 })
277 .unwrap_or_else(|| "src".to_owned());
278 if source_root != "src" {
279 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
280 }
281 boundaries.expand(&source_root);
282 }
283 let logical_groups = boundaries.expand_auto_discover(&root);
284
285 let mut boundaries = boundaries.resolve();
286 boundaries.logical_groups = logical_groups;
287
288 let overrides = self
289 .overrides
290 .into_iter()
291 .filter_map(|o| {
292 if o.rules.duplicate_exports.is_some()
293 && record_inter_file_warn_seen("duplicate-exports", &o.files)
294 {
295 let files = o.files.join(", ");
296 tracing::warn!(
297 "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."
298 );
299 }
300 if o.rules.circular_dependencies.is_some()
301 && record_inter_file_warn_seen("circular-dependency", &o.files)
302 {
303 let files = o.files.join(", ");
304 tracing::warn!(
305 "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."
306 );
307 }
308 if o.rules.re_export_cycle.is_some()
309 && record_inter_file_warn_seen("re-export-cycle", &o.files)
310 {
311 let files = o.files.join(", ");
312 tracing::warn!(
313 "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."
314 );
315 }
316 let matchers: Vec<globset::GlobMatcher> = o
317 .files
318 .iter()
319 .map(|pattern| {
320 Glob::new(pattern)
321 .expect("overrides[].files pattern was validated at config load time")
322 .compile_matcher()
323 })
324 .collect();
325 if matchers.is_empty() {
326 None
327 } else {
328 Some(ResolvedOverride {
329 matchers,
330 rules: o.rules,
331 })
332 }
333 })
334 .collect();
335
336 let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
337 .ignore_exports
338 .iter()
339 .map(|rule| CompiledIgnoreExportRule {
340 matcher: Glob::new(&rule.file)
341 .expect("ignoreExports[].file was validated at config load time")
342 .compile_matcher(),
343 exports: rule.exports.clone(),
344 })
345 .collect();
346
347 let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
348 .ignore_catalog_references
349 .iter()
350 .map(|rule| CompiledIgnoreCatalogReferenceRule {
351 package: rule.package.clone(),
352 catalog: rule.catalog.clone(),
353 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
354 Glob::new(pattern)
355 .expect(
356 "ignoreCatalogReferences[].consumer was validated at config load time",
357 )
358 .compile_matcher()
359 }),
360 })
361 .collect();
362
363 let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
364 .ignore_dependency_overrides
365 .iter()
366 .map(|rule| CompiledIgnoreDependencyOverrideRule {
367 package: rule.package.clone(),
368 source: rule.source.clone(),
369 })
370 .collect();
371
372 let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
373
374 let cache_config_hash = if no_cache {
375 0
376 } else {
377 compute_cache_config_hash(&external_plugins)
378 };
379
380 ResolvedConfig {
381 root,
382 entry_patterns: self.entry,
383 ignore_patterns: compiled_ignore_patterns,
384 output,
385 cache_dir,
386 threads,
387 no_cache,
388 cache_max_size_mb,
389 cache_config_hash,
390 ignore_dependencies: self.ignore_dependencies,
391 ignore_unresolved_imports,
392 ignore_export_rules: self.ignore_exports,
393 compiled_ignore_exports,
394 compiled_ignore_catalog_references,
395 compiled_ignore_dependency_overrides,
396 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
397 used_class_members: self.used_class_members,
398 ignore_decorators: self.ignore_decorators,
399 duplicates: self.duplicates,
400 health: self.health,
401 rules,
402 boundaries,
403 production,
404 quiet,
405 external_plugins,
406 dynamically_loaded: self.dynamically_loaded,
407 overrides,
408 regression: self.regression,
409 audit: self.audit,
410 codeowners: self.codeowners,
411 public_packages: self.public_packages,
412 flags: self.flags,
413 security: self.security,
414 fix: self.fix,
415 resolve: self.resolve,
416 include_entry_exports: self.include_entry_exports,
417 auto_imports: self.auto_imports,
418 }
419 }
420}
421
422impl ResolvedConfig {
423 #[must_use]
426 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
427 if self.overrides.is_empty() {
428 return self.rules.clone();
429 }
430
431 let relative = path.strip_prefix(&self.root).unwrap_or(path);
432 let relative_str = relative.to_string_lossy();
433
434 let mut rules = self.rules.clone();
435 for override_entry in &self.overrides {
436 let matches = override_entry
437 .matchers
438 .iter()
439 .any(|m| m.is_match(relative_str.as_ref()));
440 if matches {
441 rules.apply_partial(&override_entry.rules);
442 }
443 }
444 rules
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::CacheConfig;
452 use crate::config::boundaries::BoundaryConfig;
453 use crate::config::health::HealthConfig;
454
455 #[test]
456 fn overrides_deserialize() {
457 let json_str = r#"{
458 "overrides": [{
459 "files": ["*.test.ts"],
460 "rules": {
461 "unused-exports": "off"
462 }
463 }]
464 }"#;
465 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
466 assert_eq!(config.overrides.len(), 1);
467 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
468 assert_eq!(
469 config.overrides[0].rules.unused_exports,
470 Some(Severity::Off)
471 );
472 assert_eq!(config.overrides[0].rules.unused_files, None);
473 }
474
475 #[test]
476 fn resolve_rules_for_path_no_overrides() {
477 let config = FallowConfig {
478 schema: None,
479 extends: vec![],
480 entry: vec![],
481 ignore_patterns: vec![],
482 framework: vec![],
483 workspaces: None,
484 ignore_dependencies: vec![],
485 ignore_unresolved_imports: vec![],
486 ignore_exports: vec![],
487 ignore_catalog_references: vec![],
488 ignore_dependency_overrides: vec![],
489 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
490 used_class_members: vec![],
491 ignore_decorators: vec![],
492 duplicates: DuplicatesConfig::default(),
493 health: HealthConfig::default(),
494 rules: RulesConfig::default(),
495 boundaries: BoundaryConfig::default(),
496 production: false.into(),
497 plugins: vec![],
498 dynamically_loaded: vec![],
499 overrides: vec![],
500 regression: None,
501 audit: crate::config::AuditConfig::default(),
502 codeowners: None,
503 public_packages: vec![],
504 flags: FlagsConfig::default(),
505 security: SecurityConfig::default(),
506 fix: crate::config::FixConfig::default(),
507 resolve: ResolveConfig::default(),
508 sealed: false,
509 include_entry_exports: false,
510 auto_imports: false,
511 cache: CacheConfig::default(),
512 };
513 let resolved = config.resolve(
514 PathBuf::from("/project"),
515 OutputFormat::Human,
516 1,
517 true,
518 true,
519 None,
520 );
521 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
522 assert_eq!(rules.unused_files, Severity::Error);
523 }
524
525 #[test]
526 fn resolve_rules_for_path_with_matching_override() {
527 let config = FallowConfig {
528 schema: None,
529 extends: vec![],
530 entry: vec![],
531 ignore_patterns: vec![],
532 framework: vec![],
533 workspaces: None,
534 ignore_dependencies: vec![],
535 ignore_unresolved_imports: vec![],
536 ignore_exports: vec![],
537 ignore_catalog_references: vec![],
538 ignore_dependency_overrides: vec![],
539 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
540 used_class_members: vec![],
541 ignore_decorators: vec![],
542 duplicates: DuplicatesConfig::default(),
543 health: HealthConfig::default(),
544 rules: RulesConfig::default(),
545 boundaries: BoundaryConfig::default(),
546 production: false.into(),
547 plugins: vec![],
548 dynamically_loaded: vec![],
549 overrides: vec![ConfigOverride {
550 files: vec!["*.test.ts".to_string()],
551 rules: PartialRulesConfig {
552 unused_exports: Some(Severity::Off),
553 ..Default::default()
554 },
555 }],
556 regression: None,
557 audit: crate::config::AuditConfig::default(),
558 codeowners: None,
559 public_packages: vec![],
560 flags: FlagsConfig::default(),
561 security: SecurityConfig::default(),
562 fix: crate::config::FixConfig::default(),
563 resolve: ResolveConfig::default(),
564 sealed: false,
565 include_entry_exports: false,
566 auto_imports: false,
567 cache: CacheConfig::default(),
568 };
569 let resolved = config.resolve(
570 PathBuf::from("/project"),
571 OutputFormat::Human,
572 1,
573 true,
574 true,
575 None,
576 );
577
578 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
579 assert_eq!(test_rules.unused_exports, Severity::Off);
580 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
583 assert_eq!(src_rules.unused_exports, Severity::Error);
584 }
585
586 #[test]
587 fn resolve_rules_for_path_later_override_wins() {
588 let config = FallowConfig {
589 schema: None,
590 extends: vec![],
591 entry: vec![],
592 ignore_patterns: vec![],
593 framework: vec![],
594 workspaces: None,
595 ignore_dependencies: vec![],
596 ignore_unresolved_imports: vec![],
597 ignore_exports: vec![],
598 ignore_catalog_references: vec![],
599 ignore_dependency_overrides: vec![],
600 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
601 used_class_members: vec![],
602 ignore_decorators: vec![],
603 duplicates: DuplicatesConfig::default(),
604 health: HealthConfig::default(),
605 rules: RulesConfig::default(),
606 boundaries: BoundaryConfig::default(),
607 production: false.into(),
608 plugins: vec![],
609 dynamically_loaded: vec![],
610 overrides: vec![
611 ConfigOverride {
612 files: vec!["*.ts".to_string()],
613 rules: PartialRulesConfig {
614 unused_files: Some(Severity::Warn),
615 ..Default::default()
616 },
617 },
618 ConfigOverride {
619 files: vec!["*.test.ts".to_string()],
620 rules: PartialRulesConfig {
621 unused_files: Some(Severity::Off),
622 ..Default::default()
623 },
624 },
625 ],
626 regression: None,
627 audit: crate::config::AuditConfig::default(),
628 codeowners: None,
629 public_packages: vec![],
630 flags: FlagsConfig::default(),
631 security: SecurityConfig::default(),
632 fix: crate::config::FixConfig::default(),
633 resolve: ResolveConfig::default(),
634 sealed: false,
635 include_entry_exports: false,
636 auto_imports: false,
637 cache: CacheConfig::default(),
638 };
639 let resolved = config.resolve(
640 PathBuf::from("/project"),
641 OutputFormat::Human,
642 1,
643 true,
644 true,
645 None,
646 );
647
648 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
649 assert_eq!(rules.unused_files, Severity::Off);
650
651 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
652 assert_eq!(rules2.unused_files, Severity::Warn);
653 }
654
655 #[test]
656 fn resolve_keeps_inter_file_rule_override_after_warning() {
657 let config = FallowConfig {
658 schema: None,
659 extends: vec![],
660 entry: vec![],
661 ignore_patterns: vec![],
662 framework: vec![],
663 workspaces: None,
664 ignore_dependencies: vec![],
665 ignore_unresolved_imports: vec![],
666 ignore_exports: vec![],
667 ignore_catalog_references: vec![],
668 ignore_dependency_overrides: vec![],
669 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
670 used_class_members: vec![],
671 ignore_decorators: vec![],
672 duplicates: DuplicatesConfig::default(),
673 health: HealthConfig::default(),
674 rules: RulesConfig::default(),
675 boundaries: BoundaryConfig::default(),
676 production: false.into(),
677 plugins: vec![],
678 dynamically_loaded: vec![],
679 overrides: vec![ConfigOverride {
680 files: vec!["**/ui/**".to_string()],
681 rules: PartialRulesConfig {
682 duplicate_exports: Some(Severity::Off),
683 unused_files: Some(Severity::Warn),
684 ..Default::default()
685 },
686 }],
687 regression: None,
688 audit: crate::config::AuditConfig::default(),
689 codeowners: None,
690 public_packages: vec![],
691 flags: FlagsConfig::default(),
692 security: SecurityConfig::default(),
693 fix: crate::config::FixConfig::default(),
694 resolve: ResolveConfig::default(),
695 sealed: false,
696 include_entry_exports: false,
697 auto_imports: false,
698 cache: CacheConfig::default(),
699 };
700 let resolved = config.resolve(
701 PathBuf::from("/project"),
702 OutputFormat::Human,
703 1,
704 true,
705 true,
706 None,
707 );
708 assert_eq!(
709 resolved.overrides.len(),
710 1,
711 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
712 );
713 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
714 assert_eq!(rules.unused_files, Severity::Warn);
715 }
716
717 #[test]
718 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
719 reset_inter_file_warn_dedup_for_test();
720 let files_a = vec!["__test_dedup_a/*".to_string()];
721 let files_b = vec!["__test_dedup_b/*".to_string()];
722
723 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
724 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
725 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
726
727 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
728 assert!(!record_inter_file_warn_seen(
729 "circular-dependency",
730 &files_a
731 ));
732
733 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
734
735 let files_reordered = vec![
736 "__test_dedup_b/*".to_string(),
737 "__test_dedup_a/*".to_string(),
738 ];
739 let files_natural = vec![
740 "__test_dedup_a/*".to_string(),
741 "__test_dedup_b/*".to_string(),
742 ];
743 reset_inter_file_warn_dedup_for_test();
744 assert!(record_inter_file_warn_seen(
745 "duplicate-exports",
746 &files_natural
747 ));
748 assert!(!record_inter_file_warn_seen(
749 "duplicate-exports",
750 &files_reordered
751 ));
752 }
753
754 #[test]
755 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
756 reset_inter_file_warn_dedup_for_test();
757 let files = vec!["__test_resolve_dedup/**".to_string()];
758 let build_config = || FallowConfig {
759 schema: None,
760 extends: vec![],
761 entry: vec![],
762 ignore_patterns: vec![],
763 framework: vec![],
764 workspaces: None,
765 ignore_dependencies: vec![],
766 ignore_unresolved_imports: vec![],
767 ignore_exports: vec![],
768 ignore_catalog_references: vec![],
769 ignore_dependency_overrides: vec![],
770 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
771 used_class_members: vec![],
772 ignore_decorators: vec![],
773 duplicates: DuplicatesConfig::default(),
774 health: HealthConfig::default(),
775 rules: RulesConfig::default(),
776 boundaries: BoundaryConfig::default(),
777 production: false.into(),
778 plugins: vec![],
779 dynamically_loaded: vec![],
780 overrides: vec![ConfigOverride {
781 files: files.clone(),
782 rules: PartialRulesConfig {
783 duplicate_exports: Some(Severity::Off),
784 ..Default::default()
785 },
786 }],
787 regression: None,
788 audit: crate::config::AuditConfig::default(),
789 codeowners: None,
790 public_packages: vec![],
791 flags: FlagsConfig::default(),
792 security: SecurityConfig::default(),
793 fix: crate::config::FixConfig::default(),
794 resolve: ResolveConfig::default(),
795 sealed: false,
796 include_entry_exports: false,
797 auto_imports: false,
798 cache: CacheConfig::default(),
799 };
800 for _ in 0..10 {
801 let _ = build_config().resolve(
802 PathBuf::from("/project"),
803 OutputFormat::Human,
804 1,
805 true,
806 true,
807 None,
808 );
809 }
810 assert!(
811 !record_inter_file_warn_seen("duplicate-exports", &files),
812 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
813 );
814 }
815
816 fn make_config(production: bool) -> FallowConfig {
818 FallowConfig {
819 schema: None,
820 extends: vec![],
821 entry: vec![],
822 ignore_patterns: vec![],
823 framework: vec![],
824 workspaces: None,
825 ignore_dependencies: vec![],
826 ignore_unresolved_imports: vec![],
827 ignore_exports: vec![],
828 ignore_catalog_references: vec![],
829 ignore_dependency_overrides: vec![],
830 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
831 used_class_members: vec![],
832 ignore_decorators: vec![],
833 duplicates: DuplicatesConfig::default(),
834 health: HealthConfig::default(),
835 rules: RulesConfig::default(),
836 boundaries: BoundaryConfig::default(),
837 production: production.into(),
838 plugins: vec![],
839 dynamically_loaded: vec![],
840 overrides: vec![],
841 regression: None,
842 audit: crate::config::AuditConfig::default(),
843 codeowners: None,
844 public_packages: vec![],
845 flags: FlagsConfig::default(),
846 security: SecurityConfig::default(),
847 fix: crate::config::FixConfig::default(),
848 resolve: ResolveConfig::default(),
849 sealed: false,
850 include_entry_exports: false,
851 auto_imports: false,
852 cache: CacheConfig::default(),
853 }
854 }
855
856 #[test]
857 fn resolve_production_forces_dev_deps_off() {
858 let resolved = make_config(true).resolve(
859 PathBuf::from("/project"),
860 OutputFormat::Human,
861 1,
862 true,
863 true,
864 None,
865 );
866 assert_eq!(
867 resolved.rules.unused_dev_dependencies,
868 Severity::Off,
869 "production mode should force unused_dev_dependencies to off"
870 );
871 }
872
873 #[test]
874 fn resolve_production_forces_optional_deps_off() {
875 let resolved = make_config(true).resolve(
876 PathBuf::from("/project"),
877 OutputFormat::Human,
878 1,
879 true,
880 true,
881 None,
882 );
883 assert_eq!(
884 resolved.rules.unused_optional_dependencies,
885 Severity::Off,
886 "production mode should force unused_optional_dependencies to off"
887 );
888 }
889
890 #[test]
891 fn resolve_production_preserves_other_rules() {
892 let resolved = make_config(true).resolve(
893 PathBuf::from("/project"),
894 OutputFormat::Human,
895 1,
896 true,
897 true,
898 None,
899 );
900 assert_eq!(resolved.rules.unused_files, Severity::Error);
901 assert_eq!(resolved.rules.unused_exports, Severity::Error);
902 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
903 }
904
905 #[test]
906 fn resolve_non_production_keeps_dev_deps_default() {
907 let resolved = make_config(false).resolve(
908 PathBuf::from("/project"),
909 OutputFormat::Human,
910 1,
911 true,
912 true,
913 None,
914 );
915 assert_eq!(
916 resolved.rules.unused_dev_dependencies,
917 Severity::Warn,
918 "non-production should keep default severity"
919 );
920 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
921 }
922
923 #[test]
924 fn resolve_production_flag_stored() {
925 let resolved = make_config(true).resolve(
926 PathBuf::from("/project"),
927 OutputFormat::Human,
928 1,
929 true,
930 true,
931 None,
932 );
933 assert!(resolved.production);
934
935 let resolved2 = make_config(false).resolve(
936 PathBuf::from("/project"),
937 OutputFormat::Human,
938 1,
939 true,
940 true,
941 None,
942 );
943 assert!(!resolved2.production);
944 }
945
946 #[test]
947 fn resolve_default_ignores_node_modules() {
948 let resolved = make_config(false).resolve(
949 PathBuf::from("/project"),
950 OutputFormat::Human,
951 1,
952 true,
953 true,
954 None,
955 );
956 assert!(
957 resolved
958 .ignore_patterns
959 .is_match("node_modules/lodash/index.js")
960 );
961 assert!(
962 resolved
963 .ignore_patterns
964 .is_match("packages/a/node_modules/react/index.js")
965 );
966 }
967
968 #[test]
969 fn resolve_default_ignores_dist() {
970 let resolved = make_config(false).resolve(
971 PathBuf::from("/project"),
972 OutputFormat::Human,
973 1,
974 true,
975 true,
976 None,
977 );
978 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
979 assert!(
980 resolved
981 .ignore_patterns
982 .is_match("packages/ui/dist/index.js")
983 );
984 }
985
986 #[test]
987 fn resolve_default_ignores_root_build_only() {
988 let resolved = make_config(false).resolve(
989 PathBuf::from("/project"),
990 OutputFormat::Human,
991 1,
992 true,
993 true,
994 None,
995 );
996 assert!(
997 resolved.ignore_patterns.is_match("build/output.js"),
998 "root build/ should be ignored"
999 );
1000 assert!(
1001 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1002 "nested build/ should NOT be ignored by default"
1003 );
1004 }
1005
1006 #[test]
1007 fn resolve_default_ignores_minified_files() {
1008 let resolved = make_config(false).resolve(
1009 PathBuf::from("/project"),
1010 OutputFormat::Human,
1011 1,
1012 true,
1013 true,
1014 None,
1015 );
1016 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1017 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1018 }
1019
1020 #[test]
1021 fn resolve_default_ignores_git() {
1022 let resolved = make_config(false).resolve(
1023 PathBuf::from("/project"),
1024 OutputFormat::Human,
1025 1,
1026 true,
1027 true,
1028 None,
1029 );
1030 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1031 }
1032
1033 #[test]
1034 fn resolve_default_ignores_coverage() {
1035 let resolved = make_config(false).resolve(
1036 PathBuf::from("/project"),
1037 OutputFormat::Human,
1038 1,
1039 true,
1040 true,
1041 None,
1042 );
1043 assert!(
1044 resolved
1045 .ignore_patterns
1046 .is_match("coverage/lcov-report/index.js")
1047 );
1048 }
1049
1050 #[test]
1051 fn resolve_source_files_not_ignored_by_default() {
1052 let resolved = make_config(false).resolve(
1053 PathBuf::from("/project"),
1054 OutputFormat::Human,
1055 1,
1056 true,
1057 true,
1058 None,
1059 );
1060 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1061 assert!(
1062 !resolved
1063 .ignore_patterns
1064 .is_match("src/components/Button.tsx")
1065 );
1066 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1067 }
1068
1069 #[test]
1070 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1071 let mut config = make_config(false);
1072 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1073 let resolved = config.resolve(
1074 PathBuf::from("/project"),
1075 OutputFormat::Human,
1076 1,
1077 true,
1078 true,
1079 None,
1080 );
1081 assert!(
1082 resolved
1083 .ignore_patterns
1084 .is_match("src/__generated__/types.ts")
1085 );
1086 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1087 }
1088
1089 #[test]
1090 fn resolve_passes_through_entry_patterns() {
1091 let mut config = make_config(false);
1092 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1093 let resolved = config.resolve(
1094 PathBuf::from("/project"),
1095 OutputFormat::Human,
1096 1,
1097 true,
1098 true,
1099 None,
1100 );
1101 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1102 }
1103
1104 #[test]
1105 fn resolve_passes_through_ignore_dependencies() {
1106 let mut config = make_config(false);
1107 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1108 let resolved = config.resolve(
1109 PathBuf::from("/project"),
1110 OutputFormat::Human,
1111 1,
1112 true,
1113 true,
1114 None,
1115 );
1116 assert_eq!(
1117 resolved.ignore_dependencies,
1118 vec!["postcss", "autoprefixer"]
1119 );
1120 }
1121
1122 #[test]
1123 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1124 let mut config = make_config(false);
1125 config.ignore_unresolved_imports = vec![
1126 "@example/icons".to_string(),
1127 "@example/icons/**".to_string(),
1128 "../generated/**".to_string(),
1129 ];
1130 let resolved = config.resolve(
1131 PathBuf::from("/project"),
1132 OutputFormat::Human,
1133 1,
1134 true,
1135 true,
1136 None,
1137 );
1138
1139 assert!(
1140 resolved
1141 .ignore_unresolved_imports
1142 .iter()
1143 .any(|matcher| matcher.is_match("@example/icons"))
1144 );
1145 assert!(
1146 resolved
1147 .ignore_unresolved_imports
1148 .iter()
1149 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1150 );
1151 assert!(
1152 resolved
1153 .ignore_unresolved_imports
1154 .iter()
1155 .any(|matcher| matcher.is_match("../generated/client"))
1156 );
1157 }
1158
1159 #[test]
1160 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1161 let mut config = make_config(false);
1162 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1163 let resolved = config.resolve(
1164 PathBuf::from("/project"),
1165 OutputFormat::Human,
1166 1,
1167 true,
1168 true,
1169 None,
1170 );
1171
1172 assert!(
1173 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1174 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1175 );
1176 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1177 }
1178
1179 #[test]
1180 fn resolve_sets_cache_dir() {
1181 let resolved = make_config(false).resolve(
1182 PathBuf::from("/my/project"),
1183 OutputFormat::Human,
1184 1,
1185 true,
1186 true,
1187 None,
1188 );
1189 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1190 }
1191
1192 #[test]
1193 fn resolve_passes_through_thread_count() {
1194 let resolved = make_config(false).resolve(
1195 PathBuf::from("/project"),
1196 OutputFormat::Human,
1197 8,
1198 true,
1199 true,
1200 None,
1201 );
1202 assert_eq!(resolved.threads, 8);
1203 }
1204
1205 #[test]
1206 fn resolve_passes_through_quiet_flag() {
1207 let resolved = make_config(false).resolve(
1208 PathBuf::from("/project"),
1209 OutputFormat::Human,
1210 1,
1211 true,
1212 false,
1213 None,
1214 );
1215 assert!(!resolved.quiet);
1216
1217 let resolved2 = make_config(false).resolve(
1218 PathBuf::from("/project"),
1219 OutputFormat::Human,
1220 1,
1221 true,
1222 true,
1223 None,
1224 );
1225 assert!(resolved2.quiet);
1226 }
1227
1228 #[test]
1229 fn resolve_passes_through_no_cache_flag() {
1230 let resolved_no_cache = make_config(false).resolve(
1231 PathBuf::from("/project"),
1232 OutputFormat::Human,
1233 1,
1234 true,
1235 true,
1236 None,
1237 );
1238 assert!(resolved_no_cache.no_cache);
1239
1240 let resolved_with_cache = make_config(false).resolve(
1241 PathBuf::from("/project"),
1242 OutputFormat::Human,
1243 1,
1244 false,
1245 true,
1246 None,
1247 );
1248 assert!(!resolved_with_cache.no_cache);
1249 }
1250
1251 #[test]
1252 #[should_panic(expected = "validated at config load time")]
1253 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1254 let mut config = make_config(false);
1255 config.overrides = vec![ConfigOverride {
1256 files: vec!["[invalid".to_string()],
1257 rules: PartialRulesConfig {
1258 unused_files: Some(Severity::Off),
1259 ..Default::default()
1260 },
1261 }];
1262 let _ = config.resolve(
1263 PathBuf::from("/project"),
1264 OutputFormat::Human,
1265 1,
1266 true,
1267 true,
1268 None,
1269 );
1270 }
1271
1272 #[test]
1273 fn resolve_override_with_empty_files_skipped() {
1274 let mut config = make_config(false);
1275 config.overrides = vec![ConfigOverride {
1276 files: vec![],
1277 rules: PartialRulesConfig {
1278 unused_files: Some(Severity::Off),
1279 ..Default::default()
1280 },
1281 }];
1282 let resolved = config.resolve(
1283 PathBuf::from("/project"),
1284 OutputFormat::Human,
1285 1,
1286 true,
1287 true,
1288 None,
1289 );
1290 assert!(
1291 resolved.overrides.is_empty(),
1292 "override with no file patterns should be skipped"
1293 );
1294 }
1295
1296 #[test]
1297 fn resolve_multiple_valid_overrides() {
1298 let mut config = make_config(false);
1299 config.overrides = vec![
1300 ConfigOverride {
1301 files: vec!["*.test.ts".to_string()],
1302 rules: PartialRulesConfig {
1303 unused_exports: Some(Severity::Off),
1304 ..Default::default()
1305 },
1306 },
1307 ConfigOverride {
1308 files: vec!["*.stories.tsx".to_string()],
1309 rules: PartialRulesConfig {
1310 unused_files: Some(Severity::Off),
1311 ..Default::default()
1312 },
1313 },
1314 ];
1315 let resolved = config.resolve(
1316 PathBuf::from("/project"),
1317 OutputFormat::Human,
1318 1,
1319 true,
1320 true,
1321 None,
1322 );
1323 assert_eq!(resolved.overrides.len(), 2);
1324 }
1325
1326 #[test]
1327 fn ignore_export_rule_deserialize() {
1328 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1329 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1330 assert_eq!(rule.file, "src/types/*.ts");
1331 assert_eq!(rule.exports, vec!["*"]);
1332 }
1333
1334 #[test]
1335 fn ignore_export_rule_specific_exports() {
1336 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1337 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1338 assert_eq!(rule.exports.len(), 3);
1339 assert!(rule.exports.contains(&"FOO".to_string()));
1340 }
1341
1342 mod proptests {
1343 use super::*;
1344 use proptest::prelude::*;
1345
1346 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1347 make_config(production).resolve(
1348 PathBuf::from("/project"),
1349 OutputFormat::Human,
1350 1,
1351 true,
1352 true,
1353 None,
1354 )
1355 }
1356
1357 proptest! {
1358 #[test]
1360 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1361 let resolved = arb_resolved_config(production);
1362 prop_assert!(
1363 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1364 "Default ignore should match node_modules"
1365 );
1366 prop_assert!(
1367 resolved.ignore_patterns.is_match("dist/bundle.js"),
1368 "Default ignore should match dist"
1369 );
1370 }
1371
1372 #[test]
1374 fn production_forces_dev_deps_off(_unused in Just(())) {
1375 let resolved = arb_resolved_config(true);
1376 prop_assert_eq!(
1377 resolved.rules.unused_dev_dependencies,
1378 Severity::Off,
1379 "Production should force unused_dev_dependencies off"
1380 );
1381 prop_assert_eq!(
1382 resolved.rules.unused_optional_dependencies,
1383 Severity::Off,
1384 "Production should force unused_optional_dependencies off"
1385 );
1386 }
1387
1388 #[test]
1390 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1391 let resolved = arb_resolved_config(false);
1392 prop_assert_eq!(
1393 resolved.rules.unused_dev_dependencies,
1394 Severity::Warn,
1395 "Non-production should keep default dev dep severity"
1396 );
1397 }
1398
1399 #[test]
1401 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1402 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1403 let expected_cache = root.join(".fallow");
1404 let resolved = make_config(false).resolve(
1405 root,
1406 OutputFormat::Human,
1407 1,
1408 true,
1409 true,
1410 None,
1411 );
1412 prop_assert_eq!(
1413 resolved.cache_dir, expected_cache,
1414 "Cache dir should be root/.fallow"
1415 );
1416 }
1417
1418 #[test]
1420 fn threads_passed_through(threads in 1..64usize) {
1421 let resolved = make_config(false).resolve(
1422 PathBuf::from("/project"),
1423 OutputFormat::Human,
1424 threads,
1425 true,
1426 true, None,
1427 );
1428 prop_assert_eq!(
1429 resolved.threads, threads,
1430 "Thread count should be passed through"
1431 );
1432 }
1433
1434 #[test]
1438 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1439 let mut config = make_config(false);
1440 config.ignore_patterns = vec![pattern];
1441 let resolved = config.resolve(
1442 PathBuf::from("/project"),
1443 OutputFormat::Human,
1444 1,
1445 true,
1446 true, None,
1447 );
1448 prop_assert!(
1449 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1450 "Default node_modules ignore should still be active"
1451 );
1452 }
1453 }
1454 }
1455
1456 #[test]
1457 fn resolve_expands_boundary_preset() {
1458 use crate::config::boundaries::BoundaryPreset;
1459
1460 let mut config = make_config(false);
1461 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1462 let resolved = config.resolve(
1463 PathBuf::from("/project"),
1464 OutputFormat::Human,
1465 1,
1466 true,
1467 true,
1468 None,
1469 );
1470 assert_eq!(resolved.boundaries.zones.len(), 3);
1471 assert_eq!(resolved.boundaries.rules.len(), 3);
1472 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1473 assert_eq!(
1474 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1475 Some("adapters")
1476 );
1477 }
1478
1479 #[test]
1480 fn resolve_boundary_preset_with_user_override() {
1481 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1482
1483 let mut config = make_config(false);
1484 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1485 config.boundaries.zones = vec![BoundaryZone {
1486 name: "domain".to_string(),
1487 patterns: vec!["src/core/**".to_string()],
1488 auto_discover: vec![],
1489 root: None,
1490 }];
1491 let resolved = config.resolve(
1492 PathBuf::from("/project"),
1493 OutputFormat::Human,
1494 1,
1495 true,
1496 true,
1497 None,
1498 );
1499 assert_eq!(resolved.boundaries.zones.len(), 3);
1500 assert_eq!(
1501 resolved.boundaries.classify_zone("src/core/user.ts"),
1502 Some("domain")
1503 );
1504 assert_eq!(
1505 resolved.boundaries.classify_zone("src/domain/user.ts"),
1506 None
1507 );
1508 }
1509
1510 #[test]
1511 fn resolve_no_preset_unchanged() {
1512 let config = make_config(false);
1513 let resolved = config.resolve(
1514 PathBuf::from("/project"),
1515 OutputFormat::Human,
1516 1,
1517 true,
1518 true,
1519 None,
1520 );
1521 assert!(resolved.boundaries.is_empty());
1522 }
1523}