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