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