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
573fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
574 pattern.and_then(|pattern| match regex::Regex::new(pattern) {
575 Ok(re) => Some(re),
576 Err(error) => {
577 tracing::warn!(
578 %error,
579 "ignoring invalid unusedComponentProps.ignorePattern; this config was \
580 not validated through FallowConfig::load"
581 );
582 None
583 }
584 })
585}
586
587impl FallowConfig {
588 #[expect(
590 clippy::too_many_arguments,
591 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"
592 )]
593 pub fn resolve(
594 self,
595 root: PathBuf,
596 output: OutputFormat,
597 threads: usize,
598 no_cache: bool,
599 quiet: bool,
600 cache_max_size_mb: Option<u32>,
601 ) -> ResolvedConfig {
602 let compiled_ignores = compile_ignore_settings(&self);
603
604 let production_rules = resolve_production_rules(self.production, self.rules);
605
606 let plugins =
607 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
608
609 let cache = resolve_cache_settings(
610 &root,
611 self.cache.dir,
612 self.cache.max_size_mb,
613 cache_max_size_mb,
614 no_cache,
615 &plugins.external_plugins,
616 );
617
618 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
619
620 let unused_component_props_ignore = compile_unused_component_props_ignore(
621 self.unused_component_props.ignore_pattern.as_deref(),
622 );
623
624 ResolvedConfig {
625 root,
626 entry_patterns: self.entry,
627 ignore_patterns: compiled_ignores.patterns,
628 output,
629 cache_dir: cache.dir,
630 threads,
631 no_cache,
632 cache_max_size_mb: cache.max_size_mb,
633 cache_config_hash: cache.config_hash,
634 ignore_dependencies: self.ignore_dependencies,
635 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
636 ignore_export_rules: self.ignore_exports,
637 compiled_ignore_exports: compiled_ignores.exports,
638 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
639 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
640 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
641 used_class_members: self.used_class_members,
642 ignore_decorators: self.ignore_decorators,
643 unused_component_props_ignore,
644 duplicates: self.duplicates,
645 health: self.health,
646 rules: production_rules.rules,
647 boundaries: path_policy.boundaries,
648 rule_packs: plugins.rule_packs,
649 rule_pack_sources: plugins.rule_pack_sources,
650 production: production_rules.production,
651 quiet,
652 external_plugins: plugins.external_plugins,
653 dynamically_loaded: self.dynamically_loaded,
654 overrides: path_policy.overrides,
655 regression: self.regression,
656 audit: self.audit,
657 codeowners: self.codeowners,
658 public_packages: self.public_packages,
659 flags: self.flags,
660 security: normalize_security_config(self.security),
661 fix: self.fix,
662 resolve: self.resolve,
663 include_entry_exports: self.include_entry_exports,
664 auto_imports: self.auto_imports,
665 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
666 }
667 }
668}
669
670impl ResolvedConfig {
671 #[must_use]
674 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
675 if self.overrides.is_empty() {
676 return self.rules.clone();
677 }
678
679 let relative = path.strip_prefix(&self.root).unwrap_or(path);
680 let relative_str = relative.to_string_lossy();
681
682 let mut rules = self.rules.clone();
683 for override_entry in &self.overrides {
684 let matches = override_entry
685 .matchers
686 .iter()
687 .any(|m| m.is_match(relative_str.as_ref()));
688 if matches {
689 rules.apply_partial(&override_entry.rules);
690 }
691 }
692 rules
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::CacheConfig;
700 use crate::config::boundaries::BoundaryConfig;
701 use crate::config::health::HealthConfig;
702
703 #[test]
704 fn overrides_deserialize() {
705 let json_str = r#"{
706 "overrides": [{
707 "files": ["*.test.ts"],
708 "rules": {
709 "unused-exports": "off"
710 }
711 }]
712 }"#;
713 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
714 assert_eq!(config.overrides.len(), 1);
715 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
716 assert_eq!(
717 config.overrides[0].rules.unused_exports,
718 Some(Severity::Off)
719 );
720 assert_eq!(config.overrides[0].rules.unused_files, None);
721 }
722
723 #[test]
724 fn resolve_rules_for_path_no_overrides() {
725 let config = FallowConfig {
726 schema: None,
727 extends: vec![],
728 entry: vec![],
729 ignore_patterns: vec![],
730 framework: vec![],
731 workspaces: None,
732 ignore_dependencies: vec![],
733 ignore_unresolved_imports: vec![],
734 ignore_exports: vec![],
735 ignore_catalog_references: vec![],
736 ignore_dependency_overrides: vec![],
737 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
738 used_class_members: vec![],
739 ignore_decorators: vec![],
740 unused_component_props: crate::UnusedComponentPropsConfig::default(),
741 duplicates: DuplicatesConfig::default(),
742 health: HealthConfig::default(),
743 rules: RulesConfig::default(),
744 boundaries: BoundaryConfig::default(),
745 production: false.into(),
746 plugins: vec![],
747 rule_packs: vec![],
748 dynamically_loaded: vec![],
749 overrides: vec![],
750 regression: None,
751 audit: crate::config::AuditConfig::default(),
752 codeowners: None,
753 public_packages: vec![],
754 flags: FlagsConfig::default(),
755 security: SecurityConfig::default(),
756 fix: crate::config::FixConfig::default(),
757 resolve: ResolveConfig::default(),
758 sealed: false,
759 include_entry_exports: false,
760 auto_imports: false,
761 cache: CacheConfig::default(),
762 };
763 let resolved = config.resolve(
764 PathBuf::from("/project"),
765 OutputFormat::Human,
766 1,
767 true,
768 true,
769 None,
770 );
771 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
772 assert_eq!(rules.unused_files, Severity::Error);
773 }
774
775 #[test]
776 fn resolve_rules_for_path_with_matching_override() {
777 let config = FallowConfig {
778 schema: None,
779 extends: vec![],
780 entry: vec![],
781 ignore_patterns: vec![],
782 framework: vec![],
783 workspaces: None,
784 ignore_dependencies: vec![],
785 ignore_unresolved_imports: vec![],
786 ignore_exports: vec![],
787 ignore_catalog_references: vec![],
788 ignore_dependency_overrides: vec![],
789 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
790 used_class_members: vec![],
791 ignore_decorators: vec![],
792 unused_component_props: crate::UnusedComponentPropsConfig::default(),
793 duplicates: DuplicatesConfig::default(),
794 health: HealthConfig::default(),
795 rules: RulesConfig::default(),
796 boundaries: BoundaryConfig::default(),
797 production: false.into(),
798 plugins: vec![],
799 rule_packs: vec![],
800 dynamically_loaded: vec![],
801 overrides: vec![ConfigOverride {
802 files: vec!["*.test.ts".to_string()],
803 rules: PartialRulesConfig {
804 unused_exports: Some(Severity::Off),
805 ..Default::default()
806 },
807 }],
808 regression: None,
809 audit: crate::config::AuditConfig::default(),
810 codeowners: None,
811 public_packages: vec![],
812 flags: FlagsConfig::default(),
813 security: SecurityConfig::default(),
814 fix: crate::config::FixConfig::default(),
815 resolve: ResolveConfig::default(),
816 sealed: false,
817 include_entry_exports: false,
818 auto_imports: false,
819 cache: CacheConfig::default(),
820 };
821 let resolved = config.resolve(
822 PathBuf::from("/project"),
823 OutputFormat::Human,
824 1,
825 true,
826 true,
827 None,
828 );
829
830 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
831 assert_eq!(test_rules.unused_exports, Severity::Off);
832 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
835 assert_eq!(src_rules.unused_exports, Severity::Error);
836 }
837
838 #[test]
839 fn resolve_rules_for_path_later_override_wins() {
840 let config = FallowConfig {
841 schema: None,
842 extends: vec![],
843 entry: vec![],
844 ignore_patterns: vec![],
845 framework: vec![],
846 workspaces: None,
847 ignore_dependencies: vec![],
848 ignore_unresolved_imports: vec![],
849 ignore_exports: vec![],
850 ignore_catalog_references: vec![],
851 ignore_dependency_overrides: vec![],
852 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
853 used_class_members: vec![],
854 ignore_decorators: vec![],
855 unused_component_props: crate::UnusedComponentPropsConfig::default(),
856 duplicates: DuplicatesConfig::default(),
857 health: HealthConfig::default(),
858 rules: RulesConfig::default(),
859 boundaries: BoundaryConfig::default(),
860 production: false.into(),
861 plugins: vec![],
862 rule_packs: vec![],
863 dynamically_loaded: vec![],
864 overrides: vec![
865 ConfigOverride {
866 files: vec!["*.ts".to_string()],
867 rules: PartialRulesConfig {
868 unused_files: Some(Severity::Warn),
869 ..Default::default()
870 },
871 },
872 ConfigOverride {
873 files: vec!["*.test.ts".to_string()],
874 rules: PartialRulesConfig {
875 unused_files: Some(Severity::Off),
876 ..Default::default()
877 },
878 },
879 ],
880 regression: None,
881 audit: crate::config::AuditConfig::default(),
882 codeowners: None,
883 public_packages: vec![],
884 flags: FlagsConfig::default(),
885 security: SecurityConfig::default(),
886 fix: crate::config::FixConfig::default(),
887 resolve: ResolveConfig::default(),
888 sealed: false,
889 include_entry_exports: false,
890 auto_imports: false,
891 cache: CacheConfig::default(),
892 };
893 let resolved = config.resolve(
894 PathBuf::from("/project"),
895 OutputFormat::Human,
896 1,
897 true,
898 true,
899 None,
900 );
901
902 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
903 assert_eq!(rules.unused_files, Severity::Off);
904
905 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
906 assert_eq!(rules2.unused_files, Severity::Warn);
907 }
908
909 #[test]
910 fn resolve_keeps_inter_file_rule_override_after_warning() {
911 let config = FallowConfig {
912 schema: None,
913 extends: vec![],
914 entry: vec![],
915 ignore_patterns: vec![],
916 framework: vec![],
917 workspaces: None,
918 ignore_dependencies: vec![],
919 ignore_unresolved_imports: vec![],
920 ignore_exports: vec![],
921 ignore_catalog_references: vec![],
922 ignore_dependency_overrides: vec![],
923 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
924 used_class_members: vec![],
925 ignore_decorators: vec![],
926 unused_component_props: crate::UnusedComponentPropsConfig::default(),
927 duplicates: DuplicatesConfig::default(),
928 health: HealthConfig::default(),
929 rules: RulesConfig::default(),
930 boundaries: BoundaryConfig::default(),
931 production: false.into(),
932 plugins: vec![],
933 rule_packs: vec![],
934 dynamically_loaded: vec![],
935 overrides: vec![ConfigOverride {
936 files: vec!["**/ui/**".to_string()],
937 rules: PartialRulesConfig {
938 duplicate_exports: Some(Severity::Off),
939 unused_files: Some(Severity::Warn),
940 ..Default::default()
941 },
942 }],
943 regression: None,
944 audit: crate::config::AuditConfig::default(),
945 codeowners: None,
946 public_packages: vec![],
947 flags: FlagsConfig::default(),
948 security: SecurityConfig::default(),
949 fix: crate::config::FixConfig::default(),
950 resolve: ResolveConfig::default(),
951 sealed: false,
952 include_entry_exports: false,
953 auto_imports: false,
954 cache: CacheConfig::default(),
955 };
956 let resolved = config.resolve(
957 PathBuf::from("/project"),
958 OutputFormat::Human,
959 1,
960 true,
961 true,
962 None,
963 );
964 assert_eq!(
965 resolved.overrides.len(),
966 1,
967 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
968 );
969 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
970 assert_eq!(rules.unused_files, Severity::Warn);
971 }
972
973 #[test]
974 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
975 reset_inter_file_warn_dedup_for_test();
976 let files_a = vec!["__test_dedup_a/*".to_string()];
977 let files_b = vec!["__test_dedup_b/*".to_string()];
978
979 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
980 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
981 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
982
983 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
984 assert!(!record_inter_file_warn_seen(
985 "circular-dependency",
986 &files_a
987 ));
988
989 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
990
991 let files_reordered = vec![
992 "__test_dedup_b/*".to_string(),
993 "__test_dedup_a/*".to_string(),
994 ];
995 let files_natural = vec![
996 "__test_dedup_a/*".to_string(),
997 "__test_dedup_b/*".to_string(),
998 ];
999 reset_inter_file_warn_dedup_for_test();
1000 assert!(record_inter_file_warn_seen(
1001 "duplicate-exports",
1002 &files_natural
1003 ));
1004 assert!(!record_inter_file_warn_seen(
1005 "duplicate-exports",
1006 &files_reordered
1007 ));
1008 }
1009
1010 #[test]
1011 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1012 reset_inter_file_warn_dedup_for_test();
1013 let files = vec!["__test_resolve_dedup/**".to_string()];
1014 let build_config = || FallowConfig {
1015 schema: None,
1016 extends: vec![],
1017 entry: vec![],
1018 ignore_patterns: vec![],
1019 framework: vec![],
1020 workspaces: None,
1021 ignore_dependencies: vec![],
1022 ignore_unresolved_imports: vec![],
1023 ignore_exports: vec![],
1024 ignore_catalog_references: vec![],
1025 ignore_dependency_overrides: vec![],
1026 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1027 used_class_members: vec![],
1028 ignore_decorators: vec![],
1029 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1030 duplicates: DuplicatesConfig::default(),
1031 health: HealthConfig::default(),
1032 rules: RulesConfig::default(),
1033 boundaries: BoundaryConfig::default(),
1034 production: false.into(),
1035 plugins: vec![],
1036 rule_packs: vec![],
1037 dynamically_loaded: vec![],
1038 overrides: vec![ConfigOverride {
1039 files: files.clone(),
1040 rules: PartialRulesConfig {
1041 duplicate_exports: Some(Severity::Off),
1042 ..Default::default()
1043 },
1044 }],
1045 regression: None,
1046 audit: crate::config::AuditConfig::default(),
1047 codeowners: None,
1048 public_packages: vec![],
1049 flags: FlagsConfig::default(),
1050 security: SecurityConfig::default(),
1051 fix: crate::config::FixConfig::default(),
1052 resolve: ResolveConfig::default(),
1053 sealed: false,
1054 include_entry_exports: false,
1055 auto_imports: false,
1056 cache: CacheConfig::default(),
1057 };
1058 for _ in 0..10 {
1059 let _ = build_config().resolve(
1060 PathBuf::from("/project"),
1061 OutputFormat::Human,
1062 1,
1063 true,
1064 true,
1065 None,
1066 );
1067 }
1068 assert!(
1069 !record_inter_file_warn_seen("duplicate-exports", &files),
1070 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1071 );
1072 }
1073
1074 fn make_config(production: bool) -> FallowConfig {
1076 FallowConfig {
1077 schema: None,
1078 extends: vec![],
1079 entry: vec![],
1080 ignore_patterns: vec![],
1081 framework: vec![],
1082 workspaces: None,
1083 ignore_dependencies: vec![],
1084 ignore_unresolved_imports: vec![],
1085 ignore_exports: vec![],
1086 ignore_catalog_references: vec![],
1087 ignore_dependency_overrides: vec![],
1088 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1089 used_class_members: vec![],
1090 ignore_decorators: vec![],
1091 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1092 duplicates: DuplicatesConfig::default(),
1093 health: HealthConfig::default(),
1094 rules: RulesConfig::default(),
1095 boundaries: BoundaryConfig::default(),
1096 production: production.into(),
1097 plugins: vec![],
1098 rule_packs: vec![],
1099 dynamically_loaded: vec![],
1100 overrides: vec![],
1101 regression: None,
1102 audit: crate::config::AuditConfig::default(),
1103 codeowners: None,
1104 public_packages: vec![],
1105 flags: FlagsConfig::default(),
1106 security: SecurityConfig::default(),
1107 fix: crate::config::FixConfig::default(),
1108 resolve: ResolveConfig::default(),
1109 sealed: false,
1110 include_entry_exports: false,
1111 auto_imports: false,
1112 cache: CacheConfig::default(),
1113 }
1114 }
1115
1116 #[test]
1117 fn resolve_tracks_rule_pack_sources_in_config_order() {
1118 let dir = tempfile::tempdir().unwrap();
1119 std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1120 std::fs::write(
1121 dir.path().join("rule-packs/team-policy.jsonc"),
1122 r#"{
1123 "version": 1,
1124 "name": "team-policy",
1125 "rules": [
1126 {
1127 "id": "no-moment",
1128 "kind": "banned-import",
1129 "specifiers": ["moment"]
1130 }
1131 ]
1132}
1133"#,
1134 )
1135 .unwrap();
1136
1137 let mut config = make_config(false);
1138 config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1139
1140 let resolved = config.resolve(
1141 dir.path().to_path_buf(),
1142 OutputFormat::Human,
1143 1,
1144 true,
1145 true,
1146 None,
1147 );
1148
1149 assert_eq!(resolved.rule_packs.len(), 1);
1150 assert_eq!(resolved.rule_packs[0].name, "team-policy");
1151 assert_eq!(
1152 resolved.rule_pack_sources,
1153 vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1154 );
1155 }
1156
1157 #[test]
1158 fn resolve_production_forces_dev_deps_off() {
1159 let resolved = make_config(true).resolve(
1160 PathBuf::from("/project"),
1161 OutputFormat::Human,
1162 1,
1163 true,
1164 true,
1165 None,
1166 );
1167 assert_eq!(
1168 resolved.rules.unused_dev_dependencies,
1169 Severity::Off,
1170 "production mode should force unused_dev_dependencies to off"
1171 );
1172 }
1173
1174 #[test]
1175 fn resolve_production_forces_optional_deps_off() {
1176 let resolved = make_config(true).resolve(
1177 PathBuf::from("/project"),
1178 OutputFormat::Human,
1179 1,
1180 true,
1181 true,
1182 None,
1183 );
1184 assert_eq!(
1185 resolved.rules.unused_optional_dependencies,
1186 Severity::Off,
1187 "production mode should force unused_optional_dependencies to off"
1188 );
1189 }
1190
1191 #[test]
1192 fn resolve_production_preserves_other_rules() {
1193 let resolved = make_config(true).resolve(
1194 PathBuf::from("/project"),
1195 OutputFormat::Human,
1196 1,
1197 true,
1198 true,
1199 None,
1200 );
1201 assert_eq!(resolved.rules.unused_files, Severity::Error);
1202 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1203 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1204 }
1205
1206 #[test]
1207 fn resolve_non_production_keeps_dev_deps_default() {
1208 let resolved = make_config(false).resolve(
1209 PathBuf::from("/project"),
1210 OutputFormat::Human,
1211 1,
1212 true,
1213 true,
1214 None,
1215 );
1216 assert_eq!(
1217 resolved.rules.unused_dev_dependencies,
1218 Severity::Warn,
1219 "non-production should keep default severity"
1220 );
1221 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1222 }
1223
1224 #[test]
1225 fn resolve_production_flag_stored() {
1226 let resolved = make_config(true).resolve(
1227 PathBuf::from("/project"),
1228 OutputFormat::Human,
1229 1,
1230 true,
1231 true,
1232 None,
1233 );
1234 assert!(resolved.production);
1235
1236 let resolved2 = make_config(false).resolve(
1237 PathBuf::from("/project"),
1238 OutputFormat::Human,
1239 1,
1240 true,
1241 true,
1242 None,
1243 );
1244 assert!(!resolved2.production);
1245 }
1246
1247 #[test]
1248 fn resolve_default_ignores_node_modules() {
1249 let resolved = make_config(false).resolve(
1250 PathBuf::from("/project"),
1251 OutputFormat::Human,
1252 1,
1253 true,
1254 true,
1255 None,
1256 );
1257 assert!(
1258 resolved
1259 .ignore_patterns
1260 .is_match("node_modules/lodash/index.js")
1261 );
1262 assert!(
1263 resolved
1264 .ignore_patterns
1265 .is_match("packages/a/node_modules/react/index.js")
1266 );
1267 }
1268
1269 #[test]
1270 fn resolve_default_ignores_dist() {
1271 let resolved = make_config(false).resolve(
1272 PathBuf::from("/project"),
1273 OutputFormat::Human,
1274 1,
1275 true,
1276 true,
1277 None,
1278 );
1279 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1280 assert!(
1281 resolved
1282 .ignore_patterns
1283 .is_match("packages/ui/dist/index.js")
1284 );
1285 }
1286
1287 #[test]
1288 fn resolve_default_ignores_root_build_only() {
1289 let resolved = make_config(false).resolve(
1290 PathBuf::from("/project"),
1291 OutputFormat::Human,
1292 1,
1293 true,
1294 true,
1295 None,
1296 );
1297 assert!(
1298 resolved.ignore_patterns.is_match("build/output.js"),
1299 "root build/ should be ignored"
1300 );
1301 assert!(
1302 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1303 "nested build/ should NOT be ignored by default"
1304 );
1305 }
1306
1307 #[test]
1308 fn resolve_default_ignores_minified_files() {
1309 let resolved = make_config(false).resolve(
1310 PathBuf::from("/project"),
1311 OutputFormat::Human,
1312 1,
1313 true,
1314 true,
1315 None,
1316 );
1317 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1318 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1319 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1320 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1321 assert!(
1322 resolved
1323 .ignore_patterns
1324 .is_match("src/vendor/app.bundle.js")
1325 );
1326 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1328 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1329 }
1330
1331 #[test]
1332 fn resolve_max_file_size_bytes_default_and_unlimited() {
1333 assert_eq!(
1335 resolve_max_file_size_bytes(None),
1336 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1337 );
1338 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1340 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1342 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1343 }
1344
1345 #[test]
1346 fn resolve_sets_default_max_file_size() {
1347 let resolved = make_config(false).resolve(
1348 PathBuf::from("/project"),
1349 OutputFormat::Human,
1350 1,
1351 true,
1352 true,
1353 None,
1354 );
1355 assert_eq!(
1356 resolved.max_file_size_bytes,
1357 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1358 );
1359 }
1360
1361 #[test]
1362 fn resolve_default_ignores_git() {
1363 let resolved = make_config(false).resolve(
1364 PathBuf::from("/project"),
1365 OutputFormat::Human,
1366 1,
1367 true,
1368 true,
1369 None,
1370 );
1371 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1372 }
1373
1374 #[test]
1375 fn resolve_default_ignores_coverage() {
1376 let resolved = make_config(false).resolve(
1377 PathBuf::from("/project"),
1378 OutputFormat::Human,
1379 1,
1380 true,
1381 true,
1382 None,
1383 );
1384 assert!(
1385 resolved
1386 .ignore_patterns
1387 .is_match("coverage/lcov-report/index.js")
1388 );
1389 }
1390
1391 #[test]
1392 fn resolve_source_files_not_ignored_by_default() {
1393 let resolved = make_config(false).resolve(
1394 PathBuf::from("/project"),
1395 OutputFormat::Human,
1396 1,
1397 true,
1398 true,
1399 None,
1400 );
1401 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1402 assert!(
1403 !resolved
1404 .ignore_patterns
1405 .is_match("src/components/Button.tsx")
1406 );
1407 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1408 }
1409
1410 #[test]
1411 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1412 let mut config = make_config(false);
1413 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1414 let resolved = config.resolve(
1415 PathBuf::from("/project"),
1416 OutputFormat::Human,
1417 1,
1418 true,
1419 true,
1420 None,
1421 );
1422 assert!(
1423 resolved
1424 .ignore_patterns
1425 .is_match("src/__generated__/types.ts")
1426 );
1427 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1428 }
1429
1430 #[test]
1431 fn resolve_normalizes_leading_dot_ignore_patterns() {
1432 let mut config = make_config(false);
1433 config.ignore_patterns = vec!["./src/generated/**".to_string()];
1434 let resolved = config.resolve(
1435 PathBuf::from("/project"),
1436 OutputFormat::Human,
1437 1,
1438 true,
1439 true,
1440 None,
1441 );
1442
1443 assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1444 assert!(
1445 !resolved
1446 .ignore_patterns
1447 .is_match("./src/generated/client.ts")
1448 );
1449 }
1450
1451 #[test]
1452 fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1453 let mut config = make_config(false);
1454 config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1455 let resolved = config.resolve(
1456 PathBuf::from("/project"),
1457 OutputFormat::Human,
1458 1,
1459 true,
1460 true,
1461 None,
1462 );
1463
1464 assert!(
1465 resolved
1466 .ignore_unresolved_imports
1467 .iter()
1468 .any(|matcher| matcher.is_match("src/generated/client"))
1469 );
1470 assert!(
1471 !resolved
1472 .ignore_unresolved_imports
1473 .iter()
1474 .any(|matcher| matcher.is_match("./src/generated/client"))
1475 );
1476 }
1477
1478 #[test]
1479 fn resolve_passes_through_entry_patterns() {
1480 let mut config = make_config(false);
1481 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1482 let resolved = config.resolve(
1483 PathBuf::from("/project"),
1484 OutputFormat::Human,
1485 1,
1486 true,
1487 true,
1488 None,
1489 );
1490 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1491 }
1492
1493 #[test]
1494 fn resolve_passes_through_ignore_dependencies() {
1495 let mut config = make_config(false);
1496 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1497 let resolved = config.resolve(
1498 PathBuf::from("/project"),
1499 OutputFormat::Human,
1500 1,
1501 true,
1502 true,
1503 None,
1504 );
1505 assert_eq!(
1506 resolved.ignore_dependencies,
1507 vec!["postcss", "autoprefixer"]
1508 );
1509 }
1510
1511 #[test]
1512 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1513 let mut config = make_config(false);
1514 config.ignore_unresolved_imports = vec![
1515 "@example/icons".to_string(),
1516 "@example/icons/**".to_string(),
1517 "../generated/**".to_string(),
1518 ];
1519 let resolved = config.resolve(
1520 PathBuf::from("/project"),
1521 OutputFormat::Human,
1522 1,
1523 true,
1524 true,
1525 None,
1526 );
1527
1528 assert!(
1529 resolved
1530 .ignore_unresolved_imports
1531 .iter()
1532 .any(|matcher| matcher.is_match("@example/icons"))
1533 );
1534 assert!(
1535 resolved
1536 .ignore_unresolved_imports
1537 .iter()
1538 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1539 );
1540 assert!(
1541 resolved
1542 .ignore_unresolved_imports
1543 .iter()
1544 .any(|matcher| matcher.is_match("../generated/client"))
1545 );
1546 }
1547
1548 #[test]
1549 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1550 let mut config = make_config(false);
1551 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1552 let resolved = config.resolve(
1553 PathBuf::from("/project"),
1554 OutputFormat::Human,
1555 1,
1556 true,
1557 true,
1558 None,
1559 );
1560
1561 assert!(
1562 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1563 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1564 );
1565 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1566 }
1567
1568 #[test]
1569 fn resolve_sets_cache_dir() {
1570 let resolved = make_config(false).resolve(
1571 PathBuf::from("/my/project"),
1572 OutputFormat::Human,
1573 1,
1574 true,
1575 true,
1576 None,
1577 );
1578 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1579 }
1580
1581 #[test]
1582 fn resolve_uses_relative_configured_cache_dir_from_root() {
1583 let config = FallowConfig {
1584 cache: crate::CacheConfig {
1585 dir: Some(PathBuf::from(".cache/fallow")),
1586 ..Default::default()
1587 },
1588 ..make_config(false)
1589 };
1590 let resolved = config.resolve(
1591 PathBuf::from("/my/project"),
1592 OutputFormat::Human,
1593 1,
1594 false,
1595 true,
1596 None,
1597 );
1598 assert_eq!(
1599 resolved.cache_dir,
1600 PathBuf::from("/my/project/.cache/fallow")
1601 );
1602 }
1603
1604 #[test]
1605 fn resolve_keeps_absolute_configured_cache_dir() {
1606 let config = FallowConfig {
1607 cache: crate::CacheConfig {
1608 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1609 ..Default::default()
1610 },
1611 ..make_config(false)
1612 };
1613 let resolved = config.resolve(
1614 PathBuf::from("/my/project"),
1615 OutputFormat::Human,
1616 1,
1617 false,
1618 true,
1619 None,
1620 );
1621 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1622 }
1623
1624 #[test]
1625 fn resolve_passes_through_thread_count() {
1626 let resolved = make_config(false).resolve(
1627 PathBuf::from("/project"),
1628 OutputFormat::Human,
1629 8,
1630 true,
1631 true,
1632 None,
1633 );
1634 assert_eq!(resolved.threads, 8);
1635 }
1636
1637 #[test]
1638 fn resolve_passes_through_quiet_flag() {
1639 let resolved = make_config(false).resolve(
1640 PathBuf::from("/project"),
1641 OutputFormat::Human,
1642 1,
1643 true,
1644 false,
1645 None,
1646 );
1647 assert!(!resolved.quiet);
1648
1649 let resolved2 = make_config(false).resolve(
1650 PathBuf::from("/project"),
1651 OutputFormat::Human,
1652 1,
1653 true,
1654 true,
1655 None,
1656 );
1657 assert!(resolved2.quiet);
1658 }
1659
1660 #[test]
1661 fn resolve_passes_through_no_cache_flag() {
1662 let resolved_no_cache = make_config(false).resolve(
1663 PathBuf::from("/project"),
1664 OutputFormat::Human,
1665 1,
1666 true,
1667 true,
1668 None,
1669 );
1670 assert!(resolved_no_cache.no_cache);
1671
1672 let resolved_with_cache = make_config(false).resolve(
1673 PathBuf::from("/project"),
1674 OutputFormat::Human,
1675 1,
1676 false,
1677 true,
1678 None,
1679 );
1680 assert!(!resolved_with_cache.no_cache);
1681 }
1682
1683 #[test]
1684 #[should_panic(expected = "validated at config load time")]
1685 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1686 let mut config = make_config(false);
1687 config.overrides = vec![ConfigOverride {
1688 files: vec!["[invalid".to_string()],
1689 rules: PartialRulesConfig {
1690 unused_files: Some(Severity::Off),
1691 ..Default::default()
1692 },
1693 }];
1694 let _ = config.resolve(
1695 PathBuf::from("/project"),
1696 OutputFormat::Human,
1697 1,
1698 true,
1699 true,
1700 None,
1701 );
1702 }
1703
1704 #[test]
1705 fn resolve_override_with_empty_files_skipped() {
1706 let mut config = make_config(false);
1707 config.overrides = vec![ConfigOverride {
1708 files: vec![],
1709 rules: PartialRulesConfig {
1710 unused_files: Some(Severity::Off),
1711 ..Default::default()
1712 },
1713 }];
1714 let resolved = config.resolve(
1715 PathBuf::from("/project"),
1716 OutputFormat::Human,
1717 1,
1718 true,
1719 true,
1720 None,
1721 );
1722 assert!(
1723 resolved.overrides.is_empty(),
1724 "override with no file patterns should be skipped"
1725 );
1726 }
1727
1728 #[test]
1729 fn resolve_multiple_valid_overrides() {
1730 let mut config = make_config(false);
1731 config.overrides = vec![
1732 ConfigOverride {
1733 files: vec!["*.test.ts".to_string()],
1734 rules: PartialRulesConfig {
1735 unused_exports: Some(Severity::Off),
1736 ..Default::default()
1737 },
1738 },
1739 ConfigOverride {
1740 files: vec!["*.stories.tsx".to_string()],
1741 rules: PartialRulesConfig {
1742 unused_files: Some(Severity::Off),
1743 ..Default::default()
1744 },
1745 },
1746 ];
1747 let resolved = config.resolve(
1748 PathBuf::from("/project"),
1749 OutputFormat::Human,
1750 1,
1751 true,
1752 true,
1753 None,
1754 );
1755 assert_eq!(resolved.overrides.len(), 2);
1756 }
1757
1758 #[test]
1759 fn ignore_export_rule_deserialize() {
1760 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1761 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1762 assert_eq!(rule.file, "src/types/*.ts");
1763 assert_eq!(rule.exports, vec!["*"]);
1764 }
1765
1766 #[test]
1767 fn ignore_export_rule_specific_exports() {
1768 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1769 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1770 assert_eq!(rule.exports.len(), 3);
1771 assert!(rule.exports.contains(&"FOO".to_string()));
1772 }
1773
1774 mod proptests {
1775 use super::*;
1776 use proptest::prelude::*;
1777
1778 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1779 make_config(production).resolve(
1780 PathBuf::from("/project"),
1781 OutputFormat::Human,
1782 1,
1783 true,
1784 true,
1785 None,
1786 )
1787 }
1788
1789 proptest! {
1790 #[test]
1792 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1793 let resolved = arb_resolved_config(production);
1794 prop_assert!(
1795 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1796 "Default ignore should match node_modules"
1797 );
1798 prop_assert!(
1799 resolved.ignore_patterns.is_match("dist/bundle.js"),
1800 "Default ignore should match dist"
1801 );
1802 }
1803
1804 #[test]
1806 fn production_forces_dev_deps_off(_unused in Just(())) {
1807 let resolved = arb_resolved_config(true);
1808 prop_assert_eq!(
1809 resolved.rules.unused_dev_dependencies,
1810 Severity::Off,
1811 "Production should force unused_dev_dependencies off"
1812 );
1813 prop_assert_eq!(
1814 resolved.rules.unused_optional_dependencies,
1815 Severity::Off,
1816 "Production should force unused_optional_dependencies off"
1817 );
1818 }
1819
1820 #[test]
1822 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1823 let resolved = arb_resolved_config(false);
1824 prop_assert_eq!(
1825 resolved.rules.unused_dev_dependencies,
1826 Severity::Warn,
1827 "Non-production should keep default dev dep severity"
1828 );
1829 }
1830
1831 #[test]
1833 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1834 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1835 let expected_cache = root.join(".fallow");
1836 let resolved = make_config(false).resolve(
1837 root,
1838 OutputFormat::Human,
1839 1,
1840 true,
1841 true,
1842 None,
1843 );
1844 prop_assert_eq!(
1845 resolved.cache_dir, expected_cache,
1846 "Default cache dir should be root/.fallow"
1847 );
1848 }
1849
1850 #[test]
1852 fn threads_passed_through(threads in 1..64usize) {
1853 let resolved = make_config(false).resolve(
1854 PathBuf::from("/project"),
1855 OutputFormat::Human,
1856 threads,
1857 true,
1858 true, None,
1859 );
1860 prop_assert_eq!(
1861 resolved.threads, threads,
1862 "Thread count should be passed through"
1863 );
1864 }
1865
1866 #[test]
1870 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1871 let mut config = make_config(false);
1872 config.ignore_patterns = vec![pattern];
1873 let resolved = config.resolve(
1874 PathBuf::from("/project"),
1875 OutputFormat::Human,
1876 1,
1877 true,
1878 true, None,
1879 );
1880 prop_assert!(
1881 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1882 "Default node_modules ignore should still be active"
1883 );
1884 }
1885 }
1886 }
1887
1888 #[test]
1889 fn resolve_expands_boundary_preset() {
1890 use crate::config::boundaries::BoundaryPreset;
1891
1892 let mut config = make_config(false);
1893 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1894 let resolved = config.resolve(
1895 PathBuf::from("/project"),
1896 OutputFormat::Human,
1897 1,
1898 true,
1899 true,
1900 None,
1901 );
1902 assert_eq!(resolved.boundaries.zones.len(), 3);
1903 assert_eq!(resolved.boundaries.rules.len(), 3);
1904 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1905 assert_eq!(
1906 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1907 Some("adapters")
1908 );
1909 }
1910
1911 #[test]
1912 fn resolve_boundary_preset_with_user_override() {
1913 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1914
1915 let mut config = make_config(false);
1916 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1917 config.boundaries.zones = vec![BoundaryZone {
1918 name: "domain".to_string(),
1919 patterns: vec!["src/core/**".to_string()],
1920 auto_discover: vec![],
1921 root: None,
1922 }];
1923 let resolved = config.resolve(
1924 PathBuf::from("/project"),
1925 OutputFormat::Human,
1926 1,
1927 true,
1928 true,
1929 None,
1930 );
1931 assert_eq!(resolved.boundaries.zones.len(), 3);
1932 assert_eq!(
1933 resolved.boundaries.classify_zone("src/core/user.ts"),
1934 Some("domain")
1935 );
1936 assert_eq!(
1937 resolved.boundaries.classify_zone("src/domain/user.ts"),
1938 None
1939 );
1940 }
1941
1942 #[test]
1943 fn resolve_no_preset_unchanged() {
1944 let config = make_config(false);
1945 let resolved = config.resolve(
1946 PathBuf::from("/project"),
1947 OutputFormat::Human,
1948 1,
1949 true,
1950 true,
1951 None,
1952 );
1953 assert!(resolved.boundaries.is_empty());
1954 }
1955}