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