1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use fallow_config::{
6 DEFAULT_IGNORE_PATTERNS, IgnorePatternSet, ResolvedConfig, WorkspaceDiagnostic,
7 WorkspaceDiagnosticKind,
8};
9use fallow_types::discover::{DiscoveredFile, FileId};
10use fallow_types::path_util::display_relative;
11use fallow_types::workspace::glob_first_literal_segment;
12use ignore::WalkBuilder;
13use rustc_hash::{FxHashMap, FxHashSet};
14
15use super::{ALLOWED_HIDDEN_DIRS, SCRIPT_SCOPE_DENYLIST};
16
17fn should_emit_note_once(key: String) -> bool {
23 static EMITTED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
24 EMITTED
25 .get_or_init(|| Mutex::new(FxHashSet::default()))
26 .lock()
27 .map_or(true, |mut set| set.insert(key))
28}
29
30type SizedFile = (PathBuf, u64);
33
34type SkippedDotdirSink = Arc<Mutex<Vec<PathBuf>>>;
42
43#[derive(Default)]
60struct ExcludedByPattern {
61 file_count: u32,
63 scopes: FxHashMap<PathBuf, u32>,
65}
66
67impl ExcludedByPattern {
68 fn record(&mut self, scope: PathBuf) {
69 self.file_count = self.file_count.saturating_add(1);
70 *self.scopes.entry(scope).or_insert(0) += 1;
71 }
72
73 fn merge(&mut self, other: Self) {
74 self.file_count = self.file_count.saturating_add(other.file_count);
75 for (scope, count) in other.scopes {
76 *self.scopes.entry(scope).or_insert(0) += count;
77 }
78 }
79
80 fn directory_count(&self) -> u32 {
87 u32::try_from(self.scopes.len()).unwrap_or(u32::MAX)
88 }
89
90 fn anchor(&self) -> PathBuf {
94 self.scopes
95 .iter()
96 .max_by(|(left_path, left_count), (right_path, right_count)| {
97 left_count
98 .cmp(right_count)
99 .then_with(|| right_path.cmp(left_path))
100 })
101 .map(|(path, _)| path.clone())
102 .unwrap_or_default()
103 }
104}
105
106type ExclusionTally = FxHashMap<usize, ExcludedByPattern>;
109
110const UNREPORTED_DEFAULT_IGNORES: &[&str] = &["**/node_modules/**", "**/.git/**"];
121
122fn exclusion_scope(relative: &Path, pattern: &str) -> PathBuf {
136 let parent = relative.parent().unwrap_or_else(|| Path::new(""));
137 let Some(literal) = glob_first_literal_segment(pattern) else {
138 return parent.to_path_buf();
139 };
140 let mut prefix = PathBuf::new();
141 let mut deepest = None;
142 for component in parent.components() {
143 prefix.push(component);
144 if component.as_os_str() == OsStr::new(literal) {
145 deepest = Some(prefix.clone());
146 }
147 }
148 deepest.unwrap_or_else(|| parent.to_path_buf())
149}
150
151const NOTE_EXAMPLE_CAP: usize = 5;
155
156const DOTDIR_SCAN_MAX_DEPTH: usize = 2;
161
162const DOTDIR_SCAN_MAX_ENTRIES: usize = 256;
168
169const DOTDIR_SCAN_TOTAL_ENTRIES: usize = 1024;
180
181const DOTDIR_SCAN_MAX_CANDIDATES: usize = 64;
188
189const DOTDIR_MODULE_EXTENSIONS: &[&str] = &[
196 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
197 "mdx",
198];
199
200const LARGE_SET_THRESHOLD: usize = 20_000;
204
205const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
210
211const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
215
216const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
220
221const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
223
224const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
227
228fn is_declaration_file(path: &Path) -> bool {
233 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
234 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
235}
236
237fn is_plain_js_file(path: &Path) -> bool {
238 matches!(
239 path.extension().and_then(|ext| ext.to_str()),
240 Some("js" | "mjs" | "cjs")
241 )
242}
243
244fn has_minified_line_shape(path: &Path) -> bool {
245 use std::io::Read;
246
247 let Ok(mut file) = std::fs::File::open(path) else {
248 return false;
249 };
250 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
251 let Ok(len) = file.read(&mut sample) else {
252 return false;
253 };
254 sample.truncate(len);
255 if sample.is_empty() {
256 return false;
257 }
258
259 let mut current_line = 0usize;
260 for byte in sample {
261 if byte == b'\n' || byte == b'\r' {
262 current_line = 0;
263 continue;
264 }
265 current_line += 1;
266 if current_line >= MINIFIED_LONG_LINE_BYTES {
267 return true;
268 }
269 }
270 false
271}
272
273fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
274 size_bytes >= MINIFIED_FILE_SKIP_BYTES
275 && is_plain_js_file(path)
276 && !is_declaration_file(path)
277 && has_minified_line_shape(path)
278}
279
280fn format_size_mb(bytes: u64) -> String {
282 #[expect(
283 clippy::cast_precision_loss,
284 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
285 )]
286 let mb = bytes as f64 / (1024.0 * 1024.0);
287 format!("{mb:.1} MB")
288}
289
290fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
293 let shown: Vec<String> = examples
294 .iter()
295 .take(NOTE_EXAMPLE_CAP)
296 .map(|(path, size)| {
297 let display = display_relative(root, path);
298 format!("{display} ({})", format_size_mb(*size))
299 })
300 .collect();
301 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
302 if remaining > 0 {
303 format!("{}, and {remaining} more", shown.join(", "))
304 } else {
305 shown.join(", ")
306 }
307}
308
309fn partition_by_size(
312 raw: Vec<SizedFile>,
313 max_file_size_bytes: Option<u64>,
314) -> (Vec<SizedFile>, Vec<SizedFile>) {
315 let Some(limit) = max_file_size_bytes else {
316 return (raw, Vec::new());
317 };
318 raw.into_iter()
319 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
320}
321
322fn partition_minified_generated_js(
325 raw: Vec<SizedFile>,
326 max_file_size_bytes: Option<u64>,
327) -> (Vec<SizedFile>, Vec<SizedFile>) {
328 if max_file_size_bytes.is_none() {
329 return (raw, Vec::new());
330 }
331 raw.into_iter()
332 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
333}
334
335fn report_missing_node_modules(config: &ResolvedConfig) -> Vec<WorkspaceDiagnostic> {
346 let Some(diagnostic) = fallow_config::missing_node_modules_diagnostic(&config.root) else {
347 return Vec::new();
348 };
349 if !config.quiet
350 && should_emit_note_once(format!("node-modules-missing::{}", config.root.display()))
351 {
352 tracing::warn!("fallow: {}", diagnostic.message);
353 }
354 vec![diagnostic]
355}
356
357fn report_skipped_large_files(
358 config: &ResolvedConfig,
359 skipped: &[SizedFile],
360) -> Vec<WorkspaceDiagnostic> {
361 if skipped.is_empty() {
362 return Vec::new();
363 }
364 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
365 .iter()
366 .map(|(path, size_bytes)| {
367 WorkspaceDiagnostic::new(
368 &config.root,
369 path.clone(),
370 WorkspaceDiagnosticKind::SkippedLargeFile {
371 size_bytes: *size_bytes,
372 },
373 )
374 })
375 .collect();
376
377 let mut sorted: Vec<SizedFile> = skipped.to_vec();
378 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
379 let count = skipped.len();
380 if !config.quiet
381 && should_emit_note_once(format!(
382 "skip::{}::{count}::{}",
383 config.root.display(),
384 sorted.first().map_or(0, |f| f.1)
385 ))
386 {
387 let examples = summarize_examples(&config.root, &sorted);
388 let noun = if count == 1 { "file" } else { "files" };
389 tracing::warn!(
390 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
391 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
392 );
393 }
394 diagnostics
395}
396
397fn report_skipped_minified_files(
400 config: &ResolvedConfig,
401 skipped: &[SizedFile],
402) -> Vec<WorkspaceDiagnostic> {
403 if skipped.is_empty() {
404 return Vec::new();
405 }
406 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
407 .iter()
408 .map(|(path, size_bytes)| {
409 WorkspaceDiagnostic::new(
410 &config.root,
411 path.clone(),
412 WorkspaceDiagnosticKind::SkippedMinifiedFile {
413 size_bytes: *size_bytes,
414 },
415 )
416 })
417 .collect();
418
419 let mut sorted: Vec<SizedFile> = skipped.to_vec();
420 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
421 let count = skipped.len();
422 if !config.quiet
423 && should_emit_note_once(format!(
424 "minified::{}::{count}::{}",
425 config.root.display(),
426 sorted.first().map_or(0, |f| f.1)
427 ))
428 {
429 let examples = summarize_examples(&config.root, &sorted);
430 let noun = if count == 1 { "file" } else { "files" };
431 let pronoun = if count == 1 { "it" } else { "them" };
432 tracing::warn!(
433 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
434 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
435 );
436 }
437 diagnostics
438}
439
440fn summarize_paths(root: &Path, examples: &[&PathBuf]) -> String {
444 let shown: Vec<String> = examples
445 .iter()
446 .take(NOTE_EXAMPLE_CAP)
447 .map(|path| display_relative(root, path))
448 .collect();
449 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
450 if remaining > 0 {
451 format!("{}, and {remaining} more", shown.join(", "))
452 } else {
453 shown.join(", ")
454 }
455}
456
457fn summarize_paths_open_ended(root: &Path, examples: &[&PathBuf]) -> String {
460 let shown: Vec<String> = examples
461 .iter()
462 .take(NOTE_EXAMPLE_CAP)
463 .map(|path| display_relative(root, path))
464 .collect();
465 if examples.len() > NOTE_EXAMPLE_CAP {
466 format!("{}, and more", shown.join(", "))
467 } else {
468 shown.join(", ")
469 }
470}
471
472fn is_excluded_from_analysis(
490 config: &ResolvedConfig,
491 production_excludes: Option<&globset::GlobSet>,
492 path: &Path,
493) -> bool {
494 let relative = path.strip_prefix(&config.root).unwrap_or(path);
495 config.ignore_patterns.is_match(relative)
496 || production_excludes.is_some_and(|excludes| excludes.is_match(relative))
497}
498
499fn has_module_extension(path: &Path) -> bool {
503 path.extension()
504 .and_then(OsStr::to_str)
505 .is_some_and(|ext| DOTDIR_MODULE_EXTENSIONS.contains(&ext))
506}
507
508fn scan_for_reportable_source(
531 config: &ResolvedConfig,
532 production_excludes: Option<&globset::GlobSet>,
533 dir: &Path,
534 budget: &mut usize,
535) -> bool {
536 let mut builder = fallow_config::source_walk_builder(dir);
537 builder
538 .follow_links(false)
539 .max_depth(Some(DOTDIR_SCAN_MAX_DEPTH + 1))
540 .threads(1);
541 builder.filter_entry(|entry| {
542 if entry.depth() == 0 || !entry.file_type().is_some_and(|ft| ft.is_dir()) {
543 return true;
544 }
545 entry
546 .file_name()
547 .to_str()
548 .is_none_or(|name| !SCRIPT_SCOPE_DENYLIST.contains(&name) && name != "node_modules")
549 });
550
551 let mut per_dotdir = DOTDIR_SCAN_MAX_ENTRIES;
552 for entry in builder.build() {
553 if per_dotdir == 0 || *budget == 0 {
554 return false;
555 }
556 per_dotdir -= 1;
557 *budget -= 1;
558 let Ok(entry) = entry else {
559 continue;
560 };
561 #[expect(
564 clippy::filetype_is_file,
565 reason = "regular files only is the point: !is_dir() would readmit fifos and sockets"
566 )]
567 let is_regular_file = entry
568 .file_type()
569 .is_some_and(|file_type| file_type.is_file());
570 if !is_regular_file {
571 continue;
572 }
573 if has_module_extension(entry.path())
574 && !is_excluded_from_analysis(config, production_excludes, entry.path())
575 {
576 return true;
577 }
578 }
579 false
580}
581
582const DOTDIR_NOISE_PATH_COMPONENTS: &[&str] = &[
586 "__fixtures__",
587 "__mocks__",
588 "__tests__",
589 "e2e",
590 "fixture",
591 "fixtures",
592 "playground",
593 "playgrounds",
594 "spec",
595 "test",
596 "tests",
597];
598
599fn dotdir_is_scan_candidate(config: &ResolvedConfig, dir: &Path) -> bool {
606 let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
607 return false;
608 };
609 if SCRIPT_SCOPE_DENYLIST.contains(&name) {
610 return false;
611 }
612 let relative = dir.strip_prefix(&config.root).unwrap_or(dir);
613 if relative
617 .components()
618 .any(|component| component.as_os_str() == OsStr::new("node_modules"))
619 {
620 return false;
621 }
622 !relative.components().any(|component| {
631 DOTDIR_NOISE_PATH_COMPONENTS.contains(&component.as_os_str().to_string_lossy().as_ref())
632 })
633}
634
635fn report_default_ignore_exclusions(
644 config: &ResolvedConfig,
645 tally: &ExclusionTally,
646) -> Vec<WorkspaceDiagnostic> {
647 let mut indices: Vec<usize> = tally.keys().copied().collect();
648 indices.sort_unstable();
649 indices
650 .into_iter()
651 .filter_map(|index| {
652 let excluded = tally.get(&index)?;
653 let pattern = DEFAULT_IGNORE_PATTERNS.get(index)?;
654 Some(
655 WorkspaceDiagnostic::new(
656 &config.root,
657 config.root.join(excluded.anchor()),
658 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
659 pattern: (*pattern).to_owned(),
660 file_count: excluded.file_count,
661 directory_count: excluded.directory_count(),
662 },
663 )
664 .into_root_relative(&config.root),
671 )
672 })
673 .collect()
674}
675
676fn report_no_source_files_analyzed(
689 config: &ResolvedConfig,
690 analyzed_file_count: usize,
691 tally: &ExclusionTally,
692) -> Vec<WorkspaceDiagnostic> {
693 if analyzed_file_count > 0 {
694 return Vec::new();
695 }
696 let excluded_file_count = tally.values().map(|excluded| excluded.file_count).sum();
697 vec![
698 WorkspaceDiagnostic::new(
699 &config.root,
700 config.root.clone(),
701 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
702 excluded_file_count,
703 },
704 )
705 .into_root_relative(&config.root),
706 ]
707}
708
709fn report_skipped_source_dotdirs(
715 config: &ResolvedConfig,
716 production_excludes: Option<&globset::GlobSet>,
717 candidates: &[PathBuf],
718) -> Vec<WorkspaceDiagnostic> {
719 if candidates.is_empty() {
720 return Vec::new();
721 }
722 let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
725 let scannable: Vec<&PathBuf> = candidates
726 .iter()
727 .filter(|dir| dotdir_is_scan_candidate(config, dir))
728 .collect();
729 let reportable: Vec<&PathBuf> = scannable
730 .iter()
731 .copied()
732 .take(DOTDIR_SCAN_MAX_CANDIDATES)
733 .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
734 .collect();
735 let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
738 if reportable.is_empty() {
739 return Vec::new();
740 }
741
742 let diagnostics: Vec<WorkspaceDiagnostic> = reportable
743 .iter()
744 .map(|dir| {
745 WorkspaceDiagnostic::new(
746 &config.root,
747 (*dir).clone(),
748 WorkspaceDiagnosticKind::SkippedSourceDotdir,
749 )
750 })
751 .collect();
752
753 let count = reportable.len();
754 if !config.quiet
755 && should_emit_note_once(format!(
756 "dotdir::{}::{count}::{}",
757 config.root.display(),
758 reportable
759 .first()
760 .map_or_else(String::new, |dir| display_relative(&config.root, dir))
761 ))
762 {
763 tracing::warn!(
764 "{}",
765 build_skipped_dotdirs_note(&config.root, &reportable, truncated)
766 );
767 }
768 diagnostics
769}
770
771fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
781 let count = reportable.len();
782 let examples = if truncated {
786 summarize_paths_open_ended(root, reportable)
787 } else {
788 summarize_paths(root, reportable)
789 };
790 let noun = if count == 1 {
791 "directory"
792 } else {
793 "directories"
794 };
795 let verb = if count == 1 { "contains" } else { "contain" };
796 let at_least = if truncated { "at least " } else { "" };
797 let (target, pronoun) = match reportable {
798 [only] => (display_relative(root, only), "it"),
799 _ => ("<dir>".to_owned(), "one"),
800 };
801 format!(
802 "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
803 Hidden directories are not traversed, so a file, export or dependency used only \
804 there can be reported as unused: add '!{target}/**' to ignorePatterns to analyze \
805 {pronoun}, or add it to entry, ignoreExports or ignoreDependencies, or add \
806 '{target}/**' to ignorePatterns to silence this. fallow --root {target} analyzes \
807 {pronoun} on its own and does not fix this run."
808 )
809}
810
811fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
816 if files.is_empty() {
817 return None;
818 }
819 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
820 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
821 return None;
822 }
823 let count = files.len();
824 let noun = if count == 1 { "file" } else { "files" };
825 let mut by_size: Vec<SizedFile> = files
826 .iter()
827 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
828 .map(|f| (f.path.clone(), f.size_bytes))
829 .collect();
830 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
831 if by_size.is_empty() {
832 return Some(format!(
835 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
836 exclude large generated files via ignorePatterns or --max-file-size."
837 ));
838 }
839 let examples = summarize_examples(root, &by_size);
840 Some(format!(
841 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
842 exclude large generated files via ignorePatterns or --max-file-size."
843 ))
844}
845
846fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
851 if config.quiet {
852 return;
853 }
854 if let Some(message) = build_largest_files_note(&config.root, files)
855 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
856 {
857 tracing::warn!("{message}");
858 }
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub enum HiddenDirMatch {
864 AnyDepth,
870 ExactPath,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq)]
881pub struct HiddenDirScope {
882 root: PathBuf,
883 dirs: Vec<String>,
884 match_mode: HiddenDirMatch,
885}
886
887impl HiddenDirScope {
888 #[must_use]
895 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
896 Self {
897 root,
898 dirs,
899 match_mode: HiddenDirMatch::AnyDepth,
900 }
901 }
902
903 #[must_use]
906 pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
907 Self {
908 root,
909 dirs,
910 match_mode: HiddenDirMatch::ExactPath,
911 }
912 }
913
914 #[must_use]
919 pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
920 Self {
921 root,
922 dirs,
923 match_mode,
924 }
925 }
926
927 #[must_use]
928 pub fn root(&self) -> &Path {
929 &self.root
930 }
931
932 #[must_use]
933 pub fn dirs(&self) -> &[String] {
934 &self.dirs
935 }
936
937 #[must_use]
938 pub fn match_mode(&self) -> HiddenDirMatch {
939 self.match_mode
940 }
941
942 fn allows(&self, path: &Path, name: &OsStr) -> bool {
943 match self.match_mode {
944 HiddenDirMatch::AnyDepth => {
945 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
946 }
947 HiddenDirMatch::ExactPath => {
948 let Ok(relative) = path.strip_prefix(&self.root) else {
951 return false;
952 };
953 self.dirs.iter().any(|dir| Path::new(dir) == relative)
954 }
955 }
956 }
957}
958
959struct FileVisitor<'a> {
966 root: &'a Path,
967 canonical_root: Option<&'a Path>,
968 ignore_patterns: &'a IgnorePatternSet,
969 user_ignore_pattern_count: usize,
972 hidden_dir_scopes: &'a [HiddenDirScope],
976 production_excludes: &'a Option<globset::GlobSet>,
977 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
978 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
979 excluded_shared: &'a Mutex<ExclusionTally>,
980 local: Vec<(std::path::PathBuf, u64)>,
981 config_local: Vec<std::path::PathBuf>,
982 excluded_local: ExclusionTally,
983 match_buf: Vec<usize>,
986}
987
988impl FileVisitor<'_> {
989 fn record_default_ignore_exclusion(&mut self, relative: &Path) {
996 if relative
1002 .components()
1003 .any(|component| component.as_os_str() == OsStr::new("node_modules"))
1004 {
1005 return;
1006 }
1007 self.match_buf.clear();
1008 self.ignore_patterns
1009 .matches_into(relative, &mut self.match_buf);
1010 let Some(&first) = self.match_buf.first() else {
1013 return;
1014 };
1015 if first < self.user_ignore_pattern_count {
1016 return;
1019 }
1020 let Some(pattern) = DEFAULT_IGNORE_PATTERNS.get(first - self.user_ignore_pattern_count)
1021 else {
1022 return;
1023 };
1024 if UNREPORTED_DEFAULT_IGNORES.contains(pattern) {
1025 return;
1026 }
1027 self.excluded_local
1028 .entry(first - self.user_ignore_pattern_count)
1029 .or_default()
1030 .record(exclusion_scope(relative, pattern));
1031 }
1032}
1033
1034impl ignore::ParallelVisitor for FileVisitor<'_> {
1035 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
1036 let Ok(entry) = result else {
1037 return ignore::WalkState::Continue;
1038 };
1039 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
1040 return ignore::WalkState::Continue;
1041 }
1042 let relative = entry
1043 .path()
1044 .strip_prefix(self.root)
1045 .unwrap_or_else(|_| entry.path());
1046 if self.ignore_patterns.is_match(relative) {
1047 if has_source_extension(entry.path()) {
1048 self.record_default_ignore_exclusion(relative);
1049 }
1050 return ignore::WalkState::Continue;
1051 }
1052 if self
1053 .production_excludes
1054 .as_ref()
1055 .is_some_and(|excludes| excludes.is_match(relative))
1056 {
1057 return ignore::WalkState::Continue;
1058 }
1059 if self.ignore_patterns.has_exceptions()
1060 && is_under_exception_only_hidden_dir(self.root, entry.path(), self.hidden_dir_scopes)
1061 && !self.ignore_patterns.is_lifted(relative)
1062 {
1063 return ignore::WalkState::Continue;
1064 }
1065 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
1066 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
1067 tracing::debug!(
1068 path = %entry.path().display(),
1069 "skipping source symlink with a broken, non-file, or outside-root target"
1070 );
1071 return ignore::WalkState::Continue;
1072 };
1073 Some(size)
1074 } else {
1075 None
1076 };
1077 if has_source_extension(entry.path()) {
1078 let size_bytes =
1079 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
1080 self.local.push((entry.into_path(), size_bytes));
1081 } else if self.config_shared.is_some() {
1082 self.config_local.push(entry.into_path());
1085 }
1086 ignore::WalkState::Continue
1087 }
1088}
1089
1090fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
1091 let root = canonical_root?;
1092 let target = path.canonicalize().ok()?;
1093 if !target.starts_with(root) {
1094 return None;
1095 }
1096 let metadata = target.metadata().ok()?;
1097 metadata.is_file().then_some(metadata.len())
1098}
1099
1100impl Drop for FileVisitor<'_> {
1101 #[expect(
1102 clippy::expect_used,
1103 reason = "poisoned walk collector lock means worker state is unrecoverable"
1104 )]
1105 fn drop(&mut self) {
1106 if !self.local.is_empty() {
1107 self.shared
1108 .lock()
1109 .expect("walk collector lock poisoned")
1110 .append(&mut self.local);
1111 }
1112 if let Some(config_shared) = self.config_shared
1113 && !self.config_local.is_empty()
1114 {
1115 config_shared
1116 .lock()
1117 .expect("walk config collector lock poisoned")
1118 .append(&mut self.config_local);
1119 }
1120 if !self.excluded_local.is_empty() {
1121 let mut shared = self
1122 .excluded_shared
1123 .lock()
1124 .expect("walk exclusion collector lock poisoned");
1125 for (index, tally) in std::mem::take(&mut self.excluded_local) {
1126 shared.entry(index).or_default().merge(tally);
1127 }
1128 }
1129 }
1130}
1131
1132struct FileVisitorBuilder<'a> {
1134 root: &'a Path,
1135 canonical_root: Option<&'a Path>,
1136 ignore_patterns: &'a IgnorePatternSet,
1137 user_ignore_pattern_count: usize,
1138 hidden_dir_scopes: &'a [HiddenDirScope],
1139 production_excludes: &'a Option<globset::GlobSet>,
1140 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
1141 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
1142 excluded_shared: &'a Mutex<ExclusionTally>,
1143}
1144
1145impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
1146 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
1147 Box::new(FileVisitor {
1148 root: self.root,
1149 canonical_root: self.canonical_root,
1150 ignore_patterns: self.ignore_patterns,
1151 user_ignore_pattern_count: self.user_ignore_pattern_count,
1152 hidden_dir_scopes: self.hidden_dir_scopes,
1153 production_excludes: self.production_excludes,
1154 shared: self.shared,
1155 config_shared: self.config_shared,
1156 excluded_shared: self.excluded_shared,
1157 local: Vec::new(),
1158 config_local: Vec::new(),
1159 excluded_local: ExclusionTally::default(),
1160 match_buf: Vec::new(),
1161 })
1162 }
1163}
1164
1165pub const SOURCE_EXTENSIONS: &[&str] = &[
1167 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
1168 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
1169];
1170
1171pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
1173 "**/*.test.*",
1174 "**/*.spec.*",
1175 "**/*.e2e.*",
1176 "**/*.e2e-spec.*",
1177 "**/*.bench.*",
1178 "**/*.fixture.*",
1179 "**/*.stories.*",
1180 "**/*.story.*",
1181 "**/__tests__/**",
1182 "**/__mocks__/**",
1183 "**/__snapshots__/**",
1184 "**/__fixtures__/**",
1185 "**/test-d/**",
1186 "**/test/**",
1187 "**/tests/**",
1188 "*.config.*",
1189 "**/.*.js",
1190 "**/.*.ts",
1191 "**/.*.mjs",
1192 "**/.*.cjs",
1193];
1194
1195pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
1197 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
1198}
1199
1200fn is_allowed_scoped_hidden_dir(
1201 name: &OsStr,
1202 path: &Path,
1203 additional_hidden_dir_scopes: &[HiddenDirScope],
1204) -> bool {
1205 additional_hidden_dir_scopes
1206 .iter()
1207 .any(|scope| scope.allows(path, name))
1208}
1209
1210const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
1215
1216fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
1217 YARN_PNP_GENERATED_FILES
1218 .iter()
1219 .any(|&f| OsStr::new(f) == name)
1220}
1221
1222fn is_allowed_hidden_with_scopes(
1228 entry: &ignore::DirEntry,
1229 additional_hidden_dir_scopes: &[HiddenDirScope],
1230) -> bool {
1231 let name = entry.file_name();
1232 let name_str = name.to_string_lossy();
1233
1234 if !name_str.starts_with('.') {
1235 return true;
1236 }
1237
1238 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
1239 return !is_yarn_pnp_generated_file(name);
1240 }
1241
1242 is_allowed_hidden_dir(name)
1243 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
1244}
1245
1246fn is_under_exception_only_hidden_dir(
1251 root: &Path,
1252 path: &Path,
1253 hidden_dir_scopes: &[HiddenDirScope],
1254) -> bool {
1255 path.ancestors()
1256 .skip(1)
1257 .take_while(|dir| *dir != root && dir.starts_with(root))
1258 .any(|dir| {
1259 dir.file_name().is_some_and(|name| {
1260 name.to_string_lossy().starts_with('.')
1261 && !is_allowed_hidden_dir(name)
1262 && !is_allowed_scoped_hidden_dir(name, dir, hidden_dir_scopes)
1263 })
1264 })
1265}
1266
1267pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
1273 discover_files_with_additional_hidden_dirs(config, &[])
1274}
1275
1276fn config_candidate_basename_globs() -> &'static [String] {
1288 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
1289 GLOBS.get_or_init(|| {
1290 let mut set: FxHashSet<String> = FxHashSet::default();
1291 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1292 for pattern in plugin.config_patterns() {
1293 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1294 set.insert(basename.to_string());
1295 }
1296 if plugin.tooling_dependencies().is_empty() {
1300 continue;
1301 }
1302 for pattern in plugin.always_used() {
1303 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1304 if !basename.starts_with('!') && !basename.contains(['*', '?', '[']) {
1305 set.insert(basename.to_string());
1306 }
1307 }
1308 }
1309 let mut globs: Vec<String> = set.into_iter().collect();
1310 globs.sort_unstable();
1311 globs
1312 })
1313}
1314
1315fn has_source_extension(path: &Path) -> bool {
1318 path.extension()
1319 .and_then(OsStr::to_str)
1320 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1321}
1322
1323#[expect(
1327 clippy::expect_used,
1328 reason = "source file globs are hard-coded compile-time constants"
1329)]
1330fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1331 static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1332 static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1333
1334 let cache = if capture_config {
1335 &SOURCE_AND_CONFIG_TYPES
1336 } else {
1337 &SOURCE_TYPES
1338 };
1339 cache
1340 .get_or_init(|| {
1341 let mut types_builder = ignore::types::TypesBuilder::new();
1342 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1343 types_builder
1344 .add("source", &source_glob)
1345 .expect("valid glob");
1346 types_builder.select("source");
1347 if capture_config {
1348 for glob in config_candidate_basename_globs() {
1349 let _ = types_builder.add("config", glob);
1353 }
1354 types_builder.select("config");
1355 }
1356 types_builder.build().expect("valid types")
1357 })
1358 .clone()
1359}
1360
1361fn build_source_walk_builder(
1365 config: &ResolvedConfig,
1366 additional_hidden_dir_scopes: &[HiddenDirScope],
1367 capture_config: bool,
1368 skipped_dotdirs: &SkippedDotdirSink,
1369) -> WalkBuilder {
1370 let mut walk_builder = fallow_config::source_walk_builder(&config.root);
1371 walk_builder
1372 .types(build_walk_types(capture_config))
1373 .threads(config.threads);
1374 let scopes = additional_hidden_dir_scopes.to_vec();
1379 let sink = Arc::clone(skipped_dotdirs);
1380 let root = config.root.clone();
1381 let ignore_patterns = config.ignore_patterns.clone();
1382 walk_builder.filter_entry(move |entry| {
1383 if is_allowed_hidden_with_scopes(entry, &scopes) {
1384 return true;
1385 }
1386 if ignore_patterns.has_exceptions()
1390 && entry.file_type().is_some_and(|ft| ft.is_dir())
1391 && ignore_patterns.admits_hidden_dir(
1392 entry
1393 .path()
1394 .strip_prefix(&root)
1395 .unwrap_or_else(|_| entry.path()),
1396 )
1397 {
1398 return true;
1399 }
1400 if entry.file_type().is_some_and(|ft| ft.is_dir())
1401 && let Ok(mut collected) = sink.lock()
1402 {
1403 collected.push(entry.path().to_path_buf());
1404 }
1405 false
1406 });
1407 walk_builder
1408}
1409
1410fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1412 if !config.production {
1413 return None;
1414 }
1415 let mut builder = globset::GlobSetBuilder::new();
1416 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1417 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1418 .literal_separator(true)
1419 .build()
1420 {
1421 builder.add(glob);
1422 }
1423 }
1424 builder.build().ok()
1425}
1426
1427pub fn discover_files_with_additional_hidden_dirs(
1433 config: &ResolvedConfig,
1434 additional_hidden_dir_scopes: &[HiddenDirScope],
1435) -> Vec<DiscoveredFile> {
1436 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1437}
1438
1439pub fn discover_files_and_config_candidates(
1455 config: &ResolvedConfig,
1456 additional_hidden_dir_scopes: &[HiddenDirScope],
1457) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1458 let discovered =
1459 discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1460 (discovered.files, discovered.config_candidates)
1461}
1462
1463pub struct DiscoveredSources {
1472 pub files: Vec<DiscoveredFile>,
1474 pub config_candidates: Vec<PathBuf>,
1476 pub diagnostics: Vec<WorkspaceDiagnostic>,
1479}
1480
1481#[expect(
1489 clippy::cast_possible_truncation,
1490 reason = "file count is bounded by project size, well under u32::MAX"
1491)]
1492#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1493pub fn discover_files_config_candidates_and_diagnostics(
1494 config: &ResolvedConfig,
1495 additional_hidden_dir_scopes: &[HiddenDirScope],
1496) -> DiscoveredSources {
1497 let _span = tracing::info_span!("discover_files").entered();
1498
1499 let capture_config = !config.production;
1500 let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1501 let walk_builder = build_source_walk_builder(
1502 config,
1503 additional_hidden_dir_scopes,
1504 capture_config,
1505 &skipped_dotdirs,
1506 );
1507 let production_excludes = build_production_excludes(config);
1508 let canonical_root = config.root.canonicalize().ok();
1509
1510 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1511 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1512 let excluded_collected: Mutex<ExclusionTally> = Mutex::new(ExclusionTally::default());
1513 let mut visitor_builder = FileVisitorBuilder {
1514 root: &config.root,
1515 canonical_root: canonical_root.as_deref(),
1516 ignore_patterns: &config.ignore_patterns,
1517 user_ignore_pattern_count: config.user_ignore_pattern_count,
1518 hidden_dir_scopes: additional_hidden_dir_scopes,
1519 production_excludes: &production_excludes,
1520 shared: &collected,
1521 config_shared: capture_config.then_some(&config_collected),
1522 excluded_shared: &excluded_collected,
1523 };
1524 walk_builder.build_parallel().visit(&mut visitor_builder);
1525
1526 let mut raw = collected
1527 .into_inner()
1528 .expect("walk collector lock poisoned");
1529 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1537
1538 let mut config_candidates = config_collected
1539 .into_inner()
1540 .expect("walk config collector lock poisoned");
1541 config_candidates.sort_unstable();
1542
1543 let excluded_by_default_ignore = excluded_collected
1544 .into_inner()
1545 .expect("walk exclusion collector lock poisoned");
1546
1547 let mut dotdir_candidates = skipped_dotdirs
1551 .lock()
1552 .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1553 dotdir_candidates.sort_unstable();
1554 dotdir_candidates.dedup();
1555
1556 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1557 let (kept, skipped_minified) =
1558 partition_minified_generated_js(kept, config.max_file_size_bytes);
1559 let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1564 &config.root,
1565 report_skipped_large_files(config, &skipped)
1566 .into_iter()
1567 .chain(report_skipped_minified_files(config, &skipped_minified))
1568 .chain(report_skipped_source_dotdirs(
1569 config,
1570 production_excludes.as_ref(),
1571 &dotdir_candidates,
1572 ))
1573 .chain(report_default_ignore_exclusions(
1574 config,
1575 &excluded_by_default_ignore,
1576 ))
1577 .chain(report_missing_node_modules(config))
1578 .chain(report_no_source_files_analyzed(
1579 config,
1580 kept.len(),
1581 &excluded_by_default_ignore,
1582 ))
1583 .collect(),
1584 );
1585
1586 let files: Vec<DiscoveredFile> = kept
1587 .into_iter()
1588 .enumerate()
1589 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1590 id: FileId(idx as u32),
1591 path,
1592 size_bytes,
1593 })
1594 .collect();
1595
1596 note_largest_files(config, &files);
1597
1598 DiscoveredSources {
1599 files,
1600 config_candidates,
1601 diagnostics,
1602 }
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607 use std::ffi::OsStr;
1608 use std::path::MAIN_SEPARATOR;
1609
1610 use super::*;
1611
1612 #[test]
1616 fn exclusion_scope_stops_at_the_patterns_literal_directory_segment() {
1617 assert_eq!(
1618 exclusion_scope(
1619 Path::new("projects/app/build/static/js/main.js"),
1620 "**/build/**"
1621 ),
1622 PathBuf::from("projects/app/build")
1623 );
1624 assert_eq!(
1625 exclusion_scope(Path::new("dist/a.ts"), "**/dist/**"),
1626 PathBuf::from("dist")
1627 );
1628 assert_eq!(
1629 exclusion_scope(
1630 Path::new("node_modules/react/index.js"),
1631 "**/node_modules/**"
1632 ),
1633 PathBuf::from("node_modules"),
1634 "the helper is shape-only: `**/node_modules/**` is never tallied, \
1635 but a directory-shaped pattern still collapses to its literal segment"
1636 );
1637 }
1638
1639 #[test]
1645 fn exclusion_scope_takes_the_deepest_matching_segment() {
1646 assert_eq!(
1647 exclusion_scope(Path::new("build/tools/build/a.ts"), "**/build/**"),
1648 PathBuf::from("build/tools/build")
1649 );
1650 assert_eq!(
1651 exclusion_scope(Path::new("dist/pkg/dist/inner/a.ts"), "**/dist/**"),
1652 PathBuf::from("dist/pkg/dist")
1653 );
1654 }
1655
1656 #[test]
1659 fn exclusion_scope_falls_back_to_the_parent_for_a_file_shaped_pattern() {
1660 assert_eq!(
1661 exclusion_scope(Path::new("vendor/a.min.js"), "**/*.min.js"),
1662 PathBuf::from("vendor")
1663 );
1664 assert_eq!(
1665 exclusion_scope(Path::new("a.min.js"), "**/*.min.js"),
1666 PathBuf::new(),
1667 "a root-level match anchors at the root itself"
1668 );
1669 }
1670
1671 #[test]
1675 fn exclusion_scope_falls_back_when_the_literal_segment_is_absent() {
1676 assert_eq!(
1677 exclusion_scope(Path::new("src/nested/a.ts"), "**/build/**"),
1678 PathBuf::from("src/nested")
1679 );
1680 }
1681
1682 #[test]
1686 fn the_directory_count_is_the_number_of_distinct_scopes() {
1687 let mut one_tree = ExcludedByPattern::default();
1688 one_tree.record(PathBuf::from("packages/web/build"));
1689 one_tree.record(PathBuf::from("packages/web/build"));
1690 assert_eq!(one_tree.file_count, 2);
1691 assert_eq!(one_tree.directory_count(), 1);
1692
1693 let mut scattered = ExcludedByPattern::default();
1694 scattered.record(PathBuf::from("packages/a/dist"));
1695 scattered.record(PathBuf::from("packages/b/dist"));
1696 assert_eq!(scattered.directory_count(), 2);
1697 }
1698
1699 #[test]
1703 fn the_anchor_is_the_largest_group_with_ties_broken_by_path() {
1704 let mut tally = ExcludedByPattern::default();
1705 for _ in 0..3 {
1706 tally.record(PathBuf::from("packages/web/build"));
1707 }
1708 tally.record(PathBuf::from("packages/api/build"));
1709 assert_eq!(tally.file_count, 4);
1710 assert_eq!(tally.anchor(), PathBuf::from("packages/web/build"));
1711
1712 let mut tied = ExcludedByPattern::default();
1713 tied.record(PathBuf::from("z/build"));
1714 tied.record(PathBuf::from("a/build"));
1715 assert_eq!(tied.anchor(), PathBuf::from("a/build"));
1716 }
1717
1718 #[test]
1721 fn merging_two_thread_tallies_keeps_the_count_exact() {
1722 let mut left = ExcludedByPattern::default();
1723 left.record(PathBuf::from("dist"));
1724 left.record(PathBuf::from("dist"));
1725 let mut right = ExcludedByPattern::default();
1726 right.record(PathBuf::from("dist"));
1727 right.record(PathBuf::from("packages/ui/dist"));
1728
1729 left.merge(right);
1730 assert_eq!(left.file_count, 4);
1731 assert_eq!(left.scopes.get(Path::new("dist")), Some(&3));
1732 assert_eq!(left.anchor(), PathBuf::from("dist"));
1733 }
1734
1735 #[test]
1736 fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1737 let root = Path::new("/repo");
1738 let only = PathBuf::from("/repo/.tooling");
1739 let note = build_skipped_dotdirs_note(root, &[&only], false);
1740 assert!(note.contains("skipped 1 hidden directory that contains source files"));
1741 assert!(note.contains("add it to entry, ignoreExports or ignoreDependencies"));
1742 assert!(note.contains("fallow --root .tooling analyzes it on its own"));
1743 assert!(note.contains("does not fix this run"));
1744 assert!(note.contains("add '.tooling/**' to"));
1745 assert!(note.contains("add '!.tooling/**' to ignorePatterns to analyze it"));
1746 assert!(
1747 !note.contains("<dir>"),
1748 "the single-directory remedy must be copy-pasteable: {note}"
1749 );
1750 }
1751
1752 #[test]
1753 fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1754 let root = Path::new("/repo");
1755 let a = PathBuf::from("/repo/.a");
1756 let b = PathBuf::from("/repo/.b");
1757 let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1758 assert!(note.contains("skipped 2 hidden directories that contain source files"));
1759 assert!(note.contains("fallow --root <dir> analyzes one on its own"));
1760 }
1761
1762 #[test]
1763 fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1764 let root = Path::new("/repo");
1765 let owned: Vec<PathBuf> = (0..8)
1766 .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1767 .collect();
1768 let reportable: Vec<&PathBuf> = owned.iter().collect();
1769
1770 let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1771 assert!(bounded.contains("skipped at least 8 hidden directories"));
1772 assert!(
1773 bounded.contains("and more") && !bounded.contains("and 3 more"),
1774 "an inexact total must not carry an exact remainder: {bounded}"
1775 );
1776
1777 let complete = build_skipped_dotdirs_note(root, &reportable, false);
1778 assert!(!complete.contains("at least"));
1779 assert!(complete.contains("and 3 more"));
1780 }
1781
1782 #[test]
1783 fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1784 let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1785 sorted.sort_unstable();
1786 assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1787 for component in DOTDIR_NOISE_PATH_COMPONENTS {
1788 assert!(!component.starts_with('.'), "'{component}' is not hidden");
1789 assert_eq!(
1790 *component,
1791 component.to_lowercase(),
1792 "'{component}' is matched verbatim against a path component"
1793 );
1794 }
1795 }
1796
1797 #[test]
1798 fn script_scope_denylist_stays_disjoint_and_sorted() {
1799 let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1800 sorted.sort_unstable();
1801 assert_eq!(
1802 sorted, SCRIPT_SCOPE_DENYLIST,
1803 "keep the list sorted so additions stay reviewable"
1804 );
1805 for dir in SCRIPT_SCOPE_DENYLIST {
1806 assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1807 assert!(
1808 !ALLOWED_HIDDEN_DIRS.contains(dir),
1809 "'{dir}' is traversed, so it can never be a skipped candidate"
1810 );
1811 }
1812 }
1813
1814 #[test]
1815 fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1816 for ext in DOTDIR_MODULE_EXTENSIONS {
1817 assert!(
1818 SOURCE_EXTENSIONS.contains(ext),
1819 "'{ext}' is not discovered as source, so it cannot be a trigger"
1820 );
1821 }
1822 for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1823 assert!(
1824 !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1825 "'{ext}' carries no imports or exports for the message to be about"
1826 );
1827 }
1828 }
1829
1830 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1833 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1834 raw.into_iter()
1835 .enumerate()
1836 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1837 id: FileId(idx as u32),
1838 path,
1839 size_bytes,
1840 })
1841 .collect()
1842 }
1843
1844 #[test]
1850 fn file_id_assignment_is_deterministic_for_identical_file_set() {
1851 let paths = [
1852 "/project/src/z.ts",
1853 "/project/src/a.ts",
1854 "/project/src/components/Button.tsx",
1855 "/project/src/components/Button.module.css",
1856 "/project/index.ts",
1857 ];
1858
1859 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1861 .iter()
1862 .map(|p| (std::path::PathBuf::from(p), 10))
1863 .collect();
1864 let mut walk_two = walk_one.clone();
1865 walk_two.reverse();
1866
1867 let files_one = assign_file_ids(walk_one);
1868 let files_two = assign_file_ids(walk_two);
1869
1870 assert_eq!(files_one.len(), files_two.len());
1872 for (a, b) in files_one.iter().zip(files_two.iter()) {
1873 assert_eq!(a.id, b.id);
1874 assert_eq!(a.path, b.path);
1875 }
1876
1877 for (idx, file) in files_one.iter().enumerate() {
1880 assert_eq!(file.id, FileId(idx as u32));
1881 }
1882 assert_eq!(
1883 files_one[0].path,
1884 std::path::PathBuf::from("/project/index.ts")
1885 );
1886 }
1887
1888 #[test]
1889 fn file_id_assignment_recomputes_after_rename_or_delete() {
1890 let before = assign_file_ids(vec![
1891 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1892 (std::path::PathBuf::from("/project/src/b.ts"), 10),
1893 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1894 ]);
1895 let after_delete = assign_file_ids(vec![
1896 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1897 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1898 ]);
1899 let after_rename = assign_file_ids(vec![
1900 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1901 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1902 (std::path::PathBuf::from("/project/src/d.ts"), 10),
1903 ]);
1904
1905 assert_eq!(before[0].id, FileId(0));
1906 assert_eq!(before[1].id, FileId(1));
1907 assert_eq!(before[2].id, FileId(2));
1908 assert_eq!(after_delete[0].id, FileId(0));
1909 assert_eq!(after_delete[1].id, FileId(1));
1910 assert_eq!(
1911 after_delete[1].path,
1912 std::path::PathBuf::from("/project/src/c.ts")
1913 );
1914 assert_eq!(after_rename[0].id, FileId(0));
1915 assert_eq!(after_rename[1].id, FileId(1));
1916 assert_eq!(
1917 after_rename[1].path,
1918 std::path::PathBuf::from("/project/src/c.ts")
1919 );
1920 assert_eq!(after_rename[2].id, FileId(2));
1921 assert_eq!(
1922 after_rename[2].path,
1923 std::path::PathBuf::from("/project/src/d.ts")
1924 );
1925 }
1926
1927 #[test]
1928 fn allowed_hidden_dirs() {
1929 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1930 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1931 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1932 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1933 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1934 }
1935
1936 #[test]
1937 fn disallowed_hidden_dirs() {
1938 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1939 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1940 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1941 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1942 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1943 }
1944
1945 #[test]
1946 fn non_hidden_dirs_not_in_allowlist() {
1947 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1948 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1949 }
1950
1951 #[test]
1952 fn walk_types_match_every_supported_source_extension() {
1953 for capture_config in [false, true] {
1954 let types = build_walk_types(capture_config);
1955 for extension in SOURCE_EXTENSIONS {
1956 let path = format!("packages/ui/src/nested/component.{extension}");
1957 assert!(
1958 types.matched(&path, false).is_whitelist(),
1959 "expected source match for {path} with capture_config={capture_config}"
1960 );
1961 }
1962 }
1963 }
1964
1965 #[test]
1966 fn walk_types_match_typescript_declaration_files() {
1967 let types = build_walk_types(true);
1968 for path in [
1969 "src/env.d.ts",
1970 "packages/app/types/generated.d.mts",
1971 "packages/app/types/compat.d.cts",
1972 ] {
1973 assert!(
1974 types.matched(path, false).is_whitelist(),
1975 "expected declaration source match for {path}"
1976 );
1977 }
1978 }
1979
1980 #[test]
1981 fn walk_types_reject_source_extension_near_misses() {
1982 for capture_config in [false, true] {
1983 let types = build_walk_types(capture_config);
1984 for path in [
1985 "src/component.tsx.bak",
1986 "src/component.tsxmap",
1987 "src/component.TS",
1988 "src/component.gqlx",
1989 "src/component.htm",
1990 "src/component",
1991 "assets/component.png",
1992 ] {
1993 assert!(
1994 types.matched(path, false).is_ignore(),
1995 "expected non-source rejection for {path} with capture_config={capture_config}"
1996 );
1997 }
1998 }
1999 }
2000
2001 #[test]
2002 fn walk_types_keep_config_candidate_selection_separate() {
2003 assert!(
2004 build_walk_types(true)
2005 .matched("packages/app/tsconfig.json", false)
2006 .is_whitelist()
2007 );
2008 assert!(
2009 build_walk_types(false)
2010 .matched("packages/app/tsconfig.json", false)
2011 .is_ignore()
2012 );
2013 }
2014
2015 #[test]
2016 fn source_extensions_are_exactly_the_supported_set() {
2017 let mut actual = SOURCE_EXTENSIONS.to_vec();
2018 actual.sort_unstable();
2019 let mut expected = vec![
2020 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte",
2021 "astro", "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
2022 ];
2023 expected.sort_unstable();
2024 assert_eq!(actual, expected);
2025 }
2026
2027 fn production_excludes() -> globset::GlobSet {
2029 let config = fallow_config::FallowConfig {
2030 production: true.into(),
2031 ..Default::default()
2032 }
2033 .resolve(
2034 std::path::PathBuf::from("/project"),
2035 fallow_config::OutputFormat::Human,
2036 1,
2037 true,
2038 true,
2039 None,
2040 );
2041 build_production_excludes(&config).expect("production mode builds an exclude set")
2042 }
2043
2044 #[test]
2047 fn production_exclude_patterns_all_compile() {
2048 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
2049 assert!(
2050 globset::GlobBuilder::new(pattern)
2051 .literal_separator(true)
2052 .build()
2053 .is_ok(),
2054 "production exclude pattern does not compile: {pattern}"
2055 );
2056 }
2057 }
2058
2059 #[test]
2060 fn production_excludes_test_files() {
2061 let set = production_excludes();
2062 assert!(set.is_match("src/Button.test.ts"));
2063 assert!(set.is_match("src/utils.spec.tsx"));
2064 assert!(set.is_match("src/__tests__/helper.ts"));
2065 assert!(!set.is_match("src/Button.ts"));
2066 assert!(!set.is_match("src/utils.tsx"));
2067 }
2068
2069 #[test]
2070 fn production_excludes_story_files() {
2071 let set = production_excludes();
2072 assert!(set.is_match("src/Button.stories.tsx"));
2073 assert!(set.is_match("src/Card.story.ts"));
2074 assert!(!set.is_match("src/Button.tsx"));
2075 }
2076
2077 #[test]
2078 fn production_excludes_config_files_at_root_only() {
2079 let set = production_excludes();
2080 assert!(set.is_match("vitest.config.ts"));
2081 assert!(set.is_match("jest.config.js"));
2082 assert!(!set.is_match("src/app/app.config.ts"));
2083 assert!(!set.is_match("src/app/app.config.server.ts"));
2084 assert!(!set.is_match("packages/foo/vitest.config.ts"));
2085 assert!(!set.is_match("src/config.ts"));
2086 }
2087
2088 #[test]
2089 fn disallowed_hidden_dirs_idea() {
2090 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
2091 }
2092
2093 #[test]
2094 fn is_declaration_file_matches_dts_variants() {
2095 assert!(is_declaration_file(Path::new("env.d.ts")));
2096 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
2097 assert!(is_declaration_file(Path::new("mod.d.mts")));
2098 assert!(is_declaration_file(Path::new("compat.d.cts")));
2099 assert!(!is_declaration_file(Path::new("index.ts")));
2100 assert!(!is_declaration_file(Path::new("component.tsx")));
2101 assert!(!is_declaration_file(Path::new("notes.d.txt")));
2102 }
2103
2104 #[test]
2105 fn format_size_mb_renders_one_decimal() {
2106 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2107 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2108 assert_eq!(format_size_mb(0), "0.0 MB");
2109 }
2110
2111 #[test]
2112 fn partition_by_size_no_limit_keeps_all() {
2113 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
2114 let (kept, skipped) = partition_by_size(raw, None);
2115 assert_eq!(kept.len(), 2);
2116 assert!(skipped.is_empty());
2117 }
2118
2119 #[test]
2120 fn partition_by_size_skips_strictly_over_limit() {
2121 let raw = vec![
2122 (PathBuf::from("under.ts"), 99),
2123 (PathBuf::from("exact.ts"), 100),
2124 (PathBuf::from("over.ts"), 101),
2125 ];
2126 let (kept, skipped) = partition_by_size(raw, Some(100));
2127 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
2128 assert!(kept_has("under.ts"));
2129 assert!(
2130 kept_has("exact.ts"),
2131 "a file exactly at the limit is kept (skip is strictly-greater)"
2132 );
2133 assert_eq!(skipped.len(), 1);
2134 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
2135 }
2136
2137 #[test]
2138 fn partition_by_size_exempts_declaration_files() {
2139 let raw = vec![
2140 (PathBuf::from("huge.ts"), 10_000),
2141 (PathBuf::from("auto-imports.d.ts"), 10_000),
2142 ];
2143 let (kept, skipped) = partition_by_size(raw, Some(100));
2144 assert!(
2145 kept.iter()
2146 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
2147 "declaration files are exempt from the size skip regardless of size"
2148 );
2149 assert_eq!(skipped.len(), 1);
2150 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
2151 }
2152
2153 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
2154 DiscoveredFile {
2155 id: FileId(0),
2156 path: PathBuf::from(path),
2157 size_bytes,
2158 }
2159 }
2160
2161 #[test]
2162 fn largest_files_note_below_threshold_is_none() {
2163 let files = [disco("a.ts", 100), disco("b.ts", 200)];
2164 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
2165 }
2166
2167 #[test]
2168 fn largest_files_note_single_file_uses_singular() {
2169 let files = [disco("big.ts", 5 * 1024 * 1024)];
2170 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2171 assert!(
2172 note.contains("discovered 1 file;"),
2173 "singular noun on the single-big-file path (issue #1086 regression): {note}"
2174 );
2175 assert!(!note.contains("discovered 1 files"));
2176 assert!(note.contains("big.ts (5.0 MB)"));
2177 }
2178
2179 #[test]
2180 fn largest_files_note_filters_sub_floor_files() {
2181 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
2182 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2183 assert!(note.contains("discovered 2 files;"));
2184 assert!(note.contains("big.ts (5.0 MB)"));
2185 assert!(
2186 !note.contains("tiny.ts"),
2187 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
2188 );
2189 }
2190
2191 #[test]
2192 fn largest_files_note_large_set_no_big_file_omits_list() {
2193 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
2194 .map(|i| disco(&format!("f{i}.ts"), 100))
2195 .collect();
2196 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
2197 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
2198 assert!(
2199 !note.contains("largest:"),
2200 "no sub-floor `largest:` list when no file clears the floor: {note}"
2201 );
2202 }
2203
2204 mod discover_files_integration {
2205 use std::path::PathBuf;
2206
2207 use fallow_config::{
2208 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
2209 RulesConfig,
2210 };
2211
2212 use super::*;
2213
2214 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
2216 FallowConfig {
2217 production: production.into(),
2218 ..Default::default()
2219 }
2220 .resolve(root, OutputFormat::Human, 1, true, true, None)
2221 }
2222
2223 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
2226 files
2227 .iter()
2228 .map(|f| {
2229 f.path
2230 .strip_prefix(root)
2231 .unwrap_or(&f.path)
2232 .to_string_lossy()
2233 .replace('\\', "/")
2234 })
2235 .collect()
2236 }
2237
2238 #[cfg(unix)]
2239 fn symlink_file(target: &Path, link: &Path) {
2240 std::os::unix::fs::symlink(target, link).expect("create file symlink");
2241 }
2242
2243 #[cfg(windows)]
2244 fn symlink_file(target: &Path, link: &Path) {
2245 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
2246 }
2247
2248 #[cfg(unix)]
2249 fn symlink_dir(target: &Path, link: &Path) {
2250 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
2251 }
2252
2253 #[cfg(windows)]
2254 fn symlink_dir(target: &Path, link: &Path) {
2255 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
2256 }
2257
2258 #[test]
2259 fn source_symlinks_must_target_regular_files_inside_root() {
2260 let dir = tempfile::tempdir().expect("create project");
2261 let outside = tempfile::tempdir().expect("create outside dir");
2262 let src = dir.path().join("src");
2263 std::fs::create_dir_all(&src).unwrap();
2264 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
2265 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
2266 std::fs::write(
2267 outside.path().join("outside-target.ts"),
2268 "export const outside = 1;",
2269 )
2270 .unwrap();
2271 std::fs::create_dir_all(src.join("directory-target")).unwrap();
2272
2273 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
2274 symlink_file(
2275 &outside.path().join("outside-target.ts"),
2276 &src.join("outside-link.ts"),
2277 );
2278 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
2279 symlink_dir(
2280 &src.join("directory-target"),
2281 &src.join("directory-link.ts"),
2282 );
2283
2284 let config = make_config(dir.path().to_path_buf(), false);
2285 let names = file_names(&discover_files(&config), dir.path());
2286
2287 assert!(names.contains(&"src/regular.ts".to_string()));
2288 assert!(names.contains(&"src/inside-target.ts".to_string()));
2289 assert!(names.contains(&"src/inside-link.ts".to_string()));
2290 assert!(!names.contains(&"src/outside-link.ts".to_string()));
2291 assert!(!names.contains(&"src/broken-link.ts".to_string()));
2292 assert!(!names.contains(&"src/directory-link.ts".to_string()));
2293 }
2294
2295 #[test]
2299 fn skips_yarn_pnp_generated_files() {
2300 let dir = tempfile::tempdir().expect("create temp dir");
2301 std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
2302 std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
2303 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
2304
2305 let config = make_config(dir.path().to_path_buf(), false);
2306 let names = file_names(&discover_files(&config), dir.path());
2307
2308 assert_eq!(names, vec!["index.ts".to_string()]);
2309 }
2310
2311 #[test]
2312 fn discovers_source_files_with_valid_extensions() {
2313 let dir = tempfile::tempdir().expect("create temp dir");
2314 let src = dir.path().join("src");
2315 std::fs::create_dir_all(&src).unwrap();
2316
2317 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2318 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
2319 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
2320 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
2321 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
2322 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
2323 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
2324 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
2325
2326 let config = make_config(dir.path().to_path_buf(), false);
2327 let files = discover_files(&config);
2328 let names = file_names(&files, dir.path());
2329
2330 assert!(names.contains(&"src/app.ts".to_string()));
2331 assert!(names.contains(&"src/component.tsx".to_string()));
2332 assert!(names.contains(&"src/utils.js".to_string()));
2333 assert!(names.contains(&"src/helper.jsx".to_string()));
2334 assert!(names.contains(&"src/config.mjs".to_string()));
2335 assert!(names.contains(&"src/legacy.cjs".to_string()));
2336 assert!(names.contains(&"src/types.mts".to_string()));
2337 assert!(names.contains(&"src/compat.cts".to_string()));
2338 }
2339
2340 #[test]
2341 fn compact_source_glob_preserves_discovered_file_inventory() {
2342 let dir = tempfile::tempdir().expect("create temp dir");
2343 let nested = dir.path().join("packages/ui/src/nested");
2344 std::fs::create_dir_all(&nested).unwrap();
2345
2346 let mut expected = Vec::new();
2347 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
2348 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
2349 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
2350 expected.push(relative);
2351 }
2352 for relative in [
2353 "packages/ui/src/nested/env.d.ts",
2354 "packages/ui/src/nested/generated.d.mts",
2355 "packages/ui/src/nested/compat.d.cts",
2356 ] {
2357 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
2358 expected.push(relative.to_string());
2359 }
2360 let rejected = [
2361 "packages/ui/src/nested/component.tsx.bak",
2362 "packages/ui/src/nested/component.tsxmap",
2363 "packages/ui/src/nested/component.TS",
2364 "packages/ui/src/nested/component.gqlx",
2365 "packages/ui/src/nested/component.htm",
2366 "packages/ui/src/nested/component",
2367 "packages/ui/src/nested/component.png",
2368 ];
2369 for relative in rejected {
2370 std::fs::write(dir.path().join(relative), "not source").unwrap();
2371 }
2372
2373 let config = make_config(dir.path().to_path_buf(), false);
2374 let names = file_names(&discover_files(&config), dir.path());
2375
2376 for relative in expected {
2377 assert!(
2378 names.contains(&relative),
2379 "missing supported source {relative}"
2380 );
2381 }
2382 for relative in rejected {
2383 assert!(
2384 !names.iter().any(|name| name == relative),
2385 "unexpected near-miss source {relative}"
2386 );
2387 }
2388 }
2389
2390 #[test]
2391 fn excludes_non_source_extensions() {
2392 let dir = tempfile::tempdir().expect("create temp dir");
2393 let src = dir.path().join("src");
2394 std::fs::create_dir_all(&src).unwrap();
2395
2396 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2397
2398 std::fs::write(src.join("data.json"), "{}").unwrap();
2399 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
2400 std::fs::write(src.join("notes.txt"), "notes").unwrap();
2401 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
2402
2403 let config = make_config(dir.path().to_path_buf(), false);
2404 let files = discover_files(&config);
2405 let names = file_names(&files, dir.path());
2406
2407 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
2408 assert!(names.contains(&"src/app.ts".to_string()));
2409 }
2410
2411 #[test]
2412 fn excludes_disallowed_hidden_directories() {
2413 let dir = tempfile::tempdir().expect("create temp dir");
2414
2415 let git_dir = dir.path().join(".git");
2416 std::fs::create_dir_all(&git_dir).unwrap();
2417 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
2418
2419 let idea_dir = dir.path().join(".idea");
2420 std::fs::create_dir_all(&idea_dir).unwrap();
2421 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
2422
2423 let cache_dir = dir.path().join(".cache");
2424 std::fs::create_dir_all(&cache_dir).unwrap();
2425 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2426
2427 let src = dir.path().join("src");
2428 std::fs::create_dir_all(&src).unwrap();
2429 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2430
2431 let config = make_config(dir.path().to_path_buf(), false);
2432 let files = discover_files(&config);
2433 let names = file_names(&files, dir.path());
2434
2435 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2436 assert!(names.contains(&"src/app.ts".to_string()));
2437 }
2438
2439 #[test]
2440 fn includes_allowed_hidden_directories() {
2441 let dir = tempfile::tempdir().expect("create temp dir");
2442
2443 let storybook = dir.path().join(".storybook");
2444 std::fs::create_dir_all(&storybook).unwrap();
2445 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2446
2447 let github = dir.path().join(".github");
2448 std::fs::create_dir_all(&github).unwrap();
2449 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2450
2451 let changeset = dir.path().join(".changeset");
2452 std::fs::create_dir_all(&changeset).unwrap();
2453 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2454
2455 let config = make_config(dir.path().to_path_buf(), false);
2456 let files = discover_files(&config);
2457 let names = file_names(&files, dir.path());
2458
2459 assert!(
2460 names.contains(&".storybook/main.ts".to_string()),
2461 "files in .storybook should be discovered"
2462 );
2463 assert!(
2464 names.contains(&".github/actions.js".to_string()),
2465 "files in .github should be discovered"
2466 );
2467 assert!(
2468 names.contains(&".changeset/config.js".to_string()),
2469 "files in .changeset should be discovered"
2470 );
2471 }
2472
2473 #[test]
2474 fn default_discovery_excludes_client_and_server_hidden_directories() {
2475 let dir = tempfile::tempdir().expect("create temp dir");
2476 let app = dir.path().join("app");
2477 std::fs::create_dir_all(app.join(".client")).unwrap();
2478 std::fs::create_dir_all(app.join(".server")).unwrap();
2479 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2480 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2481 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2482
2483 let config = make_config(dir.path().to_path_buf(), false);
2484 let files = discover_files(&config);
2485 let names = file_names(&files, dir.path());
2486
2487 assert!(names.contains(&"app/root.tsx".to_string()));
2488 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2489 assert!(!names.contains(&"app/.server/db.ts".to_string()));
2490 }
2491
2492 #[test]
2493 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2494 let dir = tempfile::tempdir().expect("create temp dir");
2495 let package = dir.path().join("packages/app");
2496 std::fs::create_dir_all(package.join("app/.client")).unwrap();
2497 std::fs::create_dir_all(package.join("app/.server")).unwrap();
2498 std::fs::write(
2499 package.join("app/.client/analytics.ts"),
2500 "export const track = () => {};",
2501 )
2502 .unwrap();
2503 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2504
2505 let config = make_config(dir.path().to_path_buf(), false);
2506 let scopes = [HiddenDirScope::new(
2507 package,
2508 vec![".client".to_string(), ".server".to_string()],
2509 )];
2510 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2511 let names = file_names(&files, dir.path());
2512
2513 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2514 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2515 }
2516
2517 #[test]
2518 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2519 let dir = tempfile::tempdir().expect("create temp dir");
2520 let active = dir.path().join("packages/active");
2521 let inactive = dir.path().join("packages/inactive");
2522 std::fs::create_dir_all(active.join("app/.server")).unwrap();
2523 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2524 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2525 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2526
2527 let config = make_config(dir.path().to_path_buf(), false);
2528 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2529 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2530 let names = file_names(&files, dir.path());
2531
2532 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2533 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2534 }
2535
2536 #[test]
2537 fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2538 let dir = tempfile::tempdir().expect("create temp dir");
2542 std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2543 std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2544 std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2545 std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2546 std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2547 std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2548
2549 let config = make_config(dir.path().to_path_buf(), false);
2550 let scopes = [HiddenDirScope::new_exact_paths(
2551 dir.path().to_path_buf(),
2552 vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2553 )];
2554 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2555 let names = file_names(&files, dir.path());
2556
2557 assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2558 assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2559 assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2560 }
2561
2562 #[test]
2563 fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2564 let dir = tempfile::tempdir().expect("create temp dir");
2565 std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2566 std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2567 std::fs::write(
2568 dir.path().join("tools/.config/eslint.config.js"),
2569 "export default [];",
2570 )
2571 .unwrap();
2572 std::fs::write(
2573 dir.path().join("other/.config/eslint.config.js"),
2574 "export default [];",
2575 )
2576 .unwrap();
2577
2578 let config = make_config(dir.path().to_path_buf(), false);
2579 let scopes = [HiddenDirScope::new_exact_paths(
2580 dir.path().to_path_buf(),
2581 vec![format!("tools{MAIN_SEPARATOR}.config")],
2582 )];
2583 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2584 let names = file_names(&files, dir.path());
2585
2586 assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2587 assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2588 }
2589
2590 #[test]
2591 fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2592 let dir = tempfile::tempdir().expect("create temp dir");
2596 std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2597 std::fs::write(
2598 dir.path().join("app/routes/deep/.server/db.ts"),
2599 "export const db = {};",
2600 )
2601 .unwrap();
2602
2603 let config = make_config(dir.path().to_path_buf(), false);
2604 let scopes = [HiddenDirScope::new(
2605 dir.path().to_path_buf(),
2606 vec![".server".to_string()],
2607 )];
2608 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2609 let names = file_names(&files, dir.path());
2610
2611 assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2612 }
2613
2614 #[test]
2615 fn excludes_root_build_directory() {
2616 let dir = tempfile::tempdir().expect("create temp dir");
2617
2618 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2619
2620 let build_dir = dir.path().join("build");
2621 std::fs::create_dir_all(&build_dir).unwrap();
2622 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2623
2624 let src = dir.path().join("src");
2625 std::fs::create_dir_all(&src).unwrap();
2626 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2627
2628 let config = make_config(dir.path().to_path_buf(), false);
2629 let files = discover_files(&config);
2630 let names = file_names(&files, dir.path());
2631
2632 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2633 assert!(names.contains(&"src/app.ts".to_string()));
2634 }
2635
2636 #[test]
2637 fn excludes_nested_build_directory() {
2638 let dir = tempfile::tempdir().expect("create temp dir");
2639
2640 let nested_build = dir.path().join("src").join("build");
2641 std::fs::create_dir_all(&nested_build).unwrap();
2642 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2643
2644 let config = make_config(dir.path().to_path_buf(), false);
2645 let files = discover_files(&config);
2646 let names = file_names(&files, dir.path());
2647
2648 assert!(
2649 !names.contains(&"src/build/helper.ts".to_string()),
2650 "build/ is treated as generated output at any depth: {names:?}"
2651 );
2652 }
2653
2654 #[test]
2655 #[expect(
2656 clippy::cast_possible_truncation,
2657 reason = "test file counts are trivially small"
2658 )]
2659 fn file_ids_are_sequential_after_sorting() {
2660 let dir = tempfile::tempdir().expect("create temp dir");
2661 let src = dir.path().join("src");
2662 std::fs::create_dir_all(&src).unwrap();
2663
2664 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2665 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2666 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2667
2668 let config = make_config(dir.path().to_path_buf(), false);
2669 let files = discover_files(&config);
2670
2671 for (idx, file) in files.iter().enumerate() {
2672 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2673 }
2674
2675 for pair in files.windows(2) {
2676 assert!(
2677 pair[0].path < pair[1].path,
2678 "files should be sorted by path"
2679 );
2680 }
2681 }
2682
2683 #[test]
2684 fn production_mode_excludes_test_files() {
2685 let dir = tempfile::tempdir().expect("create temp dir");
2686 let src = dir.path().join("src");
2687 std::fs::create_dir_all(&src).unwrap();
2688
2689 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2690 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2691 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2692 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2693
2694 let config = make_config(dir.path().to_path_buf(), true);
2695 let files = discover_files(&config);
2696 let names = file_names(&files, dir.path());
2697
2698 assert!(
2699 names.contains(&"src/app.ts".to_string()),
2700 "source files should be included in production mode"
2701 );
2702 assert!(
2703 !names.contains(&"src/app.test.ts".to_string()),
2704 "test files should be excluded in production mode"
2705 );
2706 assert!(
2707 !names.contains(&"src/app.spec.ts".to_string()),
2708 "spec files should be excluded in production mode"
2709 );
2710 assert!(
2711 !names.contains(&"src/app.stories.tsx".to_string()),
2712 "story files should be excluded in production mode"
2713 );
2714 }
2715
2716 #[test]
2717 fn non_production_mode_includes_test_files() {
2718 let dir = tempfile::tempdir().expect("create temp dir");
2719 let src = dir.path().join("src");
2720 std::fs::create_dir_all(&src).unwrap();
2721
2722 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2723 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2724
2725 let config = make_config(dir.path().to_path_buf(), false);
2726 let files = discover_files(&config);
2727 let names = file_names(&files, dir.path());
2728
2729 assert!(names.contains(&"src/app.ts".to_string()));
2730 assert!(
2731 names.contains(&"src/app.test.ts".to_string()),
2732 "test files should be included in non-production mode"
2733 );
2734 }
2735
2736 #[test]
2737 fn empty_directory_returns_no_files() {
2738 let dir = tempfile::tempdir().expect("create temp dir");
2739 let config = make_config(dir.path().to_path_buf(), false);
2740 let files = discover_files(&config);
2741 assert!(files.is_empty(), "empty project should discover no files");
2742 }
2743
2744 #[test]
2745 fn hidden_files_not_discovered_as_source() {
2746 let dir = tempfile::tempdir().expect("create temp dir");
2747
2748 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2749 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2750 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2751
2752 let src = dir.path().join("src");
2753 std::fs::create_dir_all(&src).unwrap();
2754 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2755
2756 let config = make_config(dir.path().to_path_buf(), false);
2757 let files = discover_files(&config);
2758 let names = file_names(&files, dir.path());
2759
2760 assert!(
2761 !names.contains(&".env".to_string()),
2762 ".env should not be discovered"
2763 );
2764 assert!(
2765 !names.contains(&".gitignore".to_string()),
2766 ".gitignore should not be discovered"
2767 );
2768 }
2769
2770 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2772 FallowConfig {
2773 type_aware: fallow_config::TypeAwareConfig::default(),
2774 schema: None,
2775 minimum_version: None,
2776 extends: vec![],
2777 entry: vec![],
2778 ignore_patterns: ignores,
2779 ignore_findings: vec![],
2780 framework: vec![],
2781 workspaces: None,
2782 ignore_dependencies: vec![],
2783 ignore_command_entries: vec![],
2784 ignore_unresolved_imports: vec![],
2785 ignore_exports: vec![],
2786 ignore_catalog_references: vec![],
2787 ignore_dependency_overrides: vec![],
2788 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2789 ),
2790 used_class_members: vec![],
2791 ignore_decorators: vec![],
2792 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2793 circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
2794 duplicates: DuplicatesConfig::default(),
2795 similar_code: fallow_config::SimilarCodeConfig::default(),
2796 health: HealthConfig::default(),
2797 rules: RulesConfig::default(),
2798 boundaries: fallow_config::BoundaryConfig::default(),
2799 production: false.into(),
2800 plugins: vec![],
2801 rule_packs: vec![],
2802 dynamically_loaded: vec![],
2803 overrides: vec![],
2804 regression: None,
2805 audit: fallow_config::AuditConfig::default(),
2806 codeowners: None,
2807 public_packages: vec![],
2808 flags: FlagsConfig::default(),
2809 security: fallow_config::SecurityConfig::default(),
2810 fix: fallow_config::FixConfig::default(),
2811 resolve: ResolveConfig::default(),
2812 sealed: false,
2813 include_entry_exports: false,
2814 auto_imports: false,
2815 fail_on_parse_error: false,
2816 cache: fallow_config::CacheConfig::default(),
2817 }
2818 .resolve(root, OutputFormat::Human, 1, true, true, None)
2819 }
2820
2821 #[test]
2822 fn custom_ignore_patterns_exclude_matching_files() {
2823 let dir = tempfile::tempdir().expect("create temp dir");
2824
2825 let generated = dir.path().join("src").join("api").join("generated");
2826 std::fs::create_dir_all(&generated).unwrap();
2827 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2828
2829 let client = dir.path().join("src").join("api").join("client");
2830 std::fs::create_dir_all(&client).unwrap();
2831 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2832
2833 let src = dir.path().join("src");
2834 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2835
2836 let config = make_config_with_ignores(
2837 dir.path().to_path_buf(),
2838 vec![
2839 "src/api/generated/**".to_string(),
2840 "src/api/client/**".to_string(),
2841 ],
2842 );
2843 let files = discover_files(&config);
2844 let names = file_names(&files, dir.path());
2845
2846 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2847 assert!(names.contains(&"src/index.ts".to_string()));
2848 }
2849
2850 #[test]
2851 fn leading_dot_ignore_patterns_exclude_matching_files() {
2852 let dir = tempfile::tempdir().expect("create temp dir");
2853
2854 let generated = dir.path().join("src").join("generated");
2855 std::fs::create_dir_all(&generated).unwrap();
2856 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2857
2858 let src = dir.path().join("src");
2859 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2860
2861 let config = make_config_with_ignores(
2862 dir.path().to_path_buf(),
2863 vec!["./src/generated/**".to_string()],
2864 );
2865 let files = discover_files(&config);
2866 let names = file_names(&files, dir.path());
2867
2868 assert_eq!(names, vec!["src/index.ts"]);
2869 }
2870
2871 #[test]
2872 fn default_ignore_patterns_exclude_node_modules_and_dist() {
2873 let dir = tempfile::tempdir().expect("create temp dir");
2874
2875 let nm = dir.path().join("node_modules").join("lodash");
2876 std::fs::create_dir_all(&nm).unwrap();
2877 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2878
2879 let dist = dir.path().join("dist");
2880 std::fs::create_dir_all(&dist).unwrap();
2881 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2882
2883 let src = dir.path().join("src");
2884 std::fs::create_dir_all(&src).unwrap();
2885 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2886
2887 let config = make_config(dir.path().to_path_buf(), false);
2888 let files = discover_files(&config);
2889 let names = file_names(&files, dir.path());
2890
2891 assert_eq!(names.len(), 1);
2892 assert!(names.contains(&"src/index.ts".to_string()));
2893 }
2894
2895 #[test]
2896 fn default_ignore_patterns_exclude_build_at_any_depth() {
2897 let dir = tempfile::tempdir().expect("create temp dir");
2898
2899 let build = dir.path().join("build");
2900 std::fs::create_dir_all(&build).unwrap();
2901 std::fs::write(build.join("output.js"), "// built").unwrap();
2902
2903 let nested_build = dir.path().join("src").join("build");
2904 std::fs::create_dir_all(&nested_build).unwrap();
2905 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2906
2907 let src = dir.path().join("src");
2908 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2909
2910 let config = make_config(dir.path().to_path_buf(), false);
2911 let files = discover_files(&config);
2912 let names = file_names(&files, dir.path());
2913
2914 assert_eq!(names, vec!["src/index.ts".to_string()]);
2915 }
2916
2917 #[test]
2920 fn default_ignore_patterns_exclude_nested_build() {
2921 let dir = tempfile::tempdir().expect("create temp dir");
2922
2923 let build = dir.path().join("build");
2924 std::fs::create_dir_all(&build).unwrap();
2925 std::fs::write(build.join("output.js"), "// built").unwrap();
2926
2927 let package_build = dir.path().join("projects").join("app").join("build");
2928 std::fs::create_dir_all(&package_build).unwrap();
2929 std::fs::write(package_build.join("index.js"), "// built").unwrap();
2930
2931 let src = dir.path().join("src");
2932 std::fs::create_dir_all(&src).unwrap();
2933 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2934
2935 let config = make_config(dir.path().to_path_buf(), false);
2936 let files = discover_files(&config);
2937 let names = file_names(&files, dir.path());
2938
2939 assert_eq!(names, vec!["src/index.ts".to_string()]);
2940 }
2941
2942 #[test]
2944 fn default_ignore_patterns_keep_paths_that_merely_contain_build() {
2945 let dir = tempfile::tempdir().expect("create temp dir");
2946
2947 let src = dir.path().join("src");
2948 std::fs::create_dir_all(src.join("rebuild")).unwrap();
2949 std::fs::create_dir_all(src.join("buildings")).unwrap();
2950 std::fs::write(src.join("build.ts"), "export const a = 1;").unwrap();
2951 std::fs::write(src.join("rebuild").join("helper.ts"), "export const b = 1;").unwrap();
2952 std::fs::write(src.join("buildings").join("a.ts"), "export const c = 1;").unwrap();
2953
2954 let config = make_config(dir.path().to_path_buf(), false);
2955 let files = discover_files(&config);
2956 let mut names = file_names(&files, dir.path());
2957 names.sort();
2958
2959 assert_eq!(
2960 names,
2961 vec![
2962 "src/build.ts".to_string(),
2963 "src/buildings/a.ts".to_string(),
2964 "src/rebuild/helper.ts".to_string(),
2965 ]
2966 );
2967 }
2968
2969 fn make_config_with_max_file_size(
2971 root: PathBuf,
2972 max_file_size_bytes: Option<u64>,
2973 ) -> ResolvedConfig {
2974 let mut config = make_config(root, false);
2975 config.max_file_size_bytes = max_file_size_bytes;
2976 config
2977 }
2978
2979 #[test]
2980 fn skips_files_over_max_file_size() {
2981 let dir = tempfile::tempdir().expect("create temp dir");
2982 let src = dir.path().join("src");
2983 std::fs::create_dir_all(&src).unwrap();
2984 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
2985 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2986
2987 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2988 let files = discover_files(&config);
2989 let names = file_names(&files, dir.path());
2990
2991 assert!(names.contains(&"src/small.ts".to_string()));
2992 assert!(
2993 !names.contains(&"src/huge.ts".to_string()),
2994 "a file over the size limit must not be discovered"
2995 );
2996 }
2997
2998 #[test]
2999 fn declaration_files_exempt_from_size_skip() {
3000 let dir = tempfile::tempdir().expect("create temp dir");
3001 let src = dir.path().join("src");
3002 std::fs::create_dir_all(&src).unwrap();
3003 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
3004 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3005
3006 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3007 let files = discover_files(&config);
3008 let names = file_names(&files, dir.path());
3009
3010 assert!(
3011 names.contains(&"src/auto-imports.d.ts".to_string()),
3012 "a large .d.ts is exempt from the skip (reachability root for global types)"
3013 );
3014 assert!(!names.contains(&"src/huge.ts".to_string()));
3015 }
3016
3017 #[test]
3018 fn unlimited_size_keeps_large_files() {
3019 let dir = tempfile::tempdir().expect("create temp dir");
3020 let src = dir.path().join("src");
3021 std::fs::create_dir_all(&src).unwrap();
3022 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3023
3024 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3025 let files = discover_files(&config);
3026 let names = file_names(&files, dir.path());
3027
3028 assert!(
3029 names.contains(&"src/huge.ts".to_string()),
3030 "no limit keeps every file"
3031 );
3032 }
3033
3034 #[test]
3035 fn skipped_file_recorded_in_workspace_diagnostics() {
3036 let dir = tempfile::tempdir().expect("create temp dir");
3037 let src = dir.path().join("src");
3038 std::fs::create_dir_all(&src).unwrap();
3039 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3040
3041 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3042 let _ = discover_files(&config);
3043
3044 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3045 let skipped: Vec<_> = diagnostics
3046 .iter()
3047 .filter(|d| {
3048 matches!(
3049 d.kind,
3050 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
3051 )
3052 })
3053 .collect();
3054 assert_eq!(
3055 skipped.len(),
3056 1,
3057 "the skipped file is recorded in workspace diagnostics for JSON output"
3058 );
3059 assert!(skipped[0].path.ends_with("src/huge.ts"));
3060 assert!(
3061 matches!(
3062 skipped[0].kind,
3063 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
3064 if size_bytes == 5_000
3065 ),
3066 "the recorded diagnostic carries the on-disk byte size"
3067 );
3068 }
3069
3070 fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
3072 fallow_config::workspace_diagnostics_for(root)
3073 .into_iter()
3074 .filter(|d| {
3075 matches!(
3076 d.kind,
3077 fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
3078 )
3079 })
3080 .collect()
3081 }
3082
3083 fn write_at(root: &Path, relative: &str, contents: &str) {
3084 let path = root.join(relative);
3085 std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
3086 std::fs::write(path, contents).unwrap();
3087 }
3088
3089 #[test]
3090 fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
3091 let dir = tempfile::tempdir().expect("create temp dir");
3092 write_at(
3093 dir.path(),
3094 ".claude/hooks/probe.mjs",
3095 "export const a = 1;\n",
3096 );
3097 write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
3098
3099 let config = make_config(dir.path().to_path_buf(), false);
3100 let files = discover_files(&config);
3101 let names = file_names(&files, dir.path());
3102
3103 let reported = dotdir_diagnostics(dir.path());
3104 assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
3105 assert!(reported[0].path.ends_with(".claude"));
3106 assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
3107 assert!(
3108 reported[0].message.contains("--root"),
3109 "message names the real remedy: {}",
3110 reported[0].message
3111 );
3112 assert!(
3113 names.contains(&"src/app.ts".to_string()),
3114 "traversal is unchanged for ordinary directories"
3115 );
3116 assert!(
3117 !names.contains(&".claude/hooks/probe.mjs".to_string()),
3118 "the diagnostic reports the skip, it does not change traversal"
3119 );
3120 }
3121
3122 #[test]
3123 fn allowlisted_dotdir_is_not_reported() {
3124 let dir = tempfile::tempdir().expect("create temp dir");
3125 write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
3126
3127 let config = make_config(dir.path().to_path_buf(), false);
3128 let files = discover_files(&config);
3129 let names = file_names(&files, dir.path());
3130
3131 assert!(dotdir_diagnostics(dir.path()).is_empty());
3132 assert!(
3133 names.contains(&".storybook/main.ts".to_string()),
3134 "an allowlisted dotdir is still traversed"
3135 );
3136 }
3137
3138 #[test]
3139 fn denylisted_dotdir_is_not_reported() {
3140 let dir = tempfile::tempdir().expect("create temp dir");
3141 write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
3142 write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
3143 write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
3144 write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
3145
3146 let config = make_config(dir.path().to_path_buf(), false);
3147 let _ = discover_files(&config);
3148
3149 assert!(
3150 dotdir_diagnostics(dir.path()).is_empty(),
3151 "build caches, VCS and package-manager state never advise"
3152 );
3153 }
3154
3155 #[test]
3156 fn scoped_dotdir_is_traversed_and_not_reported() {
3157 let dir = tempfile::tempdir().expect("create temp dir");
3158 write_at(
3159 dir.path(),
3160 ".claude/hooks/probe.mjs",
3161 "export const a = 1;\n",
3162 );
3163
3164 let config = make_config(dir.path().to_path_buf(), false);
3165 let scopes = [HiddenDirScope::new(
3166 dir.path().to_path_buf(),
3167 vec![".claude".to_owned()],
3168 )];
3169 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
3170 let names = file_names(&files, dir.path());
3171
3172 assert!(
3173 dotdir_diagnostics(dir.path()).is_empty(),
3174 "a plugin- or script-contributed scope is admitted, so nothing was skipped"
3175 );
3176 assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
3177 }
3178
3179 #[test]
3180 fn ignore_patterns_silence_the_skipped_source_dotdir() {
3181 let dir = tempfile::tempdir().expect("create temp dir");
3182 write_at(
3183 dir.path(),
3184 ".claude/hooks/probe.mjs",
3185 "export const a = 1;\n",
3186 );
3187
3188 let config =
3189 make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
3190 let _ = discover_files(&config);
3191
3192 assert!(
3193 dotdir_diagnostics(dir.path()).is_empty(),
3194 "the documented silencing route works"
3195 );
3196 }
3197
3198 #[test]
3199 fn dotdir_without_source_files_is_not_reported() {
3200 let dir = tempfile::tempdir().expect("create temp dir");
3201 write_at(dir.path(), ".claude/settings.json", "{}\n");
3202 write_at(dir.path(), ".claude/README.md", "# notes\n");
3203
3204 let config = make_config(dir.path().to_path_buf(), false);
3205 let _ = discover_files(&config);
3206
3207 assert!(dotdir_diagnostics(dir.path()).is_empty());
3208 }
3209
3210 #[test]
3211 fn dotdir_source_at_scan_depth_limit_is_reported() {
3212 let dir = tempfile::tempdir().expect("create temp dir");
3213 write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
3214
3215 let config = make_config(dir.path().to_path_buf(), false);
3216 let _ = discover_files(&config);
3217
3218 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3219 }
3220
3221 #[test]
3222 fn dotdir_source_below_scan_depth_limit_is_not_reported() {
3223 let dir = tempfile::tempdir().expect("create temp dir");
3224 write_at(
3225 dir.path(),
3226 ".claude/a/b/c/deeper.ts",
3227 "export const a = 1;\n",
3228 );
3229
3230 let config = make_config(dir.path().to_path_buf(), false);
3231 let _ = discover_files(&config);
3232
3233 assert!(
3234 dotdir_diagnostics(dir.path()).is_empty(),
3235 "the depth cap is real, so widening it stays a deliberate act"
3236 );
3237 }
3238
3239 fn mark_as_git_repo(root: &Path) {
3243 std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
3244 }
3245
3246 #[test]
3247 fn gitignored_dotdir_contents_are_not_reported() {
3248 for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
3252 let dir = tempfile::tempdir().expect("create temp dir");
3253 mark_as_git_repo(dir.path());
3254 write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
3255 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3256
3257 let config = make_config(dir.path().to_path_buf(), false);
3258 let _ = discover_files(&config);
3259
3260 assert!(
3261 dotdir_diagnostics(dir.path()).is_empty(),
3262 "gitignore pattern '{pattern}' excludes the contents, so neither \
3263 advertised remedy would find anything there"
3264 );
3265 }
3266 }
3267
3268 #[test]
3269 fn self_ignoring_dotdir_is_not_reported() {
3270 let dir = tempfile::tempdir().expect("create temp dir");
3271 mark_as_git_repo(dir.path());
3272 write_at(dir.path(), ".toolcache/.gitignore", "*\n");
3273 write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
3274
3275 let config = make_config(dir.path().to_path_buf(), false);
3276 let _ = discover_files(&config);
3277
3278 assert!(
3279 dotdir_diagnostics(dir.path()).is_empty(),
3280 "a cache directory that ignores itself has excluded its own contents"
3281 );
3282 }
3283
3284 #[test]
3285 fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
3286 let dir = tempfile::tempdir().expect("create temp dir");
3287 mark_as_git_repo(dir.path());
3288 write_at(dir.path(), ".gitignore", "dist/\n");
3289 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3290
3291 let config = make_config(dir.path().to_path_buf(), false);
3292 let _ = discover_files(&config);
3293
3294 assert_eq!(
3295 dotdir_diagnostics(dir.path()).len(),
3296 1,
3297 "the gitignore check must not swallow the case the diagnostic exists for"
3298 );
3299 }
3300
3301 #[test]
3302 fn production_run_does_not_report_a_test_only_dotdir() {
3303 let dir = tempfile::tempdir().expect("create temp dir");
3304 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3305 write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
3306
3307 let config = make_config(dir.path().to_path_buf(), true);
3308 let _ = discover_files(&config);
3309
3310 assert!(
3311 dotdir_diagnostics(dir.path()).is_empty(),
3312 "a --production run would analyze none of those files, so the \
3313 --root remedy would return nothing"
3314 );
3315 }
3316
3317 #[test]
3318 fn production_run_still_reports_a_dotdir_with_production_source() {
3319 let dir = tempfile::tempdir().expect("create temp dir");
3320 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3321 write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
3322
3323 let config = make_config(dir.path().to_path_buf(), true);
3324 let _ = discover_files(&config);
3325
3326 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3327 }
3328
3329 #[test]
3330 fn dotdir_with_only_generated_markup_is_not_reported() {
3331 let dir = tempfile::tempdir().expect("create temp dir");
3332 write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
3333 write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
3334 write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
3335
3336 let config = make_config(dir.path().to_path_buf(), false);
3337 let _ = discover_files(&config);
3338
3339 assert!(
3340 dotdir_diagnostics(dir.path()).is_empty(),
3341 "the message claims imports and exports are lost, and these have none"
3342 );
3343 }
3344
3345 #[test]
3346 fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
3347 let dir = tempfile::tempdir().expect("create temp dir");
3348 write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
3349 write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
3350 write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
3351 write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
3352 write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
3353
3354 let config = make_config(dir.path().to_path_buf(), false);
3355 let _ = discover_files(&config);
3356
3357 assert!(
3358 dotdir_diagnostics(dir.path()).is_empty(),
3359 "generated output and foreign VCS metadata are not first-party source"
3360 );
3361 }
3362
3363 #[test]
3364 fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
3365 let dir = tempfile::tempdir().expect("create temp dir");
3366 for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
3369 write_at(
3370 dir.path(),
3371 &format!("packages/pkg{index:03}/.turbo/blob.js"),
3372 "export const a = 1;\n",
3373 );
3374 }
3375 write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
3376
3377 let config = make_config(dir.path().to_path_buf(), false);
3378 let _ = discover_files(&config);
3379
3380 let reported = dotdir_diagnostics(dir.path());
3381 assert_eq!(reported.len(), 1, "{reported:?}");
3382 assert!(reported[0].path.ends_with(".tooling"));
3383 }
3384
3385 #[test]
3386 fn one_pathological_dotdir_cannot_starve_the_rest() {
3387 let dir = tempfile::tempdir().expect("create temp dir");
3388 for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
3390 write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
3391 }
3392 write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
3393
3394 let config = make_config(dir.path().to_path_buf(), false);
3395 let _ = discover_files(&config);
3396
3397 let reported = dotdir_diagnostics(dir.path());
3398 assert_eq!(reported.len(), 1, "{reported:?}");
3399 assert!(reported[0].path.ends_with(".zzz-real"));
3400 }
3401
3402 #[test]
3403 fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
3404 let dir = tempfile::tempdir().expect("create temp dir");
3405 write_at(
3406 dir.path(),
3407 ".claude/hooks/probe.mjs",
3408 "export const a = 1;\n",
3409 );
3410
3411 let config = make_config(dir.path().to_path_buf(), false);
3412 let _ = discover_files(&config);
3413 let _ = discover_files(&config);
3414
3415 assert_eq!(
3416 dotdir_diagnostics(dir.path()).len(),
3417 1,
3418 "each walk replaces its own root's source-discovery set"
3419 );
3420 }
3421
3422 #[test]
3423 fn skips_large_one_line_js_as_minified_generated_output() {
3424 let dir = tempfile::tempdir().expect("create temp dir");
3425 let src = dir.path().join("src");
3426 std::fs::create_dir_all(&src).unwrap();
3427 let asset = src.join("index-abc123.js");
3428 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3429
3430 let config = make_config(dir.path().to_path_buf(), false);
3431 let files = discover_files(&config);
3432 let names = file_names(&files, dir.path());
3433
3434 assert!(
3435 !names.contains(&"src/index-abc123.js".to_string()),
3436 "large one-line JS assets should be skipped before parsing"
3437 );
3438
3439 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3440 assert!(
3441 diagnostics.iter().any(|diag| {
3442 diag.path.ends_with("src/index-abc123.js")
3443 && matches!(
3444 diag.kind,
3445 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
3446 )
3447 }),
3448 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
3449 );
3450 }
3451
3452 #[test]
3453 fn unlimited_size_keeps_large_one_line_js() {
3454 let dir = tempfile::tempdir().expect("create temp dir");
3455 let src = dir.path().join("src");
3456 std::fs::create_dir_all(&src).unwrap();
3457 let asset = src.join("index-abc123.js");
3458 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3459
3460 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3461 let files = discover_files(&config);
3462 let names = file_names(&files, dir.path());
3463
3464 assert!(
3465 names.contains(&"src/index-abc123.js".to_string()),
3466 "--max-file-size 0 should opt out of generated JS skipping"
3467 );
3468 }
3469
3470 #[test]
3471 fn keeps_large_multiline_js() {
3472 let dir = tempfile::tempdir().expect("create temp dir");
3473 let src = dir.path().join("src");
3474 std::fs::create_dir_all(&src).unwrap();
3475 let asset = src.join("handwritten.js");
3476 let mut content = String::new();
3477 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3478 content.push_str("export const value = 1;\n");
3479 }
3480 std::fs::write(&asset, content).unwrap();
3481
3482 let config = make_config(dir.path().to_path_buf(), false);
3483 let files = discover_files(&config);
3484 let names = file_names(&files, dir.path());
3485
3486 assert!(
3487 names.contains(&"src/handwritten.js".to_string()),
3488 "large multiline JS should not be treated as a generated minified asset"
3489 );
3490 }
3491 }
3492}