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