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, 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::FallowConfig;
22use super::IgnoreExportsUsedInFileConfig;
23
24static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
40
41fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
46 let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
47 sorted.sort_unstable();
48 let mut hasher = DefaultHasher::new();
49 rule_name.hash(&mut hasher);
50 for s in &sorted {
51 s.hash(&mut hasher);
52 }
53 hasher.finish()
54}
55
56fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
61 let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
62 let key = inter_file_warn_key(rule_name, files);
63 seen.lock().map_or(true, |mut set| set.insert(key))
64}
65
66#[cfg(test)]
67fn reset_inter_file_warn_dedup_for_test() {
68 if let Some(seen) = INTER_FILE_WARN_SEEN.get()
69 && let Ok(mut set) = seen.lock()
70 {
71 set.clear();
72 }
73}
74
75#[derive(Debug, Deserialize, Serialize, JsonSchema)]
77pub struct IgnoreExportRule {
78 pub file: String,
80 pub exports: Vec<String>,
82}
83
84#[derive(Debug)]
91pub struct CompiledIgnoreExportRule {
92 pub matcher: globset::GlobMatcher,
93 pub exports: Vec<String>,
94}
95
96#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
98#[serde(rename_all = "camelCase")]
99pub struct ConfigOverride {
100 pub files: Vec<String>,
102 #[serde(default)]
104 pub rules: PartialRulesConfig,
105}
106
107#[derive(Debug)]
109pub struct ResolvedOverride {
110 pub matchers: Vec<globset::GlobMatcher>,
111 pub rules: PartialRulesConfig,
112}
113
114#[derive(Debug)]
116pub struct ResolvedConfig {
117 pub root: PathBuf,
118 pub entry_patterns: Vec<String>,
119 pub ignore_patterns: GlobSet,
120 pub output: OutputFormat,
121 pub cache_dir: PathBuf,
122 pub threads: usize,
123 pub no_cache: bool,
124 pub ignore_dependencies: Vec<String>,
125 pub ignore_export_rules: Vec<IgnoreExportRule>,
126 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
132 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
134 pub used_class_members: Vec<UsedClassMemberRule>,
138 pub duplicates: DuplicatesConfig,
139 pub health: HealthConfig,
140 pub rules: RulesConfig,
141 pub boundaries: ResolvedBoundaryConfig,
143 pub production: bool,
145 pub quiet: bool,
147 pub external_plugins: Vec<ExternalPluginDef>,
149 pub dynamically_loaded: Vec<String>,
151 pub overrides: Vec<ResolvedOverride>,
153 pub regression: Option<super::RegressionConfig>,
155 pub audit: super::AuditConfig,
157 pub codeowners: Option<String>,
159 pub public_packages: Vec<String>,
162 pub flags: FlagsConfig,
164 pub resolve: ResolveConfig,
166 pub include_entry_exports: bool,
171}
172
173impl FallowConfig {
174 pub fn resolve(
176 self,
177 root: PathBuf,
178 output: OutputFormat,
179 threads: usize,
180 no_cache: bool,
181 quiet: bool,
182 ) -> ResolvedConfig {
183 let mut ignore_builder = GlobSetBuilder::new();
184 for pattern in &self.ignore_patterns {
185 match Glob::new(pattern) {
186 Ok(glob) => {
187 ignore_builder.add(glob);
188 }
189 Err(e) => {
190 tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
191 }
192 }
193 }
194
195 let default_ignores = [
199 "**/node_modules/**",
200 "**/dist/**",
201 "build/**",
202 "**/.git/**",
203 "**/coverage/**",
204 "**/*.min.js",
205 "**/*.min.mjs",
206 ];
207 for pattern in &default_ignores {
208 if let Ok(glob) = Glob::new(pattern) {
209 ignore_builder.add(glob);
210 }
211 }
212
213 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
214 let cache_dir = root.join(".fallow");
215
216 let mut rules = self.rules;
217
218 let production = self.production.global();
220 if production {
221 rules.unused_dev_dependencies = Severity::Off;
222 rules.unused_optional_dependencies = Severity::Off;
223 }
224
225 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
226 external_plugins.extend(self.framework);
228
229 let mut boundaries = self.boundaries;
232 if boundaries.preset.is_some() {
233 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
234 .filter(|r| {
235 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
236 })
237 .unwrap_or_else(|| "src".to_owned());
238 if source_root != "src" {
239 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
240 }
241 boundaries.expand(&source_root);
242 }
243
244 let validation_errors = boundaries.validate_zone_references();
246 for (rule_idx, zone_name) in &validation_errors {
247 tracing::error!(
248 "boundary rule {} references undefined zone '{zone_name}'",
249 rule_idx
250 );
251 }
252 for message in boundaries.validate_root_prefixes() {
253 tracing::error!("{message}");
254 }
255 let boundaries = boundaries.resolve();
256
257 let overrides = self
259 .overrides
260 .into_iter()
261 .filter_map(|o| {
262 if o.rules.duplicate_exports.is_some()
272 && record_inter_file_warn_seen("duplicate-exports", &o.files)
273 {
274 let files = o.files.join(", ");
275 tracing::warn!(
276 "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."
277 );
278 }
279 if o.rules.circular_dependencies.is_some()
280 && record_inter_file_warn_seen("circular-dependency", &o.files)
281 {
282 let files = o.files.join(", ");
283 tracing::warn!(
284 "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."
285 );
286 }
287 let matchers: Vec<globset::GlobMatcher> = o
288 .files
289 .iter()
290 .filter_map(|pattern| match Glob::new(pattern) {
291 Ok(glob) => Some(glob.compile_matcher()),
292 Err(e) => {
293 tracing::warn!("invalid override glob pattern '{pattern}': {e}");
294 None
295 }
296 })
297 .collect();
298 if matchers.is_empty() {
299 None
300 } else {
301 Some(ResolvedOverride {
302 matchers,
303 rules: o.rules,
304 })
305 }
306 })
307 .collect();
308
309 let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
314 .ignore_exports
315 .iter()
316 .filter_map(|rule| match Glob::new(&rule.file) {
317 Ok(g) => Some(CompiledIgnoreExportRule {
318 matcher: g.compile_matcher(),
319 exports: rule.exports.clone(),
320 }),
321 Err(e) => {
322 tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
323 None
324 }
325 })
326 .collect();
327
328 ResolvedConfig {
329 root,
330 entry_patterns: self.entry,
331 ignore_patterns: compiled_ignore_patterns,
332 output,
333 cache_dir,
334 threads,
335 no_cache,
336 ignore_dependencies: self.ignore_dependencies,
337 ignore_export_rules: self.ignore_exports,
338 compiled_ignore_exports,
339 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
340 used_class_members: self.used_class_members,
341 duplicates: self.duplicates,
342 health: self.health,
343 rules,
344 boundaries,
345 production,
346 quiet,
347 external_plugins,
348 dynamically_loaded: self.dynamically_loaded,
349 overrides,
350 regression: self.regression,
351 audit: self.audit,
352 codeowners: self.codeowners,
353 public_packages: self.public_packages,
354 flags: self.flags,
355 resolve: self.resolve,
356 include_entry_exports: self.include_entry_exports,
357 }
358 }
359}
360
361impl ResolvedConfig {
362 #[must_use]
365 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
366 if self.overrides.is_empty() {
367 return self.rules.clone();
368 }
369
370 let relative = path.strip_prefix(&self.root).unwrap_or(path);
371 let relative_str = relative.to_string_lossy();
372
373 let mut rules = self.rules.clone();
374 for override_entry in &self.overrides {
375 let matches = override_entry
376 .matchers
377 .iter()
378 .any(|m| m.is_match(relative_str.as_ref()));
379 if matches {
380 rules.apply_partial(&override_entry.rules);
381 }
382 }
383 rules
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::config::boundaries::BoundaryConfig;
391 use crate::config::health::HealthConfig;
392
393 #[test]
394 fn overrides_deserialize() {
395 let json_str = r#"{
396 "overrides": [{
397 "files": ["*.test.ts"],
398 "rules": {
399 "unused-exports": "off"
400 }
401 }]
402 }"#;
403 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
404 assert_eq!(config.overrides.len(), 1);
405 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
406 assert_eq!(
407 config.overrides[0].rules.unused_exports,
408 Some(Severity::Off)
409 );
410 assert_eq!(config.overrides[0].rules.unused_files, None);
411 }
412
413 #[test]
414 fn resolve_rules_for_path_no_overrides() {
415 let config = FallowConfig {
416 schema: None,
417 extends: vec![],
418 entry: vec![],
419 ignore_patterns: vec![],
420 framework: vec![],
421 workspaces: None,
422 ignore_dependencies: vec![],
423 ignore_exports: vec![],
424 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
425 used_class_members: vec![],
426 duplicates: DuplicatesConfig::default(),
427 health: HealthConfig::default(),
428 rules: RulesConfig::default(),
429 boundaries: BoundaryConfig::default(),
430 production: false.into(),
431 plugins: vec![],
432 dynamically_loaded: vec![],
433 overrides: vec![],
434 regression: None,
435 audit: crate::config::AuditConfig::default(),
436 codeowners: None,
437 public_packages: vec![],
438 flags: FlagsConfig::default(),
439 resolve: ResolveConfig::default(),
440 sealed: false,
441 include_entry_exports: false,
442 };
443 let resolved = config.resolve(
444 PathBuf::from("/project"),
445 OutputFormat::Human,
446 1,
447 true,
448 true,
449 );
450 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
451 assert_eq!(rules.unused_files, Severity::Error);
452 }
453
454 #[test]
455 fn resolve_rules_for_path_with_matching_override() {
456 let config = FallowConfig {
457 schema: None,
458 extends: vec![],
459 entry: vec![],
460 ignore_patterns: vec![],
461 framework: vec![],
462 workspaces: None,
463 ignore_dependencies: vec![],
464 ignore_exports: vec![],
465 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
466 used_class_members: vec![],
467 duplicates: DuplicatesConfig::default(),
468 health: HealthConfig::default(),
469 rules: RulesConfig::default(),
470 boundaries: BoundaryConfig::default(),
471 production: false.into(),
472 plugins: vec![],
473 dynamically_loaded: vec![],
474 overrides: vec![ConfigOverride {
475 files: vec!["*.test.ts".to_string()],
476 rules: PartialRulesConfig {
477 unused_exports: Some(Severity::Off),
478 ..Default::default()
479 },
480 }],
481 regression: None,
482 audit: crate::config::AuditConfig::default(),
483 codeowners: None,
484 public_packages: vec![],
485 flags: FlagsConfig::default(),
486 resolve: ResolveConfig::default(),
487 sealed: false,
488 include_entry_exports: false,
489 };
490 let resolved = config.resolve(
491 PathBuf::from("/project"),
492 OutputFormat::Human,
493 1,
494 true,
495 true,
496 );
497
498 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
500 assert_eq!(test_rules.unused_exports, Severity::Off);
501 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
505 assert_eq!(src_rules.unused_exports, Severity::Error);
506 }
507
508 #[test]
509 fn resolve_rules_for_path_later_override_wins() {
510 let config = FallowConfig {
511 schema: None,
512 extends: vec![],
513 entry: vec![],
514 ignore_patterns: vec![],
515 framework: vec![],
516 workspaces: None,
517 ignore_dependencies: vec![],
518 ignore_exports: vec![],
519 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
520 used_class_members: vec![],
521 duplicates: DuplicatesConfig::default(),
522 health: HealthConfig::default(),
523 rules: RulesConfig::default(),
524 boundaries: BoundaryConfig::default(),
525 production: false.into(),
526 plugins: vec![],
527 dynamically_loaded: vec![],
528 overrides: vec![
529 ConfigOverride {
530 files: vec!["*.ts".to_string()],
531 rules: PartialRulesConfig {
532 unused_files: Some(Severity::Warn),
533 ..Default::default()
534 },
535 },
536 ConfigOverride {
537 files: vec!["*.test.ts".to_string()],
538 rules: PartialRulesConfig {
539 unused_files: Some(Severity::Off),
540 ..Default::default()
541 },
542 },
543 ],
544 regression: None,
545 audit: crate::config::AuditConfig::default(),
546 codeowners: None,
547 public_packages: vec![],
548 flags: FlagsConfig::default(),
549 resolve: ResolveConfig::default(),
550 sealed: false,
551 include_entry_exports: false,
552 };
553 let resolved = config.resolve(
554 PathBuf::from("/project"),
555 OutputFormat::Human,
556 1,
557 true,
558 true,
559 );
560
561 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
563 assert_eq!(rules.unused_files, Severity::Off);
564
565 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
567 assert_eq!(rules2.unused_files, Severity::Warn);
568 }
569
570 #[test]
571 fn resolve_keeps_inter_file_rule_override_after_warning() {
572 let config = FallowConfig {
578 schema: None,
579 extends: vec![],
580 entry: vec![],
581 ignore_patterns: vec![],
582 framework: vec![],
583 workspaces: None,
584 ignore_dependencies: vec![],
585 ignore_exports: vec![],
586 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
587 used_class_members: vec![],
588 duplicates: DuplicatesConfig::default(),
589 health: HealthConfig::default(),
590 rules: RulesConfig::default(),
591 boundaries: BoundaryConfig::default(),
592 production: false.into(),
593 plugins: vec![],
594 dynamically_loaded: vec![],
595 overrides: vec![ConfigOverride {
596 files: vec!["**/ui/**".to_string()],
597 rules: PartialRulesConfig {
598 duplicate_exports: Some(Severity::Off),
599 unused_files: Some(Severity::Warn),
600 ..Default::default()
601 },
602 }],
603 regression: None,
604 audit: crate::config::AuditConfig::default(),
605 codeowners: None,
606 public_packages: vec![],
607 flags: FlagsConfig::default(),
608 resolve: ResolveConfig::default(),
609 sealed: false,
610 include_entry_exports: false,
611 };
612 let resolved = config.resolve(
613 PathBuf::from("/project"),
614 OutputFormat::Human,
615 1,
616 true,
617 true,
618 );
619 assert_eq!(
620 resolved.overrides.len(),
621 1,
622 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
623 );
624 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
625 assert_eq!(rules.unused_files, Severity::Warn);
626 }
627
628 #[test]
629 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
630 reset_inter_file_warn_dedup_for_test();
634 let files_a = vec!["__test_dedup_a/*".to_string()];
635 let files_b = vec!["__test_dedup_b/*".to_string()];
636
637 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
639 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
640 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
641
642 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
644 assert!(!record_inter_file_warn_seen(
645 "circular-dependency",
646 &files_a
647 ));
648
649 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
651
652 let files_reordered = vec![
654 "__test_dedup_b/*".to_string(),
655 "__test_dedup_a/*".to_string(),
656 ];
657 let files_natural = vec![
658 "__test_dedup_a/*".to_string(),
659 "__test_dedup_b/*".to_string(),
660 ];
661 reset_inter_file_warn_dedup_for_test();
662 assert!(record_inter_file_warn_seen(
663 "duplicate-exports",
664 &files_natural
665 ));
666 assert!(!record_inter_file_warn_seen(
667 "duplicate-exports",
668 &files_reordered
669 ));
670 }
671
672 #[test]
673 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
674 reset_inter_file_warn_dedup_for_test();
679 let files = vec!["__test_resolve_dedup/**".to_string()];
680 let build_config = || FallowConfig {
681 schema: None,
682 extends: vec![],
683 entry: vec![],
684 ignore_patterns: vec![],
685 framework: vec![],
686 workspaces: None,
687 ignore_dependencies: vec![],
688 ignore_exports: vec![],
689 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
690 used_class_members: vec![],
691 duplicates: DuplicatesConfig::default(),
692 health: HealthConfig::default(),
693 rules: RulesConfig::default(),
694 boundaries: BoundaryConfig::default(),
695 production: false.into(),
696 plugins: vec![],
697 dynamically_loaded: vec![],
698 overrides: vec![ConfigOverride {
699 files: files.clone(),
700 rules: PartialRulesConfig {
701 duplicate_exports: Some(Severity::Off),
702 ..Default::default()
703 },
704 }],
705 regression: None,
706 audit: crate::config::AuditConfig::default(),
707 codeowners: None,
708 public_packages: vec![],
709 flags: FlagsConfig::default(),
710 resolve: ResolveConfig::default(),
711 sealed: false,
712 include_entry_exports: false,
713 };
714 for _ in 0..10 {
715 let _ = build_config().resolve(
716 PathBuf::from("/project"),
717 OutputFormat::Human,
718 1,
719 true,
720 true,
721 );
722 }
723 assert!(
727 !record_inter_file_warn_seen("duplicate-exports", &files),
728 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
729 );
730 }
731
732 fn make_config(production: bool) -> FallowConfig {
734 FallowConfig {
735 schema: None,
736 extends: vec![],
737 entry: vec![],
738 ignore_patterns: vec![],
739 framework: vec![],
740 workspaces: None,
741 ignore_dependencies: vec![],
742 ignore_exports: vec![],
743 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
744 used_class_members: vec![],
745 duplicates: DuplicatesConfig::default(),
746 health: HealthConfig::default(),
747 rules: RulesConfig::default(),
748 boundaries: BoundaryConfig::default(),
749 production: production.into(),
750 plugins: vec![],
751 dynamically_loaded: vec![],
752 overrides: vec![],
753 regression: None,
754 audit: crate::config::AuditConfig::default(),
755 codeowners: None,
756 public_packages: vec![],
757 flags: FlagsConfig::default(),
758 resolve: ResolveConfig::default(),
759 sealed: false,
760 include_entry_exports: false,
761 }
762 }
763
764 #[test]
767 fn resolve_production_forces_dev_deps_off() {
768 let resolved = make_config(true).resolve(
769 PathBuf::from("/project"),
770 OutputFormat::Human,
771 1,
772 true,
773 true,
774 );
775 assert_eq!(
776 resolved.rules.unused_dev_dependencies,
777 Severity::Off,
778 "production mode should force unused_dev_dependencies to off"
779 );
780 }
781
782 #[test]
783 fn resolve_production_forces_optional_deps_off() {
784 let resolved = make_config(true).resolve(
785 PathBuf::from("/project"),
786 OutputFormat::Human,
787 1,
788 true,
789 true,
790 );
791 assert_eq!(
792 resolved.rules.unused_optional_dependencies,
793 Severity::Off,
794 "production mode should force unused_optional_dependencies to off"
795 );
796 }
797
798 #[test]
799 fn resolve_production_preserves_other_rules() {
800 let resolved = make_config(true).resolve(
801 PathBuf::from("/project"),
802 OutputFormat::Human,
803 1,
804 true,
805 true,
806 );
807 assert_eq!(resolved.rules.unused_files, Severity::Error);
809 assert_eq!(resolved.rules.unused_exports, Severity::Error);
810 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
811 }
812
813 #[test]
814 fn resolve_non_production_keeps_dev_deps_default() {
815 let resolved = make_config(false).resolve(
816 PathBuf::from("/project"),
817 OutputFormat::Human,
818 1,
819 true,
820 true,
821 );
822 assert_eq!(
823 resolved.rules.unused_dev_dependencies,
824 Severity::Warn,
825 "non-production should keep default severity"
826 );
827 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
828 }
829
830 #[test]
831 fn resolve_production_flag_stored() {
832 let resolved = make_config(true).resolve(
833 PathBuf::from("/project"),
834 OutputFormat::Human,
835 1,
836 true,
837 true,
838 );
839 assert!(resolved.production);
840
841 let resolved2 = make_config(false).resolve(
842 PathBuf::from("/project"),
843 OutputFormat::Human,
844 1,
845 true,
846 true,
847 );
848 assert!(!resolved2.production);
849 }
850
851 #[test]
854 fn resolve_default_ignores_node_modules() {
855 let resolved = make_config(false).resolve(
856 PathBuf::from("/project"),
857 OutputFormat::Human,
858 1,
859 true,
860 true,
861 );
862 assert!(
863 resolved
864 .ignore_patterns
865 .is_match("node_modules/lodash/index.js")
866 );
867 assert!(
868 resolved
869 .ignore_patterns
870 .is_match("packages/a/node_modules/react/index.js")
871 );
872 }
873
874 #[test]
875 fn resolve_default_ignores_dist() {
876 let resolved = make_config(false).resolve(
877 PathBuf::from("/project"),
878 OutputFormat::Human,
879 1,
880 true,
881 true,
882 );
883 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
884 assert!(
885 resolved
886 .ignore_patterns
887 .is_match("packages/ui/dist/index.js")
888 );
889 }
890
891 #[test]
892 fn resolve_default_ignores_root_build_only() {
893 let resolved = make_config(false).resolve(
894 PathBuf::from("/project"),
895 OutputFormat::Human,
896 1,
897 true,
898 true,
899 );
900 assert!(
901 resolved.ignore_patterns.is_match("build/output.js"),
902 "root build/ should be ignored"
903 );
904 assert!(
906 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
907 "nested build/ should NOT be ignored by default"
908 );
909 }
910
911 #[test]
912 fn resolve_default_ignores_minified_files() {
913 let resolved = make_config(false).resolve(
914 PathBuf::from("/project"),
915 OutputFormat::Human,
916 1,
917 true,
918 true,
919 );
920 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
921 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
922 }
923
924 #[test]
925 fn resolve_default_ignores_git() {
926 let resolved = make_config(false).resolve(
927 PathBuf::from("/project"),
928 OutputFormat::Human,
929 1,
930 true,
931 true,
932 );
933 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
934 }
935
936 #[test]
937 fn resolve_default_ignores_coverage() {
938 let resolved = make_config(false).resolve(
939 PathBuf::from("/project"),
940 OutputFormat::Human,
941 1,
942 true,
943 true,
944 );
945 assert!(
946 resolved
947 .ignore_patterns
948 .is_match("coverage/lcov-report/index.js")
949 );
950 }
951
952 #[test]
953 fn resolve_source_files_not_ignored_by_default() {
954 let resolved = make_config(false).resolve(
955 PathBuf::from("/project"),
956 OutputFormat::Human,
957 1,
958 true,
959 true,
960 );
961 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
962 assert!(
963 !resolved
964 .ignore_patterns
965 .is_match("src/components/Button.tsx")
966 );
967 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
968 }
969
970 #[test]
973 fn resolve_custom_ignore_patterns_merged_with_defaults() {
974 let mut config = make_config(false);
975 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
976 let resolved = config.resolve(
977 PathBuf::from("/project"),
978 OutputFormat::Human,
979 1,
980 true,
981 true,
982 );
983 assert!(
985 resolved
986 .ignore_patterns
987 .is_match("src/__generated__/types.ts")
988 );
989 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
991 }
992
993 #[test]
996 fn resolve_passes_through_entry_patterns() {
997 let mut config = make_config(false);
998 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
999 let resolved = config.resolve(
1000 PathBuf::from("/project"),
1001 OutputFormat::Human,
1002 1,
1003 true,
1004 true,
1005 );
1006 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1007 }
1008
1009 #[test]
1010 fn resolve_passes_through_ignore_dependencies() {
1011 let mut config = make_config(false);
1012 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1013 let resolved = config.resolve(
1014 PathBuf::from("/project"),
1015 OutputFormat::Human,
1016 1,
1017 true,
1018 true,
1019 );
1020 assert_eq!(
1021 resolved.ignore_dependencies,
1022 vec!["postcss", "autoprefixer"]
1023 );
1024 }
1025
1026 #[test]
1027 fn resolve_sets_cache_dir() {
1028 let resolved = make_config(false).resolve(
1029 PathBuf::from("/my/project"),
1030 OutputFormat::Human,
1031 1,
1032 true,
1033 true,
1034 );
1035 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1036 }
1037
1038 #[test]
1039 fn resolve_passes_through_thread_count() {
1040 let resolved = make_config(false).resolve(
1041 PathBuf::from("/project"),
1042 OutputFormat::Human,
1043 8,
1044 true,
1045 true,
1046 );
1047 assert_eq!(resolved.threads, 8);
1048 }
1049
1050 #[test]
1051 fn resolve_passes_through_quiet_flag() {
1052 let resolved = make_config(false).resolve(
1053 PathBuf::from("/project"),
1054 OutputFormat::Human,
1055 1,
1056 true,
1057 false,
1058 );
1059 assert!(!resolved.quiet);
1060
1061 let resolved2 = make_config(false).resolve(
1062 PathBuf::from("/project"),
1063 OutputFormat::Human,
1064 1,
1065 true,
1066 true,
1067 );
1068 assert!(resolved2.quiet);
1069 }
1070
1071 #[test]
1072 fn resolve_passes_through_no_cache_flag() {
1073 let resolved_no_cache = make_config(false).resolve(
1074 PathBuf::from("/project"),
1075 OutputFormat::Human,
1076 1,
1077 true,
1078 true,
1079 );
1080 assert!(resolved_no_cache.no_cache);
1081
1082 let resolved_with_cache = make_config(false).resolve(
1083 PathBuf::from("/project"),
1084 OutputFormat::Human,
1085 1,
1086 false,
1087 true,
1088 );
1089 assert!(!resolved_with_cache.no_cache);
1090 }
1091
1092 #[test]
1095 fn resolve_override_with_invalid_glob_skipped() {
1096 let mut config = make_config(false);
1097 config.overrides = vec![ConfigOverride {
1098 files: vec!["[invalid".to_string()],
1099 rules: PartialRulesConfig {
1100 unused_files: Some(Severity::Off),
1101 ..Default::default()
1102 },
1103 }];
1104 let resolved = config.resolve(
1105 PathBuf::from("/project"),
1106 OutputFormat::Human,
1107 1,
1108 true,
1109 true,
1110 );
1111 assert!(
1113 resolved.overrides.is_empty(),
1114 "override with invalid glob should be skipped"
1115 );
1116 }
1117
1118 #[test]
1119 fn resolve_override_with_empty_files_skipped() {
1120 let mut config = make_config(false);
1121 config.overrides = vec![ConfigOverride {
1122 files: vec![],
1123 rules: PartialRulesConfig {
1124 unused_files: Some(Severity::Off),
1125 ..Default::default()
1126 },
1127 }];
1128 let resolved = config.resolve(
1129 PathBuf::from("/project"),
1130 OutputFormat::Human,
1131 1,
1132 true,
1133 true,
1134 );
1135 assert!(
1136 resolved.overrides.is_empty(),
1137 "override with no file patterns should be skipped"
1138 );
1139 }
1140
1141 #[test]
1142 fn resolve_multiple_valid_overrides() {
1143 let mut config = make_config(false);
1144 config.overrides = vec![
1145 ConfigOverride {
1146 files: vec!["*.test.ts".to_string()],
1147 rules: PartialRulesConfig {
1148 unused_exports: Some(Severity::Off),
1149 ..Default::default()
1150 },
1151 },
1152 ConfigOverride {
1153 files: vec!["*.stories.tsx".to_string()],
1154 rules: PartialRulesConfig {
1155 unused_files: Some(Severity::Off),
1156 ..Default::default()
1157 },
1158 },
1159 ];
1160 let resolved = config.resolve(
1161 PathBuf::from("/project"),
1162 OutputFormat::Human,
1163 1,
1164 true,
1165 true,
1166 );
1167 assert_eq!(resolved.overrides.len(), 2);
1168 }
1169
1170 #[test]
1173 fn ignore_export_rule_deserialize() {
1174 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1175 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1176 assert_eq!(rule.file, "src/types/*.ts");
1177 assert_eq!(rule.exports, vec!["*"]);
1178 }
1179
1180 #[test]
1181 fn ignore_export_rule_specific_exports() {
1182 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1183 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1184 assert_eq!(rule.exports.len(), 3);
1185 assert!(rule.exports.contains(&"FOO".to_string()));
1186 }
1187
1188 mod proptests {
1189 use super::*;
1190 use proptest::prelude::*;
1191
1192 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1193 make_config(production).resolve(
1194 PathBuf::from("/project"),
1195 OutputFormat::Human,
1196 1,
1197 true,
1198 true,
1199 )
1200 }
1201
1202 proptest! {
1203 #[test]
1205 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1206 let resolved = arb_resolved_config(production);
1207 prop_assert!(
1209 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1210 "Default ignore should match node_modules"
1211 );
1212 prop_assert!(
1213 resolved.ignore_patterns.is_match("dist/bundle.js"),
1214 "Default ignore should match dist"
1215 );
1216 }
1217
1218 #[test]
1220 fn production_forces_dev_deps_off(_unused in Just(())) {
1221 let resolved = arb_resolved_config(true);
1222 prop_assert_eq!(
1223 resolved.rules.unused_dev_dependencies,
1224 Severity::Off,
1225 "Production should force unused_dev_dependencies off"
1226 );
1227 prop_assert_eq!(
1228 resolved.rules.unused_optional_dependencies,
1229 Severity::Off,
1230 "Production should force unused_optional_dependencies off"
1231 );
1232 }
1233
1234 #[test]
1236 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1237 let resolved = arb_resolved_config(false);
1238 prop_assert_eq!(
1239 resolved.rules.unused_dev_dependencies,
1240 Severity::Warn,
1241 "Non-production should keep default dev dep severity"
1242 );
1243 }
1244
1245 #[test]
1247 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1248 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1249 let expected_cache = root.join(".fallow");
1250 let resolved = make_config(false).resolve(
1251 root,
1252 OutputFormat::Human,
1253 1,
1254 true,
1255 true,
1256 );
1257 prop_assert_eq!(
1258 resolved.cache_dir, expected_cache,
1259 "Cache dir should be root/.fallow"
1260 );
1261 }
1262
1263 #[test]
1265 fn threads_passed_through(threads in 1..64usize) {
1266 let resolved = make_config(false).resolve(
1267 PathBuf::from("/project"),
1268 OutputFormat::Human,
1269 threads,
1270 true,
1271 true,
1272 );
1273 prop_assert_eq!(
1274 resolved.threads, threads,
1275 "Thread count should be passed through"
1276 );
1277 }
1278
1279 #[test]
1283 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1284 let mut config = make_config(false);
1285 config.ignore_patterns = vec![pattern];
1286 let resolved = config.resolve(
1287 PathBuf::from("/project"),
1288 OutputFormat::Human,
1289 1,
1290 true,
1291 true,
1292 );
1293 prop_assert!(
1296 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1297 "Default node_modules ignore should still be active"
1298 );
1299 }
1300 }
1301 }
1302
1303 #[test]
1306 fn resolve_expands_boundary_preset() {
1307 use crate::config::boundaries::BoundaryPreset;
1308
1309 let mut config = make_config(false);
1310 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1311 let resolved = config.resolve(
1312 PathBuf::from("/project"),
1313 OutputFormat::Human,
1314 1,
1315 true,
1316 true,
1317 );
1318 assert_eq!(resolved.boundaries.zones.len(), 3);
1320 assert_eq!(resolved.boundaries.rules.len(), 3);
1321 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1322 assert_eq!(
1323 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1324 Some("adapters")
1325 );
1326 }
1327
1328 #[test]
1329 fn resolve_boundary_preset_with_user_override() {
1330 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1331
1332 let mut config = make_config(false);
1333 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1334 config.boundaries.zones = vec![BoundaryZone {
1335 name: "domain".to_string(),
1336 patterns: vec!["src/core/**".to_string()],
1337 root: None,
1338 }];
1339 let resolved = config.resolve(
1340 PathBuf::from("/project"),
1341 OutputFormat::Human,
1342 1,
1343 true,
1344 true,
1345 );
1346 assert_eq!(resolved.boundaries.zones.len(), 3);
1348 assert_eq!(
1350 resolved.boundaries.classify_zone("src/core/user.ts"),
1351 Some("domain")
1352 );
1353 assert_eq!(
1355 resolved.boundaries.classify_zone("src/domain/user.ts"),
1356 None
1357 );
1358 }
1359
1360 #[test]
1361 fn resolve_no_preset_unchanged() {
1362 let config = make_config(false);
1363 let resolved = config.resolve(
1364 PathBuf::from("/project"),
1365 OutputFormat::Human,
1366 1,
1367 true,
1368 true,
1369 );
1370 assert!(resolved.boundaries.is_empty());
1371 }
1372}