1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use fallow_config::{ResolvedConfig, WorkspaceDiagnostic, WorkspaceDiagnosticKind};
6use fallow_types::discover::{DiscoveredFile, FileId};
7use ignore::WalkBuilder;
8use rustc_hash::FxHashSet;
9
10use super::{ALLOWED_HIDDEN_DIRS, SCRIPT_SCOPE_DENYLIST};
11
12fn should_emit_note_once(key: String) -> bool {
18 static EMITTED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
19 EMITTED
20 .get_or_init(|| Mutex::new(FxHashSet::default()))
21 .lock()
22 .map_or(true, |mut set| set.insert(key))
23}
24
25type SizedFile = (PathBuf, u64);
28
29type SkippedDotdirSink = Arc<Mutex<Vec<PathBuf>>>;
37
38const NOTE_EXAMPLE_CAP: usize = 5;
42
43const DOTDIR_SCAN_MAX_DEPTH: usize = 2;
48
49const DOTDIR_SCAN_MAX_ENTRIES: usize = 256;
55
56const DOTDIR_SCAN_TOTAL_ENTRIES: usize = 1024;
67
68const DOTDIR_SCAN_MAX_CANDIDATES: usize = 64;
75
76const DOTDIR_MODULE_EXTENSIONS: &[&str] = &[
83 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
84 "mdx",
85];
86
87const LARGE_SET_THRESHOLD: usize = 20_000;
91
92const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
97
98const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
102
103const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
107
108const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
110
111const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
114
115fn is_declaration_file(path: &Path) -> bool {
120 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
121 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
122}
123
124fn is_plain_js_file(path: &Path) -> bool {
125 matches!(
126 path.extension().and_then(|ext| ext.to_str()),
127 Some("js" | "mjs" | "cjs")
128 )
129}
130
131fn has_minified_line_shape(path: &Path) -> bool {
132 use std::io::Read;
133
134 let Ok(mut file) = std::fs::File::open(path) else {
135 return false;
136 };
137 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
138 let Ok(len) = file.read(&mut sample) else {
139 return false;
140 };
141 sample.truncate(len);
142 if sample.is_empty() {
143 return false;
144 }
145
146 let mut current_line = 0usize;
147 for byte in sample {
148 if byte == b'\n' || byte == b'\r' {
149 current_line = 0;
150 continue;
151 }
152 current_line += 1;
153 if current_line >= MINIFIED_LONG_LINE_BYTES {
154 return true;
155 }
156 }
157 false
158}
159
160fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
161 size_bytes >= MINIFIED_FILE_SKIP_BYTES
162 && is_plain_js_file(path)
163 && !is_declaration_file(path)
164 && has_minified_line_shape(path)
165}
166
167fn format_size_mb(bytes: u64) -> String {
169 #[expect(
170 clippy::cast_precision_loss,
171 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
172 )]
173 let mb = bytes as f64 / (1024.0 * 1024.0);
174 format!("{mb:.1} MB")
175}
176
177fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
180 let shown: Vec<String> = examples
181 .iter()
182 .take(NOTE_EXAMPLE_CAP)
183 .map(|(path, size)| {
184 let display = display_relative_path(root, path);
185 format!("{display} ({})", format_size_mb(*size))
186 })
187 .collect();
188 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
189 if remaining > 0 {
190 format!("{}, and {remaining} more", shown.join(", "))
191 } else {
192 shown.join(", ")
193 }
194}
195
196fn partition_by_size(
199 raw: Vec<SizedFile>,
200 max_file_size_bytes: Option<u64>,
201) -> (Vec<SizedFile>, Vec<SizedFile>) {
202 let Some(limit) = max_file_size_bytes else {
203 return (raw, Vec::new());
204 };
205 raw.into_iter()
206 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
207}
208
209fn partition_minified_generated_js(
212 raw: Vec<SizedFile>,
213 max_file_size_bytes: Option<u64>,
214) -> (Vec<SizedFile>, Vec<SizedFile>) {
215 if max_file_size_bytes.is_none() {
216 return (raw, Vec::new());
217 }
218 raw.into_iter()
219 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
220}
221
222fn report_missing_node_modules(config: &ResolvedConfig) -> Vec<WorkspaceDiagnostic> {
233 let Some(diagnostic) = fallow_config::missing_node_modules_diagnostic(&config.root) else {
234 return Vec::new();
235 };
236 if !config.quiet
237 && should_emit_note_once(format!("node-modules-missing::{}", config.root.display()))
238 {
239 tracing::warn!("fallow: {}", diagnostic.message);
240 }
241 vec![diagnostic]
242}
243
244fn report_skipped_large_files(
245 config: &ResolvedConfig,
246 skipped: &[SizedFile],
247) -> Vec<WorkspaceDiagnostic> {
248 if skipped.is_empty() {
249 return Vec::new();
250 }
251 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
252 .iter()
253 .map(|(path, size_bytes)| {
254 WorkspaceDiagnostic::new(
255 &config.root,
256 path.clone(),
257 WorkspaceDiagnosticKind::SkippedLargeFile {
258 size_bytes: *size_bytes,
259 },
260 )
261 })
262 .collect();
263
264 let mut sorted: Vec<SizedFile> = skipped.to_vec();
265 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
266 let count = skipped.len();
267 if !config.quiet
268 && should_emit_note_once(format!(
269 "skip::{}::{count}::{}",
270 config.root.display(),
271 sorted.first().map_or(0, |f| f.1)
272 ))
273 {
274 let examples = summarize_examples(&config.root, &sorted);
275 let noun = if count == 1 { "file" } else { "files" };
276 tracing::warn!(
277 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
278 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
279 );
280 }
281 diagnostics
282}
283
284fn report_skipped_minified_files(
287 config: &ResolvedConfig,
288 skipped: &[SizedFile],
289) -> Vec<WorkspaceDiagnostic> {
290 if skipped.is_empty() {
291 return Vec::new();
292 }
293 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
294 .iter()
295 .map(|(path, size_bytes)| {
296 WorkspaceDiagnostic::new(
297 &config.root,
298 path.clone(),
299 WorkspaceDiagnosticKind::SkippedMinifiedFile {
300 size_bytes: *size_bytes,
301 },
302 )
303 })
304 .collect();
305
306 let mut sorted: Vec<SizedFile> = skipped.to_vec();
307 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
308 let count = skipped.len();
309 if !config.quiet
310 && should_emit_note_once(format!(
311 "minified::{}::{count}::{}",
312 config.root.display(),
313 sorted.first().map_or(0, |f| f.1)
314 ))
315 {
316 let examples = summarize_examples(&config.root, &sorted);
317 let noun = if count == 1 { "file" } else { "files" };
318 let pronoun = if count == 1 { "it" } else { "them" };
319 tracing::warn!(
320 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
321 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
322 );
323 }
324 diagnostics
325}
326
327fn summarize_paths(root: &Path, examples: &[&PathBuf]) -> String {
331 let shown: Vec<String> = examples
332 .iter()
333 .take(NOTE_EXAMPLE_CAP)
334 .map(|path| display_relative_path(root, path))
335 .collect();
336 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
337 if remaining > 0 {
338 format!("{}, and {remaining} more", shown.join(", "))
339 } else {
340 shown.join(", ")
341 }
342}
343
344fn summarize_paths_open_ended(root: &Path, examples: &[&PathBuf]) -> String {
347 let shown: Vec<String> = examples
348 .iter()
349 .take(NOTE_EXAMPLE_CAP)
350 .map(|path| display_relative_path(root, path))
351 .collect();
352 if examples.len() > NOTE_EXAMPLE_CAP {
353 format!("{}, and more", shown.join(", "))
354 } else {
355 shown.join(", ")
356 }
357}
358
359fn display_relative_path(root: &Path, path: &Path) -> String {
362 path.strip_prefix(root)
363 .unwrap_or(path)
364 .display()
365 .to_string()
366 .replace('\\', "/")
367}
368
369fn is_excluded_from_analysis(
387 config: &ResolvedConfig,
388 production_excludes: Option<&globset::GlobSet>,
389 path: &Path,
390) -> bool {
391 let relative = path.strip_prefix(&config.root).unwrap_or(path);
392 config.ignore_patterns.is_match(relative)
393 || production_excludes.is_some_and(|excludes| excludes.is_match(relative))
394}
395
396fn has_module_extension(path: &Path) -> bool {
400 path.extension()
401 .and_then(OsStr::to_str)
402 .is_some_and(|ext| DOTDIR_MODULE_EXTENSIONS.contains(&ext))
403}
404
405fn scan_for_reportable_source(
428 config: &ResolvedConfig,
429 production_excludes: Option<&globset::GlobSet>,
430 dir: &Path,
431 budget: &mut usize,
432) -> bool {
433 let mut builder = WalkBuilder::new(dir);
434 builder
435 .hidden(false)
436 .git_ignore(true)
437 .git_global(true)
438 .git_exclude(true)
439 .follow_links(false)
440 .max_depth(Some(DOTDIR_SCAN_MAX_DEPTH + 1))
441 .threads(1);
442 builder.filter_entry(|entry| {
443 if entry.depth() == 0 || !entry.file_type().is_some_and(|ft| ft.is_dir()) {
444 return true;
445 }
446 entry
447 .file_name()
448 .to_str()
449 .is_none_or(|name| !SCRIPT_SCOPE_DENYLIST.contains(&name) && name != "node_modules")
450 });
451
452 let mut per_dotdir = DOTDIR_SCAN_MAX_ENTRIES;
453 for entry in builder.build() {
454 if per_dotdir == 0 || *budget == 0 {
455 return false;
456 }
457 per_dotdir -= 1;
458 *budget -= 1;
459 let Ok(entry) = entry else {
460 continue;
461 };
462 #[expect(
465 clippy::filetype_is_file,
466 reason = "regular files only is the point: !is_dir() would readmit fifos and sockets"
467 )]
468 let is_regular_file = entry
469 .file_type()
470 .is_some_and(|file_type| file_type.is_file());
471 if !is_regular_file {
472 continue;
473 }
474 if has_module_extension(entry.path())
475 && !is_excluded_from_analysis(config, production_excludes, entry.path())
476 {
477 return true;
478 }
479 }
480 false
481}
482
483const DOTDIR_NOISE_PATH_COMPONENTS: &[&str] = &[
487 "__fixtures__",
488 "__mocks__",
489 "__tests__",
490 "e2e",
491 "fixture",
492 "fixtures",
493 "playground",
494 "playgrounds",
495 "spec",
496 "test",
497 "tests",
498];
499
500fn dotdir_is_scan_candidate(config: &ResolvedConfig, dir: &Path) -> bool {
507 let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
508 return false;
509 };
510 if SCRIPT_SCOPE_DENYLIST.contains(&name) {
511 return false;
512 }
513 let relative = dir.strip_prefix(&config.root).unwrap_or(dir);
514 if relative
518 .components()
519 .any(|component| component.as_os_str() == OsStr::new("node_modules"))
520 {
521 return false;
522 }
523 !relative.components().any(|component| {
532 DOTDIR_NOISE_PATH_COMPONENTS.contains(&component.as_os_str().to_string_lossy().as_ref())
533 })
534}
535
536fn report_skipped_source_dotdirs(
542 config: &ResolvedConfig,
543 production_excludes: Option<&globset::GlobSet>,
544 candidates: &[PathBuf],
545) -> Vec<WorkspaceDiagnostic> {
546 if candidates.is_empty() {
547 return Vec::new();
548 }
549 let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
552 let scannable: Vec<&PathBuf> = candidates
553 .iter()
554 .filter(|dir| dotdir_is_scan_candidate(config, dir))
555 .collect();
556 let reportable: Vec<&PathBuf> = scannable
557 .iter()
558 .copied()
559 .take(DOTDIR_SCAN_MAX_CANDIDATES)
560 .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
561 .collect();
562 let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
565 if reportable.is_empty() {
566 return Vec::new();
567 }
568
569 let diagnostics: Vec<WorkspaceDiagnostic> = reportable
570 .iter()
571 .map(|dir| {
572 WorkspaceDiagnostic::new(
573 &config.root,
574 (*dir).clone(),
575 WorkspaceDiagnosticKind::SkippedSourceDotdir,
576 )
577 })
578 .collect();
579
580 let count = reportable.len();
581 if !config.quiet
582 && should_emit_note_once(format!(
583 "dotdir::{}::{count}::{}",
584 config.root.display(),
585 reportable
586 .first()
587 .map_or_else(String::new, |dir| display_relative_path(&config.root, dir))
588 ))
589 {
590 tracing::warn!(
591 "{}",
592 build_skipped_dotdirs_note(&config.root, &reportable, truncated)
593 );
594 }
595 diagnostics
596}
597
598fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
608 let count = reportable.len();
609 let examples = if truncated {
613 summarize_paths_open_ended(root, reportable)
614 } else {
615 summarize_paths(root, reportable)
616 };
617 let noun = if count == 1 {
618 "directory"
619 } else {
620 "directories"
621 };
622 let verb = if count == 1 { "contains" } else { "contain" };
623 let at_least = if truncated { "at least " } else { "" };
624 let (target, pronoun) = match reportable {
625 [only] => (display_relative_path(root, only), "it"),
626 _ => ("<dir>".to_owned(), "one"),
627 };
628 format!(
629 "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
630 Hidden directories are not traversed and no config field adds one: analyze {pronoun} \
631 with fallow --root {target} if it holds first-party source, or add '{target}/**' to \
632 ignorePatterns to silence this."
633 )
634}
635
636fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
641 if files.is_empty() {
642 return None;
643 }
644 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
645 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
646 return None;
647 }
648 let count = files.len();
649 let noun = if count == 1 { "file" } else { "files" };
650 let mut by_size: Vec<SizedFile> = files
651 .iter()
652 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
653 .map(|f| (f.path.clone(), f.size_bytes))
654 .collect();
655 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
656 if by_size.is_empty() {
657 return Some(format!(
660 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
661 exclude large generated files via ignorePatterns or --max-file-size."
662 ));
663 }
664 let examples = summarize_examples(root, &by_size);
665 Some(format!(
666 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
667 exclude large generated files via ignorePatterns or --max-file-size."
668 ))
669}
670
671fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
676 if config.quiet {
677 return;
678 }
679 if let Some(message) = build_largest_files_note(&config.root, files)
680 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
681 {
682 tracing::warn!("{message}");
683 }
684}
685
686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688pub enum HiddenDirMatch {
689 AnyDepth,
695 ExactPath,
702}
703
704#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct HiddenDirScope {
707 root: PathBuf,
708 dirs: Vec<String>,
709 match_mode: HiddenDirMatch,
710}
711
712impl HiddenDirScope {
713 #[must_use]
720 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
721 Self {
722 root,
723 dirs,
724 match_mode: HiddenDirMatch::AnyDepth,
725 }
726 }
727
728 #[must_use]
731 pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
732 Self {
733 root,
734 dirs,
735 match_mode: HiddenDirMatch::ExactPath,
736 }
737 }
738
739 #[must_use]
744 pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
745 Self {
746 root,
747 dirs,
748 match_mode,
749 }
750 }
751
752 #[must_use]
753 pub fn root(&self) -> &Path {
754 &self.root
755 }
756
757 #[must_use]
758 pub fn dirs(&self) -> &[String] {
759 &self.dirs
760 }
761
762 #[must_use]
763 pub fn match_mode(&self) -> HiddenDirMatch {
764 self.match_mode
765 }
766
767 fn allows(&self, path: &Path, name: &OsStr) -> bool {
768 match self.match_mode {
769 HiddenDirMatch::AnyDepth => {
770 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
771 }
772 HiddenDirMatch::ExactPath => {
773 let Ok(relative) = path.strip_prefix(&self.root) else {
776 return false;
777 };
778 self.dirs.iter().any(|dir| Path::new(dir) == relative)
779 }
780 }
781 }
782}
783
784struct FileVisitor<'a> {
791 root: &'a Path,
792 canonical_root: Option<&'a Path>,
793 ignore_patterns: &'a globset::GlobSet,
794 production_excludes: &'a Option<globset::GlobSet>,
795 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
796 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
797 local: Vec<(std::path::PathBuf, u64)>,
798 config_local: Vec<std::path::PathBuf>,
799}
800
801impl ignore::ParallelVisitor for FileVisitor<'_> {
802 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
803 let Ok(entry) = result else {
804 return ignore::WalkState::Continue;
805 };
806 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
807 return ignore::WalkState::Continue;
808 }
809 let relative = entry
810 .path()
811 .strip_prefix(self.root)
812 .unwrap_or_else(|_| entry.path());
813 if self.ignore_patterns.is_match(relative) {
814 return ignore::WalkState::Continue;
815 }
816 if self
817 .production_excludes
818 .as_ref()
819 .is_some_and(|excludes| excludes.is_match(relative))
820 {
821 return ignore::WalkState::Continue;
822 }
823 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
824 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
825 tracing::debug!(
826 path = %entry.path().display(),
827 "skipping source symlink with a broken, non-file, or outside-root target"
828 );
829 return ignore::WalkState::Continue;
830 };
831 Some(size)
832 } else {
833 None
834 };
835 if has_source_extension(entry.path()) {
836 let size_bytes =
837 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
838 self.local.push((entry.into_path(), size_bytes));
839 } else if self.config_shared.is_some() {
840 self.config_local.push(entry.into_path());
843 }
844 ignore::WalkState::Continue
845 }
846}
847
848fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
849 let root = canonical_root?;
850 let target = path.canonicalize().ok()?;
851 if !target.starts_with(root) {
852 return None;
853 }
854 let metadata = target.metadata().ok()?;
855 metadata.is_file().then_some(metadata.len())
856}
857
858impl Drop for FileVisitor<'_> {
859 #[expect(
860 clippy::expect_used,
861 reason = "poisoned walk collector lock means worker state is unrecoverable"
862 )]
863 fn drop(&mut self) {
864 if !self.local.is_empty() {
865 self.shared
866 .lock()
867 .expect("walk collector lock poisoned")
868 .append(&mut self.local);
869 }
870 if let Some(config_shared) = self.config_shared
871 && !self.config_local.is_empty()
872 {
873 config_shared
874 .lock()
875 .expect("walk config collector lock poisoned")
876 .append(&mut self.config_local);
877 }
878 }
879}
880
881struct FileVisitorBuilder<'a> {
883 root: &'a Path,
884 canonical_root: Option<&'a Path>,
885 ignore_patterns: &'a globset::GlobSet,
886 production_excludes: &'a Option<globset::GlobSet>,
887 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
888 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
889}
890
891impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
892 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
893 Box::new(FileVisitor {
894 root: self.root,
895 canonical_root: self.canonical_root,
896 ignore_patterns: self.ignore_patterns,
897 production_excludes: self.production_excludes,
898 shared: self.shared,
899 config_shared: self.config_shared,
900 local: Vec::new(),
901 config_local: Vec::new(),
902 })
903 }
904}
905
906pub const SOURCE_EXTENSIONS: &[&str] = &[
908 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
909 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
910];
911
912pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
914 "**/*.test.*",
915 "**/*.spec.*",
916 "**/*.e2e.*",
917 "**/*.e2e-spec.*",
918 "**/*.bench.*",
919 "**/*.fixture.*",
920 "**/*.stories.*",
921 "**/*.story.*",
922 "**/__tests__/**",
923 "**/__mocks__/**",
924 "**/__snapshots__/**",
925 "**/__fixtures__/**",
926 "**/test/**",
927 "**/tests/**",
928 "*.config.*",
929 "**/.*.js",
930 "**/.*.ts",
931 "**/.*.mjs",
932 "**/.*.cjs",
933];
934
935pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
937 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
938}
939
940fn is_allowed_scoped_hidden_dir(
941 name: &OsStr,
942 path: &Path,
943 additional_hidden_dir_scopes: &[HiddenDirScope],
944) -> bool {
945 additional_hidden_dir_scopes
946 .iter()
947 .any(|scope| scope.allows(path, name))
948}
949
950const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
955
956fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
957 YARN_PNP_GENERATED_FILES
958 .iter()
959 .any(|&f| OsStr::new(f) == name)
960}
961
962fn is_allowed_hidden_with_scopes(
968 entry: &ignore::DirEntry,
969 additional_hidden_dir_scopes: &[HiddenDirScope],
970) -> bool {
971 let name = entry.file_name();
972 let name_str = name.to_string_lossy();
973
974 if !name_str.starts_with('.') {
975 return true;
976 }
977
978 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
979 return !is_yarn_pnp_generated_file(name);
980 }
981
982 is_allowed_hidden_dir(name)
983 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
984}
985
986pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
992 discover_files_with_additional_hidden_dirs(config, &[])
993}
994
995fn config_candidate_basename_globs() -> &'static [String] {
1007 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
1008 GLOBS.get_or_init(|| {
1009 let mut set: FxHashSet<String> = FxHashSet::default();
1010 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1011 for pattern in plugin.config_patterns() {
1012 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1013 set.insert(basename.to_string());
1014 }
1015 }
1016 let mut globs: Vec<String> = set.into_iter().collect();
1017 globs.sort_unstable();
1018 globs
1019 })
1020}
1021
1022fn has_source_extension(path: &Path) -> bool {
1025 path.extension()
1026 .and_then(OsStr::to_str)
1027 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1028}
1029
1030#[expect(
1034 clippy::expect_used,
1035 reason = "source file globs are hard-coded compile-time constants"
1036)]
1037fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1038 static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1039 static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1040
1041 let cache = if capture_config {
1042 &SOURCE_AND_CONFIG_TYPES
1043 } else {
1044 &SOURCE_TYPES
1045 };
1046 cache
1047 .get_or_init(|| {
1048 let mut types_builder = ignore::types::TypesBuilder::new();
1049 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1050 types_builder
1051 .add("source", &source_glob)
1052 .expect("valid glob");
1053 types_builder.select("source");
1054 if capture_config {
1055 for glob in config_candidate_basename_globs() {
1056 let _ = types_builder.add("config", glob);
1060 }
1061 types_builder.select("config");
1062 }
1063 types_builder.build().expect("valid types")
1064 })
1065 .clone()
1066}
1067
1068fn build_source_walk_builder(
1072 config: &ResolvedConfig,
1073 additional_hidden_dir_scopes: &[HiddenDirScope],
1074 capture_config: bool,
1075 skipped_dotdirs: &SkippedDotdirSink,
1076) -> WalkBuilder {
1077 let mut walk_builder = WalkBuilder::new(&config.root);
1078 walk_builder
1079 .hidden(false)
1080 .git_ignore(true)
1081 .git_global(true)
1082 .git_exclude(true)
1083 .types(build_walk_types(capture_config))
1084 .threads(config.threads);
1085 let scopes = additional_hidden_dir_scopes.to_vec();
1090 let sink = Arc::clone(skipped_dotdirs);
1091 walk_builder.filter_entry(move |entry| {
1092 if is_allowed_hidden_with_scopes(entry, &scopes) {
1093 return true;
1094 }
1095 if entry.file_type().is_some_and(|ft| ft.is_dir())
1096 && let Ok(mut collected) = sink.lock()
1097 {
1098 collected.push(entry.path().to_path_buf());
1099 }
1100 false
1101 });
1102 walk_builder
1103}
1104
1105fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1107 if !config.production {
1108 return None;
1109 }
1110 let mut builder = globset::GlobSetBuilder::new();
1111 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1112 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1113 .literal_separator(true)
1114 .build()
1115 {
1116 builder.add(glob);
1117 }
1118 }
1119 builder.build().ok()
1120}
1121
1122pub fn discover_files_with_additional_hidden_dirs(
1128 config: &ResolvedConfig,
1129 additional_hidden_dir_scopes: &[HiddenDirScope],
1130) -> Vec<DiscoveredFile> {
1131 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1132}
1133
1134pub fn discover_files_and_config_candidates(
1150 config: &ResolvedConfig,
1151 additional_hidden_dir_scopes: &[HiddenDirScope],
1152) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1153 let discovered =
1154 discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1155 (discovered.files, discovered.config_candidates)
1156}
1157
1158pub struct DiscoveredSources {
1167 pub files: Vec<DiscoveredFile>,
1169 pub config_candidates: Vec<PathBuf>,
1171 pub diagnostics: Vec<WorkspaceDiagnostic>,
1174}
1175
1176#[expect(
1184 clippy::cast_possible_truncation,
1185 reason = "file count is bounded by project size, well under u32::MAX"
1186)]
1187#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1188pub fn discover_files_config_candidates_and_diagnostics(
1189 config: &ResolvedConfig,
1190 additional_hidden_dir_scopes: &[HiddenDirScope],
1191) -> DiscoveredSources {
1192 let _span = tracing::info_span!("discover_files").entered();
1193
1194 let capture_config = !config.production;
1195 let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1196 let walk_builder = build_source_walk_builder(
1197 config,
1198 additional_hidden_dir_scopes,
1199 capture_config,
1200 &skipped_dotdirs,
1201 );
1202 let production_excludes = build_production_excludes(config);
1203 let canonical_root = config.root.canonicalize().ok();
1204
1205 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1206 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1207 let mut visitor_builder = FileVisitorBuilder {
1208 root: &config.root,
1209 canonical_root: canonical_root.as_deref(),
1210 ignore_patterns: &config.ignore_patterns,
1211 production_excludes: &production_excludes,
1212 shared: &collected,
1213 config_shared: capture_config.then_some(&config_collected),
1214 };
1215 walk_builder.build_parallel().visit(&mut visitor_builder);
1216
1217 let mut raw = collected
1218 .into_inner()
1219 .expect("walk collector lock poisoned");
1220 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1228
1229 let mut config_candidates = config_collected
1230 .into_inner()
1231 .expect("walk config collector lock poisoned");
1232 config_candidates.sort_unstable();
1233
1234 let mut dotdir_candidates = skipped_dotdirs
1238 .lock()
1239 .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1240 dotdir_candidates.sort_unstable();
1241 dotdir_candidates.dedup();
1242
1243 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1244 let (kept, skipped_minified) =
1245 partition_minified_generated_js(kept, config.max_file_size_bytes);
1246 let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1251 &config.root,
1252 report_skipped_large_files(config, &skipped)
1253 .into_iter()
1254 .chain(report_skipped_minified_files(config, &skipped_minified))
1255 .chain(report_skipped_source_dotdirs(
1256 config,
1257 production_excludes.as_ref(),
1258 &dotdir_candidates,
1259 ))
1260 .chain(report_missing_node_modules(config))
1261 .collect(),
1262 );
1263
1264 let files: Vec<DiscoveredFile> = kept
1265 .into_iter()
1266 .enumerate()
1267 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1268 id: FileId(idx as u32),
1269 path,
1270 size_bytes,
1271 })
1272 .collect();
1273
1274 note_largest_files(config, &files);
1275
1276 DiscoveredSources {
1277 files,
1278 config_candidates,
1279 diagnostics,
1280 }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use std::ffi::OsStr;
1286 use std::path::MAIN_SEPARATOR;
1287
1288 use super::*;
1289
1290 #[test]
1291 fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1292 let root = Path::new("/repo");
1293 let only = PathBuf::from("/repo/.tooling");
1294 let note = build_skipped_dotdirs_note(root, &[&only], false);
1295 assert!(note.contains("skipped 1 hidden directory that contains source files"));
1296 assert!(note.contains("analyze it with fallow --root .tooling"));
1297 assert!(note.contains("add '.tooling/**' to"));
1298 assert!(
1299 !note.contains("<dir>"),
1300 "the single-directory remedy must be copy-pasteable: {note}"
1301 );
1302 }
1303
1304 #[test]
1305 fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1306 let root = Path::new("/repo");
1307 let a = PathBuf::from("/repo/.a");
1308 let b = PathBuf::from("/repo/.b");
1309 let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1310 assert!(note.contains("skipped 2 hidden directories that contain source files"));
1311 assert!(note.contains("analyze one with fallow --root <dir>"));
1312 }
1313
1314 #[test]
1315 fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1316 let root = Path::new("/repo");
1317 let owned: Vec<PathBuf> = (0..8)
1318 .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1319 .collect();
1320 let reportable: Vec<&PathBuf> = owned.iter().collect();
1321
1322 let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1323 assert!(bounded.contains("skipped at least 8 hidden directories"));
1324 assert!(
1325 bounded.contains("and more") && !bounded.contains("and 3 more"),
1326 "an inexact total must not carry an exact remainder: {bounded}"
1327 );
1328
1329 let complete = build_skipped_dotdirs_note(root, &reportable, false);
1330 assert!(!complete.contains("at least"));
1331 assert!(complete.contains("and 3 more"));
1332 }
1333
1334 #[test]
1335 fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1336 let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1337 sorted.sort_unstable();
1338 assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1339 for component in DOTDIR_NOISE_PATH_COMPONENTS {
1340 assert!(!component.starts_with('.'), "'{component}' is not hidden");
1341 assert_eq!(
1342 *component,
1343 component.to_lowercase(),
1344 "'{component}' is matched verbatim against a path component"
1345 );
1346 }
1347 }
1348
1349 #[test]
1350 fn script_scope_denylist_stays_disjoint_and_sorted() {
1351 let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1352 sorted.sort_unstable();
1353 assert_eq!(
1354 sorted, SCRIPT_SCOPE_DENYLIST,
1355 "keep the list sorted so additions stay reviewable"
1356 );
1357 for dir in SCRIPT_SCOPE_DENYLIST {
1358 assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1359 assert!(
1360 !ALLOWED_HIDDEN_DIRS.contains(dir),
1361 "'{dir}' is traversed, so it can never be a skipped candidate"
1362 );
1363 }
1364 }
1365
1366 #[test]
1367 fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1368 for ext in DOTDIR_MODULE_EXTENSIONS {
1369 assert!(
1370 SOURCE_EXTENSIONS.contains(ext),
1371 "'{ext}' is not discovered as source, so it cannot be a trigger"
1372 );
1373 }
1374 for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1375 assert!(
1376 !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1377 "'{ext}' carries no imports or exports for the message to be about"
1378 );
1379 }
1380 }
1381
1382 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1385 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1386 raw.into_iter()
1387 .enumerate()
1388 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1389 id: FileId(idx as u32),
1390 path,
1391 size_bytes,
1392 })
1393 .collect()
1394 }
1395
1396 #[test]
1402 fn file_id_assignment_is_deterministic_for_identical_file_set() {
1403 let paths = [
1404 "/project/src/z.ts",
1405 "/project/src/a.ts",
1406 "/project/src/components/Button.tsx",
1407 "/project/src/components/Button.module.css",
1408 "/project/index.ts",
1409 ];
1410
1411 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1413 .iter()
1414 .map(|p| (std::path::PathBuf::from(p), 10))
1415 .collect();
1416 let mut walk_two = walk_one.clone();
1417 walk_two.reverse();
1418
1419 let files_one = assign_file_ids(walk_one);
1420 let files_two = assign_file_ids(walk_two);
1421
1422 assert_eq!(files_one.len(), files_two.len());
1424 for (a, b) in files_one.iter().zip(files_two.iter()) {
1425 assert_eq!(a.id, b.id);
1426 assert_eq!(a.path, b.path);
1427 }
1428
1429 for (idx, file) in files_one.iter().enumerate() {
1432 assert_eq!(file.id, FileId(idx as u32));
1433 }
1434 assert_eq!(
1435 files_one[0].path,
1436 std::path::PathBuf::from("/project/index.ts")
1437 );
1438 }
1439
1440 #[test]
1441 fn file_id_assignment_recomputes_after_rename_or_delete() {
1442 let before = assign_file_ids(vec![
1443 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1444 (std::path::PathBuf::from("/project/src/b.ts"), 10),
1445 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1446 ]);
1447 let after_delete = assign_file_ids(vec![
1448 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1449 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1450 ]);
1451 let after_rename = assign_file_ids(vec![
1452 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1453 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1454 (std::path::PathBuf::from("/project/src/d.ts"), 10),
1455 ]);
1456
1457 assert_eq!(before[0].id, FileId(0));
1458 assert_eq!(before[1].id, FileId(1));
1459 assert_eq!(before[2].id, FileId(2));
1460 assert_eq!(after_delete[0].id, FileId(0));
1461 assert_eq!(after_delete[1].id, FileId(1));
1462 assert_eq!(
1463 after_delete[1].path,
1464 std::path::PathBuf::from("/project/src/c.ts")
1465 );
1466 assert_eq!(after_rename[0].id, FileId(0));
1467 assert_eq!(after_rename[1].id, FileId(1));
1468 assert_eq!(
1469 after_rename[1].path,
1470 std::path::PathBuf::from("/project/src/c.ts")
1471 );
1472 assert_eq!(after_rename[2].id, FileId(2));
1473 assert_eq!(
1474 after_rename[2].path,
1475 std::path::PathBuf::from("/project/src/d.ts")
1476 );
1477 }
1478
1479 #[test]
1480 fn allowed_hidden_dirs() {
1481 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1482 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1483 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1484 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1485 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1486 }
1487
1488 #[test]
1489 fn disallowed_hidden_dirs() {
1490 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1491 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1492 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1493 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1494 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1495 }
1496
1497 #[test]
1498 fn non_hidden_dirs_not_in_allowlist() {
1499 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1500 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1501 }
1502
1503 #[test]
1504 fn walk_types_match_every_supported_source_extension() {
1505 for capture_config in [false, true] {
1506 let types = build_walk_types(capture_config);
1507 for extension in SOURCE_EXTENSIONS {
1508 let path = format!("packages/ui/src/nested/component.{extension}");
1509 assert!(
1510 types.matched(&path, false).is_whitelist(),
1511 "expected source match for {path} with capture_config={capture_config}"
1512 );
1513 }
1514 }
1515 }
1516
1517 #[test]
1518 fn walk_types_match_typescript_declaration_files() {
1519 let types = build_walk_types(true);
1520 for path in [
1521 "src/env.d.ts",
1522 "packages/app/types/generated.d.mts",
1523 "packages/app/types/compat.d.cts",
1524 ] {
1525 assert!(
1526 types.matched(path, false).is_whitelist(),
1527 "expected declaration source match for {path}"
1528 );
1529 }
1530 }
1531
1532 #[test]
1533 fn walk_types_reject_source_extension_near_misses() {
1534 for capture_config in [false, true] {
1535 let types = build_walk_types(capture_config);
1536 for path in [
1537 "src/component.tsx.bak",
1538 "src/component.tsxmap",
1539 "src/component.TS",
1540 "src/component.gqlx",
1541 "src/component.htm",
1542 "src/component",
1543 "assets/component.png",
1544 ] {
1545 assert!(
1546 types.matched(path, false).is_ignore(),
1547 "expected non-source rejection for {path} with capture_config={capture_config}"
1548 );
1549 }
1550 }
1551 }
1552
1553 #[test]
1554 fn walk_types_keep_config_candidate_selection_separate() {
1555 assert!(
1556 build_walk_types(true)
1557 .matched("packages/app/tsconfig.json", false)
1558 .is_whitelist()
1559 );
1560 assert!(
1561 build_walk_types(false)
1562 .matched("packages/app/tsconfig.json", false)
1563 .is_ignore()
1564 );
1565 }
1566
1567 #[test]
1568 fn source_extensions_include_typescript() {
1569 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
1570 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
1571 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
1572 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
1573 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
1574 }
1575
1576 #[test]
1577 fn source_extensions_include_javascript() {
1578 assert!(SOURCE_EXTENSIONS.contains(&"js"));
1579 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
1580 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
1581 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
1582 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
1583 }
1584
1585 #[test]
1586 fn source_extensions_include_sfc_formats() {
1587 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
1588 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
1589 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
1590 }
1591
1592 #[test]
1593 fn source_extensions_include_styles() {
1594 assert!(SOURCE_EXTENSIONS.contains(&"css"));
1595 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
1596 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
1597 assert!(SOURCE_EXTENSIONS.contains(&"less"));
1598 }
1599
1600 #[test]
1601 fn source_extensions_exclude_non_source() {
1602 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
1603 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
1604 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
1605 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1606 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
1607 }
1608
1609 #[test]
1610 fn source_extensions_include_html() {
1611 assert!(SOURCE_EXTENSIONS.contains(&"html"));
1612 }
1613
1614 #[test]
1615 fn source_extensions_include_graphql_documents() {
1616 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
1617 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
1618 }
1619
1620 fn build_production_glob_set() -> globset::GlobSet {
1621 let mut builder = globset::GlobSetBuilder::new();
1622 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1623 builder.add(
1624 globset::GlobBuilder::new(pattern)
1625 .literal_separator(true)
1626 .build()
1627 .expect("valid glob pattern"),
1628 );
1629 }
1630 builder.build().expect("valid glob set")
1631 }
1632
1633 #[test]
1634 fn production_excludes_test_files() {
1635 let set = build_production_glob_set();
1636 assert!(set.is_match("src/Button.test.ts"));
1637 assert!(set.is_match("src/utils.spec.tsx"));
1638 assert!(set.is_match("src/__tests__/helper.ts"));
1639 assert!(!set.is_match("src/Button.ts"));
1640 assert!(!set.is_match("src/utils.tsx"));
1641 }
1642
1643 #[test]
1644 fn production_excludes_story_files() {
1645 let set = build_production_glob_set();
1646 assert!(set.is_match("src/Button.stories.tsx"));
1647 assert!(set.is_match("src/Card.story.ts"));
1648 assert!(!set.is_match("src/Button.tsx"));
1649 }
1650
1651 #[test]
1652 fn production_excludes_config_files_at_root_only() {
1653 let set = build_production_glob_set();
1654 assert!(set.is_match("vitest.config.ts"));
1655 assert!(set.is_match("jest.config.js"));
1656 assert!(!set.is_match("src/app/app.config.ts"));
1657 assert!(!set.is_match("src/app/app.config.server.ts"));
1658 assert!(!set.is_match("packages/foo/vitest.config.ts"));
1659 assert!(!set.is_match("src/config.ts"));
1660 }
1661
1662 #[test]
1663 fn production_patterns_are_valid_globs() {
1664 let _ = build_production_glob_set();
1665 }
1666
1667 #[test]
1668 fn disallowed_hidden_dirs_idea() {
1669 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1670 }
1671
1672 #[test]
1673 fn source_extensions_include_mdx() {
1674 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1675 }
1676
1677 #[test]
1678 fn source_extensions_exclude_image_and_data_formats() {
1679 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1680 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1681 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1682 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1683 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1684 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1685 }
1686
1687 #[test]
1688 fn is_declaration_file_matches_dts_variants() {
1689 assert!(is_declaration_file(Path::new("env.d.ts")));
1690 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1691 assert!(is_declaration_file(Path::new("mod.d.mts")));
1692 assert!(is_declaration_file(Path::new("compat.d.cts")));
1693 assert!(!is_declaration_file(Path::new("index.ts")));
1694 assert!(!is_declaration_file(Path::new("component.tsx")));
1695 assert!(!is_declaration_file(Path::new("notes.d.txt")));
1696 }
1697
1698 #[test]
1699 fn format_size_mb_renders_one_decimal() {
1700 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1701 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1702 assert_eq!(format_size_mb(0), "0.0 MB");
1703 }
1704
1705 #[test]
1706 fn partition_by_size_no_limit_keeps_all() {
1707 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1708 let (kept, skipped) = partition_by_size(raw, None);
1709 assert_eq!(kept.len(), 2);
1710 assert!(skipped.is_empty());
1711 }
1712
1713 #[test]
1714 fn partition_by_size_skips_strictly_over_limit() {
1715 let raw = vec![
1716 (PathBuf::from("under.ts"), 99),
1717 (PathBuf::from("exact.ts"), 100),
1718 (PathBuf::from("over.ts"), 101),
1719 ];
1720 let (kept, skipped) = partition_by_size(raw, Some(100));
1721 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1722 assert!(kept_has("under.ts"));
1723 assert!(
1724 kept_has("exact.ts"),
1725 "a file exactly at the limit is kept (skip is strictly-greater)"
1726 );
1727 assert_eq!(skipped.len(), 1);
1728 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1729 }
1730
1731 #[test]
1732 fn partition_by_size_exempts_declaration_files() {
1733 let raw = vec![
1734 (PathBuf::from("huge.ts"), 10_000),
1735 (PathBuf::from("auto-imports.d.ts"), 10_000),
1736 ];
1737 let (kept, skipped) = partition_by_size(raw, Some(100));
1738 assert!(
1739 kept.iter()
1740 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1741 "declaration files are exempt from the size skip regardless of size"
1742 );
1743 assert_eq!(skipped.len(), 1);
1744 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1745 }
1746
1747 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1748 DiscoveredFile {
1749 id: FileId(0),
1750 path: PathBuf::from(path),
1751 size_bytes,
1752 }
1753 }
1754
1755 #[test]
1756 fn largest_files_note_below_threshold_is_none() {
1757 let files = [disco("a.ts", 100), disco("b.ts", 200)];
1758 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1759 }
1760
1761 #[test]
1762 fn largest_files_note_single_file_uses_singular() {
1763 let files = [disco("big.ts", 5 * 1024 * 1024)];
1764 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1765 assert!(
1766 note.contains("discovered 1 file;"),
1767 "singular noun on the single-big-file path (issue #1086 regression): {note}"
1768 );
1769 assert!(!note.contains("discovered 1 files"));
1770 assert!(note.contains("big.ts (5.0 MB)"));
1771 }
1772
1773 #[test]
1774 fn largest_files_note_filters_sub_floor_files() {
1775 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1776 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1777 assert!(note.contains("discovered 2 files;"));
1778 assert!(note.contains("big.ts (5.0 MB)"));
1779 assert!(
1780 !note.contains("tiny.ts"),
1781 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1782 );
1783 }
1784
1785 #[test]
1786 fn largest_files_note_large_set_no_big_file_omits_list() {
1787 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1788 .map(|i| disco(&format!("f{i}.ts"), 100))
1789 .collect();
1790 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1791 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1792 assert!(
1793 !note.contains("largest:"),
1794 "no sub-floor `largest:` list when no file clears the floor: {note}"
1795 );
1796 }
1797
1798 mod discover_files_integration {
1799 use std::path::PathBuf;
1800
1801 use fallow_config::{
1802 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1803 RulesConfig,
1804 };
1805
1806 use super::*;
1807
1808 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1810 FallowConfig {
1811 production: production.into(),
1812 ..Default::default()
1813 }
1814 .resolve(root, OutputFormat::Human, 1, true, true, None)
1815 }
1816
1817 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1820 files
1821 .iter()
1822 .map(|f| {
1823 f.path
1824 .strip_prefix(root)
1825 .unwrap_or(&f.path)
1826 .to_string_lossy()
1827 .replace('\\', "/")
1828 })
1829 .collect()
1830 }
1831
1832 #[cfg(unix)]
1833 fn symlink_file(target: &Path, link: &Path) {
1834 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1835 }
1836
1837 #[cfg(windows)]
1838 fn symlink_file(target: &Path, link: &Path) {
1839 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1840 }
1841
1842 #[cfg(unix)]
1843 fn symlink_dir(target: &Path, link: &Path) {
1844 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1845 }
1846
1847 #[cfg(windows)]
1848 fn symlink_dir(target: &Path, link: &Path) {
1849 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1850 }
1851
1852 #[test]
1853 fn source_symlinks_must_target_regular_files_inside_root() {
1854 let dir = tempfile::tempdir().expect("create project");
1855 let outside = tempfile::tempdir().expect("create outside dir");
1856 let src = dir.path().join("src");
1857 std::fs::create_dir_all(&src).unwrap();
1858 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1859 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1860 std::fs::write(
1861 outside.path().join("outside-target.ts"),
1862 "export const outside = 1;",
1863 )
1864 .unwrap();
1865 std::fs::create_dir_all(src.join("directory-target")).unwrap();
1866
1867 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1868 symlink_file(
1869 &outside.path().join("outside-target.ts"),
1870 &src.join("outside-link.ts"),
1871 );
1872 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1873 symlink_dir(
1874 &src.join("directory-target"),
1875 &src.join("directory-link.ts"),
1876 );
1877
1878 let config = make_config(dir.path().to_path_buf(), false);
1879 let names = file_names(&discover_files(&config), dir.path());
1880
1881 assert!(names.contains(&"src/regular.ts".to_string()));
1882 assert!(names.contains(&"src/inside-target.ts".to_string()));
1883 assert!(names.contains(&"src/inside-link.ts".to_string()));
1884 assert!(!names.contains(&"src/outside-link.ts".to_string()));
1885 assert!(!names.contains(&"src/broken-link.ts".to_string()));
1886 assert!(!names.contains(&"src/directory-link.ts".to_string()));
1887 }
1888
1889 #[test]
1893 fn skips_yarn_pnp_generated_files() {
1894 let dir = tempfile::tempdir().expect("create temp dir");
1895 std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
1896 std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
1897 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1898
1899 let config = make_config(dir.path().to_path_buf(), false);
1900 let names = file_names(&discover_files(&config), dir.path());
1901
1902 assert_eq!(names, vec!["index.ts".to_string()]);
1903 }
1904
1905 #[test]
1906 fn discovers_source_files_with_valid_extensions() {
1907 let dir = tempfile::tempdir().expect("create temp dir");
1908 let src = dir.path().join("src");
1909 std::fs::create_dir_all(&src).unwrap();
1910
1911 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1912 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1913 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1914 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1915 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1916 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1917 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1918 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1919
1920 let config = make_config(dir.path().to_path_buf(), false);
1921 let files = discover_files(&config);
1922 let names = file_names(&files, dir.path());
1923
1924 assert!(names.contains(&"src/app.ts".to_string()));
1925 assert!(names.contains(&"src/component.tsx".to_string()));
1926 assert!(names.contains(&"src/utils.js".to_string()));
1927 assert!(names.contains(&"src/helper.jsx".to_string()));
1928 assert!(names.contains(&"src/config.mjs".to_string()));
1929 assert!(names.contains(&"src/legacy.cjs".to_string()));
1930 assert!(names.contains(&"src/types.mts".to_string()));
1931 assert!(names.contains(&"src/compat.cts".to_string()));
1932 }
1933
1934 #[test]
1935 fn compact_source_glob_preserves_discovered_file_inventory() {
1936 let dir = tempfile::tempdir().expect("create temp dir");
1937 let nested = dir.path().join("packages/ui/src/nested");
1938 std::fs::create_dir_all(&nested).unwrap();
1939
1940 let mut expected = Vec::new();
1941 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1942 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1943 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1944 expected.push(relative);
1945 }
1946 for relative in [
1947 "packages/ui/src/nested/env.d.ts",
1948 "packages/ui/src/nested/generated.d.mts",
1949 "packages/ui/src/nested/compat.d.cts",
1950 ] {
1951 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1952 expected.push(relative.to_string());
1953 }
1954 let rejected = [
1955 "packages/ui/src/nested/component.tsx.bak",
1956 "packages/ui/src/nested/component.tsxmap",
1957 "packages/ui/src/nested/component.TS",
1958 "packages/ui/src/nested/component.gqlx",
1959 "packages/ui/src/nested/component.htm",
1960 "packages/ui/src/nested/component",
1961 "packages/ui/src/nested/component.png",
1962 ];
1963 for relative in rejected {
1964 std::fs::write(dir.path().join(relative), "not source").unwrap();
1965 }
1966
1967 let config = make_config(dir.path().to_path_buf(), false);
1968 let names = file_names(&discover_files(&config), dir.path());
1969
1970 for relative in expected {
1971 assert!(
1972 names.contains(&relative),
1973 "missing supported source {relative}"
1974 );
1975 }
1976 for relative in rejected {
1977 assert!(
1978 !names.iter().any(|name| name == relative),
1979 "unexpected near-miss source {relative}"
1980 );
1981 }
1982 }
1983
1984 #[test]
1985 fn excludes_non_source_extensions() {
1986 let dir = tempfile::tempdir().expect("create temp dir");
1987 let src = dir.path().join("src");
1988 std::fs::create_dir_all(&src).unwrap();
1989
1990 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1991
1992 std::fs::write(src.join("data.json"), "{}").unwrap();
1993 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1994 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1995 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1996
1997 let config = make_config(dir.path().to_path_buf(), false);
1998 let files = discover_files(&config);
1999 let names = file_names(&files, dir.path());
2000
2001 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
2002 assert!(names.contains(&"src/app.ts".to_string()));
2003 }
2004
2005 #[test]
2006 fn excludes_disallowed_hidden_directories() {
2007 let dir = tempfile::tempdir().expect("create temp dir");
2008
2009 let git_dir = dir.path().join(".git");
2010 std::fs::create_dir_all(&git_dir).unwrap();
2011 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
2012
2013 let idea_dir = dir.path().join(".idea");
2014 std::fs::create_dir_all(&idea_dir).unwrap();
2015 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
2016
2017 let cache_dir = dir.path().join(".cache");
2018 std::fs::create_dir_all(&cache_dir).unwrap();
2019 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2020
2021 let src = dir.path().join("src");
2022 std::fs::create_dir_all(&src).unwrap();
2023 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2024
2025 let config = make_config(dir.path().to_path_buf(), false);
2026 let files = discover_files(&config);
2027 let names = file_names(&files, dir.path());
2028
2029 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2030 assert!(names.contains(&"src/app.ts".to_string()));
2031 }
2032
2033 #[test]
2034 fn includes_allowed_hidden_directories() {
2035 let dir = tempfile::tempdir().expect("create temp dir");
2036
2037 let storybook = dir.path().join(".storybook");
2038 std::fs::create_dir_all(&storybook).unwrap();
2039 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2040
2041 let github = dir.path().join(".github");
2042 std::fs::create_dir_all(&github).unwrap();
2043 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2044
2045 let changeset = dir.path().join(".changeset");
2046 std::fs::create_dir_all(&changeset).unwrap();
2047 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2048
2049 let config = make_config(dir.path().to_path_buf(), false);
2050 let files = discover_files(&config);
2051 let names = file_names(&files, dir.path());
2052
2053 assert!(
2054 names.contains(&".storybook/main.ts".to_string()),
2055 "files in .storybook should be discovered"
2056 );
2057 assert!(
2058 names.contains(&".github/actions.js".to_string()),
2059 "files in .github should be discovered"
2060 );
2061 assert!(
2062 names.contains(&".changeset/config.js".to_string()),
2063 "files in .changeset should be discovered"
2064 );
2065 }
2066
2067 #[test]
2068 fn default_discovery_excludes_client_and_server_hidden_directories() {
2069 let dir = tempfile::tempdir().expect("create temp dir");
2070 let app = dir.path().join("app");
2071 std::fs::create_dir_all(app.join(".client")).unwrap();
2072 std::fs::create_dir_all(app.join(".server")).unwrap();
2073 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2074 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2075 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2076
2077 let config = make_config(dir.path().to_path_buf(), false);
2078 let files = discover_files(&config);
2079 let names = file_names(&files, dir.path());
2080
2081 assert!(names.contains(&"app/root.tsx".to_string()));
2082 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2083 assert!(!names.contains(&"app/.server/db.ts".to_string()));
2084 }
2085
2086 #[test]
2087 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2088 let dir = tempfile::tempdir().expect("create temp dir");
2089 let package = dir.path().join("packages/app");
2090 std::fs::create_dir_all(package.join("app/.client")).unwrap();
2091 std::fs::create_dir_all(package.join("app/.server")).unwrap();
2092 std::fs::write(
2093 package.join("app/.client/analytics.ts"),
2094 "export const track = () => {};",
2095 )
2096 .unwrap();
2097 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2098
2099 let config = make_config(dir.path().to_path_buf(), false);
2100 let scopes = [HiddenDirScope::new(
2101 package,
2102 vec![".client".to_string(), ".server".to_string()],
2103 )];
2104 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2105 let names = file_names(&files, dir.path());
2106
2107 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2108 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2109 }
2110
2111 #[test]
2112 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2113 let dir = tempfile::tempdir().expect("create temp dir");
2114 let active = dir.path().join("packages/active");
2115 let inactive = dir.path().join("packages/inactive");
2116 std::fs::create_dir_all(active.join("app/.server")).unwrap();
2117 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2118 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2119 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2120
2121 let config = make_config(dir.path().to_path_buf(), false);
2122 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2123 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2124 let names = file_names(&files, dir.path());
2125
2126 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2127 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2128 }
2129
2130 #[test]
2131 fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2132 let dir = tempfile::tempdir().expect("create temp dir");
2136 std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2137 std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2138 std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2139 std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2140 std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2141 std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2142
2143 let config = make_config(dir.path().to_path_buf(), false);
2144 let scopes = [HiddenDirScope::new_exact_paths(
2145 dir.path().to_path_buf(),
2146 vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2147 )];
2148 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2149 let names = file_names(&files, dir.path());
2150
2151 assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2152 assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2153 assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2154 }
2155
2156 #[test]
2157 fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2158 let dir = tempfile::tempdir().expect("create temp dir");
2159 std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2160 std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2161 std::fs::write(
2162 dir.path().join("tools/.config/eslint.config.js"),
2163 "export default [];",
2164 )
2165 .unwrap();
2166 std::fs::write(
2167 dir.path().join("other/.config/eslint.config.js"),
2168 "export default [];",
2169 )
2170 .unwrap();
2171
2172 let config = make_config(dir.path().to_path_buf(), false);
2173 let scopes = [HiddenDirScope::new_exact_paths(
2174 dir.path().to_path_buf(),
2175 vec![format!("tools{MAIN_SEPARATOR}.config")],
2176 )];
2177 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2178 let names = file_names(&files, dir.path());
2179
2180 assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2181 assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2182 }
2183
2184 #[test]
2185 fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2186 let dir = tempfile::tempdir().expect("create temp dir");
2190 std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2191 std::fs::write(
2192 dir.path().join("app/routes/deep/.server/db.ts"),
2193 "export const db = {};",
2194 )
2195 .unwrap();
2196
2197 let config = make_config(dir.path().to_path_buf(), false);
2198 let scopes = [HiddenDirScope::new(
2199 dir.path().to_path_buf(),
2200 vec![".server".to_string()],
2201 )];
2202 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2203 let names = file_names(&files, dir.path());
2204
2205 assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2206 }
2207
2208 #[test]
2209 fn excludes_root_build_directory() {
2210 let dir = tempfile::tempdir().expect("create temp dir");
2211
2212 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2213
2214 let build_dir = dir.path().join("build");
2215 std::fs::create_dir_all(&build_dir).unwrap();
2216 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2217
2218 let src = dir.path().join("src");
2219 std::fs::create_dir_all(&src).unwrap();
2220 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2221
2222 let config = make_config(dir.path().to_path_buf(), false);
2223 let files = discover_files(&config);
2224 let names = file_names(&files, dir.path());
2225
2226 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2227 assert!(names.contains(&"src/app.ts".to_string()));
2228 }
2229
2230 #[test]
2231 fn includes_nested_build_directory() {
2232 let dir = tempfile::tempdir().expect("create temp dir");
2233
2234 let nested_build = dir.path().join("src").join("build");
2235 std::fs::create_dir_all(&nested_build).unwrap();
2236 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2237
2238 let config = make_config(dir.path().to_path_buf(), false);
2239 let files = discover_files(&config);
2240 let names = file_names(&files, dir.path());
2241
2242 assert!(
2243 names.contains(&"src/build/helper.ts".to_string()),
2244 "nested build/ directories should be included"
2245 );
2246 }
2247
2248 #[test]
2249 #[expect(
2250 clippy::cast_possible_truncation,
2251 reason = "test file counts are trivially small"
2252 )]
2253 fn file_ids_are_sequential_after_sorting() {
2254 let dir = tempfile::tempdir().expect("create temp dir");
2255 let src = dir.path().join("src");
2256 std::fs::create_dir_all(&src).unwrap();
2257
2258 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2259 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2260 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2261
2262 let config = make_config(dir.path().to_path_buf(), false);
2263 let files = discover_files(&config);
2264
2265 for (idx, file) in files.iter().enumerate() {
2266 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2267 }
2268
2269 for pair in files.windows(2) {
2270 assert!(
2271 pair[0].path < pair[1].path,
2272 "files should be sorted by path"
2273 );
2274 }
2275 }
2276
2277 #[test]
2278 fn production_mode_excludes_test_files() {
2279 let dir = tempfile::tempdir().expect("create temp dir");
2280 let src = dir.path().join("src");
2281 std::fs::create_dir_all(&src).unwrap();
2282
2283 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2284 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2285 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2286 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2287
2288 let config = make_config(dir.path().to_path_buf(), true);
2289 let files = discover_files(&config);
2290 let names = file_names(&files, dir.path());
2291
2292 assert!(
2293 names.contains(&"src/app.ts".to_string()),
2294 "source files should be included in production mode"
2295 );
2296 assert!(
2297 !names.contains(&"src/app.test.ts".to_string()),
2298 "test files should be excluded in production mode"
2299 );
2300 assert!(
2301 !names.contains(&"src/app.spec.ts".to_string()),
2302 "spec files should be excluded in production mode"
2303 );
2304 assert!(
2305 !names.contains(&"src/app.stories.tsx".to_string()),
2306 "story files should be excluded in production mode"
2307 );
2308 }
2309
2310 #[test]
2311 fn non_production_mode_includes_test_files() {
2312 let dir = tempfile::tempdir().expect("create temp dir");
2313 let src = dir.path().join("src");
2314 std::fs::create_dir_all(&src).unwrap();
2315
2316 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2317 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2318
2319 let config = make_config(dir.path().to_path_buf(), false);
2320 let files = discover_files(&config);
2321 let names = file_names(&files, dir.path());
2322
2323 assert!(names.contains(&"src/app.ts".to_string()));
2324 assert!(
2325 names.contains(&"src/app.test.ts".to_string()),
2326 "test files should be included in non-production mode"
2327 );
2328 }
2329
2330 #[test]
2331 fn empty_directory_returns_no_files() {
2332 let dir = tempfile::tempdir().expect("create temp dir");
2333 let config = make_config(dir.path().to_path_buf(), false);
2334 let files = discover_files(&config);
2335 assert!(files.is_empty(), "empty project should discover no files");
2336 }
2337
2338 #[test]
2339 fn hidden_files_not_discovered_as_source() {
2340 let dir = tempfile::tempdir().expect("create temp dir");
2341
2342 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2343 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2344 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2345
2346 let src = dir.path().join("src");
2347 std::fs::create_dir_all(&src).unwrap();
2348 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2349
2350 let config = make_config(dir.path().to_path_buf(), false);
2351 let files = discover_files(&config);
2352 let names = file_names(&files, dir.path());
2353
2354 assert!(
2355 !names.contains(&".env".to_string()),
2356 ".env should not be discovered"
2357 );
2358 assert!(
2359 !names.contains(&".gitignore".to_string()),
2360 ".gitignore should not be discovered"
2361 );
2362 }
2363
2364 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2366 FallowConfig {
2367 type_aware: fallow_config::TypeAwareConfig::default(),
2368 schema: None,
2369 minimum_version: None,
2370 extends: vec![],
2371 entry: vec![],
2372 ignore_patterns: ignores,
2373 ignore_findings: vec![],
2374 framework: vec![],
2375 workspaces: None,
2376 ignore_dependencies: vec![],
2377 ignore_unresolved_imports: vec![],
2378 ignore_exports: vec![],
2379 ignore_catalog_references: vec![],
2380 ignore_dependency_overrides: vec![],
2381 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2382 ),
2383 used_class_members: vec![],
2384 ignore_decorators: vec![],
2385 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2386 duplicates: DuplicatesConfig::default(),
2387 similar_code: fallow_config::SimilarCodeConfig::default(),
2388 health: HealthConfig::default(),
2389 rules: RulesConfig::default(),
2390 boundaries: fallow_config::BoundaryConfig::default(),
2391 production: false.into(),
2392 plugins: vec![],
2393 rule_packs: vec![],
2394 dynamically_loaded: vec![],
2395 overrides: vec![],
2396 regression: None,
2397 audit: fallow_config::AuditConfig::default(),
2398 codeowners: None,
2399 public_packages: vec![],
2400 flags: FlagsConfig::default(),
2401 security: fallow_config::SecurityConfig::default(),
2402 fix: fallow_config::FixConfig::default(),
2403 resolve: ResolveConfig::default(),
2404 sealed: false,
2405 include_entry_exports: false,
2406 auto_imports: false,
2407 cache: fallow_config::CacheConfig::default(),
2408 }
2409 .resolve(root, OutputFormat::Human, 1, true, true, None)
2410 }
2411
2412 #[test]
2413 fn custom_ignore_patterns_exclude_matching_files() {
2414 let dir = tempfile::tempdir().expect("create temp dir");
2415
2416 let generated = dir.path().join("src").join("api").join("generated");
2417 std::fs::create_dir_all(&generated).unwrap();
2418 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2419
2420 let client = dir.path().join("src").join("api").join("client");
2421 std::fs::create_dir_all(&client).unwrap();
2422 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2423
2424 let src = dir.path().join("src");
2425 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2426
2427 let config = make_config_with_ignores(
2428 dir.path().to_path_buf(),
2429 vec![
2430 "src/api/generated/**".to_string(),
2431 "src/api/client/**".to_string(),
2432 ],
2433 );
2434 let files = discover_files(&config);
2435 let names = file_names(&files, dir.path());
2436
2437 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2438 assert!(names.contains(&"src/index.ts".to_string()));
2439 }
2440
2441 #[test]
2442 fn leading_dot_ignore_patterns_exclude_matching_files() {
2443 let dir = tempfile::tempdir().expect("create temp dir");
2444
2445 let generated = dir.path().join("src").join("generated");
2446 std::fs::create_dir_all(&generated).unwrap();
2447 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2448
2449 let src = dir.path().join("src");
2450 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2451
2452 let config = make_config_with_ignores(
2453 dir.path().to_path_buf(),
2454 vec!["./src/generated/**".to_string()],
2455 );
2456 let files = discover_files(&config);
2457 let names = file_names(&files, dir.path());
2458
2459 assert_eq!(names, vec!["src/index.ts"]);
2460 }
2461
2462 #[test]
2463 fn default_ignore_patterns_exclude_node_modules_and_dist() {
2464 let dir = tempfile::tempdir().expect("create temp dir");
2465
2466 let nm = dir.path().join("node_modules").join("lodash");
2467 std::fs::create_dir_all(&nm).unwrap();
2468 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2469
2470 let dist = dir.path().join("dist");
2471 std::fs::create_dir_all(&dist).unwrap();
2472 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2473
2474 let src = dir.path().join("src");
2475 std::fs::create_dir_all(&src).unwrap();
2476 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2477
2478 let config = make_config(dir.path().to_path_buf(), false);
2479 let files = discover_files(&config);
2480 let names = file_names(&files, dir.path());
2481
2482 assert_eq!(names.len(), 1);
2483 assert!(names.contains(&"src/index.ts".to_string()));
2484 }
2485
2486 #[test]
2487 fn default_ignore_patterns_exclude_root_build() {
2488 let dir = tempfile::tempdir().expect("create temp dir");
2489
2490 let build = dir.path().join("build");
2491 std::fs::create_dir_all(&build).unwrap();
2492 std::fs::write(build.join("output.js"), "// built").unwrap();
2493
2494 let nested_build = dir.path().join("src").join("build");
2495 std::fs::create_dir_all(&nested_build).unwrap();
2496 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2497
2498 let src = dir.path().join("src");
2499 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2500
2501 let config = make_config(dir.path().to_path_buf(), false);
2502 let files = discover_files(&config);
2503 let names = file_names(&files, dir.path());
2504
2505 assert_eq!(
2506 names.len(),
2507 2,
2508 "root build/ excluded, nested kept: {names:?}"
2509 );
2510 assert!(names.contains(&"src/index.ts".to_string()));
2511 assert!(names.contains(&"src/build/helper.ts".to_string()));
2512 }
2513
2514 fn make_config_with_max_file_size(
2516 root: PathBuf,
2517 max_file_size_bytes: Option<u64>,
2518 ) -> ResolvedConfig {
2519 let mut config = make_config(root, false);
2520 config.max_file_size_bytes = max_file_size_bytes;
2521 config
2522 }
2523
2524 #[test]
2525 fn skips_files_over_max_file_size() {
2526 let dir = tempfile::tempdir().expect("create temp dir");
2527 let src = dir.path().join("src");
2528 std::fs::create_dir_all(&src).unwrap();
2529 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
2530 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2531
2532 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2533 let files = discover_files(&config);
2534 let names = file_names(&files, dir.path());
2535
2536 assert!(names.contains(&"src/small.ts".to_string()));
2537 assert!(
2538 !names.contains(&"src/huge.ts".to_string()),
2539 "a file over the size limit must not be discovered"
2540 );
2541 }
2542
2543 #[test]
2544 fn declaration_files_exempt_from_size_skip() {
2545 let dir = tempfile::tempdir().expect("create temp dir");
2546 let src = dir.path().join("src");
2547 std::fs::create_dir_all(&src).unwrap();
2548 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
2549 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2550
2551 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2552 let files = discover_files(&config);
2553 let names = file_names(&files, dir.path());
2554
2555 assert!(
2556 names.contains(&"src/auto-imports.d.ts".to_string()),
2557 "a large .d.ts is exempt from the skip (reachability root for global types)"
2558 );
2559 assert!(!names.contains(&"src/huge.ts".to_string()));
2560 }
2561
2562 #[test]
2563 fn unlimited_size_keeps_large_files() {
2564 let dir = tempfile::tempdir().expect("create temp dir");
2565 let src = dir.path().join("src");
2566 std::fs::create_dir_all(&src).unwrap();
2567 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2568
2569 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2570 let files = discover_files(&config);
2571 let names = file_names(&files, dir.path());
2572
2573 assert!(
2574 names.contains(&"src/huge.ts".to_string()),
2575 "no limit keeps every file"
2576 );
2577 }
2578
2579 #[test]
2580 fn skipped_file_recorded_in_workspace_diagnostics() {
2581 let dir = tempfile::tempdir().expect("create temp dir");
2582 let src = dir.path().join("src");
2583 std::fs::create_dir_all(&src).unwrap();
2584 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2585
2586 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2587 let _ = discover_files(&config);
2588
2589 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2590 let skipped: Vec<_> = diagnostics
2591 .iter()
2592 .filter(|d| {
2593 matches!(
2594 d.kind,
2595 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
2596 )
2597 })
2598 .collect();
2599 assert_eq!(
2600 skipped.len(),
2601 1,
2602 "the skipped file is recorded in workspace diagnostics for JSON output"
2603 );
2604 assert!(skipped[0].path.ends_with("src/huge.ts"));
2605 assert!(
2606 matches!(
2607 skipped[0].kind,
2608 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
2609 if size_bytes == 5_000
2610 ),
2611 "the recorded diagnostic carries the on-disk byte size"
2612 );
2613 }
2614
2615 fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
2617 fallow_config::workspace_diagnostics_for(root)
2618 .into_iter()
2619 .filter(|d| {
2620 matches!(
2621 d.kind,
2622 fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
2623 )
2624 })
2625 .collect()
2626 }
2627
2628 fn write_at(root: &Path, relative: &str, contents: &str) {
2629 let path = root.join(relative);
2630 std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
2631 std::fs::write(path, contents).unwrap();
2632 }
2633
2634 #[test]
2635 fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
2636 let dir = tempfile::tempdir().expect("create temp dir");
2637 write_at(
2638 dir.path(),
2639 ".claude/hooks/probe.mjs",
2640 "export const a = 1;\n",
2641 );
2642 write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
2643
2644 let config = make_config(dir.path().to_path_buf(), false);
2645 let files = discover_files(&config);
2646 let names = file_names(&files, dir.path());
2647
2648 let reported = dotdir_diagnostics(dir.path());
2649 assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
2650 assert!(reported[0].path.ends_with(".claude"));
2651 assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
2652 assert!(
2653 reported[0].message.contains("--root"),
2654 "message names the real remedy: {}",
2655 reported[0].message
2656 );
2657 assert!(
2658 names.contains(&"src/app.ts".to_string()),
2659 "traversal is unchanged for ordinary directories"
2660 );
2661 assert!(
2662 !names.contains(&".claude/hooks/probe.mjs".to_string()),
2663 "the diagnostic reports the skip, it does not change traversal"
2664 );
2665 }
2666
2667 #[test]
2668 fn allowlisted_dotdir_is_not_reported() {
2669 let dir = tempfile::tempdir().expect("create temp dir");
2670 write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
2671
2672 let config = make_config(dir.path().to_path_buf(), false);
2673 let files = discover_files(&config);
2674 let names = file_names(&files, dir.path());
2675
2676 assert!(dotdir_diagnostics(dir.path()).is_empty());
2677 assert!(
2678 names.contains(&".storybook/main.ts".to_string()),
2679 "an allowlisted dotdir is still traversed"
2680 );
2681 }
2682
2683 #[test]
2684 fn denylisted_dotdir_is_not_reported() {
2685 let dir = tempfile::tempdir().expect("create temp dir");
2686 write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
2687 write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
2688 write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
2689 write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
2690
2691 let config = make_config(dir.path().to_path_buf(), false);
2692 let _ = discover_files(&config);
2693
2694 assert!(
2695 dotdir_diagnostics(dir.path()).is_empty(),
2696 "build caches, VCS and package-manager state never advise"
2697 );
2698 }
2699
2700 #[test]
2701 fn scoped_dotdir_is_traversed_and_not_reported() {
2702 let dir = tempfile::tempdir().expect("create temp dir");
2703 write_at(
2704 dir.path(),
2705 ".claude/hooks/probe.mjs",
2706 "export const a = 1;\n",
2707 );
2708
2709 let config = make_config(dir.path().to_path_buf(), false);
2710 let scopes = [HiddenDirScope::new(
2711 dir.path().to_path_buf(),
2712 vec![".claude".to_owned()],
2713 )];
2714 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2715 let names = file_names(&files, dir.path());
2716
2717 assert!(
2718 dotdir_diagnostics(dir.path()).is_empty(),
2719 "a plugin- or script-contributed scope is admitted, so nothing was skipped"
2720 );
2721 assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
2722 }
2723
2724 #[test]
2725 fn ignore_patterns_silence_the_skipped_source_dotdir() {
2726 let dir = tempfile::tempdir().expect("create temp dir");
2727 write_at(
2728 dir.path(),
2729 ".claude/hooks/probe.mjs",
2730 "export const a = 1;\n",
2731 );
2732
2733 let config =
2734 make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
2735 let _ = discover_files(&config);
2736
2737 assert!(
2738 dotdir_diagnostics(dir.path()).is_empty(),
2739 "the documented silencing route works"
2740 );
2741 }
2742
2743 #[test]
2744 fn dotdir_without_source_files_is_not_reported() {
2745 let dir = tempfile::tempdir().expect("create temp dir");
2746 write_at(dir.path(), ".claude/settings.json", "{}\n");
2747 write_at(dir.path(), ".claude/README.md", "# notes\n");
2748
2749 let config = make_config(dir.path().to_path_buf(), false);
2750 let _ = discover_files(&config);
2751
2752 assert!(dotdir_diagnostics(dir.path()).is_empty());
2753 }
2754
2755 #[test]
2756 fn dotdir_source_at_scan_depth_limit_is_reported() {
2757 let dir = tempfile::tempdir().expect("create temp dir");
2758 write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
2759
2760 let config = make_config(dir.path().to_path_buf(), false);
2761 let _ = discover_files(&config);
2762
2763 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2764 }
2765
2766 #[test]
2767 fn dotdir_source_below_scan_depth_limit_is_not_reported() {
2768 let dir = tempfile::tempdir().expect("create temp dir");
2769 write_at(
2770 dir.path(),
2771 ".claude/a/b/c/deeper.ts",
2772 "export const a = 1;\n",
2773 );
2774
2775 let config = make_config(dir.path().to_path_buf(), false);
2776 let _ = discover_files(&config);
2777
2778 assert!(
2779 dotdir_diagnostics(dir.path()).is_empty(),
2780 "the depth cap is real, so widening it stays a deliberate act"
2781 );
2782 }
2783
2784 fn mark_as_git_repo(root: &Path) {
2788 std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
2789 }
2790
2791 #[test]
2792 fn gitignored_dotdir_contents_are_not_reported() {
2793 for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
2797 let dir = tempfile::tempdir().expect("create temp dir");
2798 mark_as_git_repo(dir.path());
2799 write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
2800 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2801
2802 let config = make_config(dir.path().to_path_buf(), false);
2803 let _ = discover_files(&config);
2804
2805 assert!(
2806 dotdir_diagnostics(dir.path()).is_empty(),
2807 "gitignore pattern '{pattern}' excludes the contents, so neither \
2808 advertised remedy would find anything there"
2809 );
2810 }
2811 }
2812
2813 #[test]
2814 fn self_ignoring_dotdir_is_not_reported() {
2815 let dir = tempfile::tempdir().expect("create temp dir");
2816 mark_as_git_repo(dir.path());
2817 write_at(dir.path(), ".toolcache/.gitignore", "*\n");
2818 write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
2819
2820 let config = make_config(dir.path().to_path_buf(), false);
2821 let _ = discover_files(&config);
2822
2823 assert!(
2824 dotdir_diagnostics(dir.path()).is_empty(),
2825 "a cache directory that ignores itself has excluded its own contents"
2826 );
2827 }
2828
2829 #[test]
2830 fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
2831 let dir = tempfile::tempdir().expect("create temp dir");
2832 mark_as_git_repo(dir.path());
2833 write_at(dir.path(), ".gitignore", "dist/\n");
2834 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2835
2836 let config = make_config(dir.path().to_path_buf(), false);
2837 let _ = discover_files(&config);
2838
2839 assert_eq!(
2840 dotdir_diagnostics(dir.path()).len(),
2841 1,
2842 "the gitignore check must not swallow the case the diagnostic exists for"
2843 );
2844 }
2845
2846 #[test]
2847 fn production_run_does_not_report_a_test_only_dotdir() {
2848 let dir = tempfile::tempdir().expect("create temp dir");
2849 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2850 write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
2851
2852 let config = make_config(dir.path().to_path_buf(), true);
2853 let _ = discover_files(&config);
2854
2855 assert!(
2856 dotdir_diagnostics(dir.path()).is_empty(),
2857 "a --production run would analyze none of those files, so the \
2858 --root remedy would return nothing"
2859 );
2860 }
2861
2862 #[test]
2863 fn production_run_still_reports_a_dotdir_with_production_source() {
2864 let dir = tempfile::tempdir().expect("create temp dir");
2865 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2866 write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
2867
2868 let config = make_config(dir.path().to_path_buf(), true);
2869 let _ = discover_files(&config);
2870
2871 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2872 }
2873
2874 #[test]
2875 fn dotdir_with_only_generated_markup_is_not_reported() {
2876 let dir = tempfile::tempdir().expect("create temp dir");
2877 write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
2878 write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
2879 write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
2880
2881 let config = make_config(dir.path().to_path_buf(), false);
2882 let _ = discover_files(&config);
2883
2884 assert!(
2885 dotdir_diagnostics(dir.path()).is_empty(),
2886 "the message claims imports and exports are lost, and these have none"
2887 );
2888 }
2889
2890 #[test]
2891 fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
2892 let dir = tempfile::tempdir().expect("create temp dir");
2893 write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
2894 write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
2895 write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
2896 write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
2897 write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
2898
2899 let config = make_config(dir.path().to_path_buf(), false);
2900 let _ = discover_files(&config);
2901
2902 assert!(
2903 dotdir_diagnostics(dir.path()).is_empty(),
2904 "generated output and foreign VCS metadata are not first-party source"
2905 );
2906 }
2907
2908 #[test]
2909 fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
2910 let dir = tempfile::tempdir().expect("create temp dir");
2911 for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
2914 write_at(
2915 dir.path(),
2916 &format!("packages/pkg{index:03}/.turbo/blob.js"),
2917 "export const a = 1;\n",
2918 );
2919 }
2920 write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
2921
2922 let config = make_config(dir.path().to_path_buf(), false);
2923 let _ = discover_files(&config);
2924
2925 let reported = dotdir_diagnostics(dir.path());
2926 assert_eq!(reported.len(), 1, "{reported:?}");
2927 assert!(reported[0].path.ends_with(".tooling"));
2928 }
2929
2930 #[test]
2931 fn one_pathological_dotdir_cannot_starve_the_rest() {
2932 let dir = tempfile::tempdir().expect("create temp dir");
2933 for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
2935 write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
2936 }
2937 write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
2938
2939 let config = make_config(dir.path().to_path_buf(), false);
2940 let _ = discover_files(&config);
2941
2942 let reported = dotdir_diagnostics(dir.path());
2943 assert_eq!(reported.len(), 1, "{reported:?}");
2944 assert!(reported[0].path.ends_with(".zzz-real"));
2945 }
2946
2947 #[test]
2948 fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
2949 let dir = tempfile::tempdir().expect("create temp dir");
2950 write_at(
2951 dir.path(),
2952 ".claude/hooks/probe.mjs",
2953 "export const a = 1;\n",
2954 );
2955
2956 let config = make_config(dir.path().to_path_buf(), false);
2957 let _ = discover_files(&config);
2958 let _ = discover_files(&config);
2959
2960 assert_eq!(
2961 dotdir_diagnostics(dir.path()).len(),
2962 1,
2963 "each walk replaces its own root's source-discovery set"
2964 );
2965 }
2966
2967 #[test]
2968 fn skips_large_one_line_js_as_minified_generated_output() {
2969 let dir = tempfile::tempdir().expect("create temp dir");
2970 let src = dir.path().join("src");
2971 std::fs::create_dir_all(&src).unwrap();
2972 let asset = src.join("index-abc123.js");
2973 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2974
2975 let config = make_config(dir.path().to_path_buf(), false);
2976 let files = discover_files(&config);
2977 let names = file_names(&files, dir.path());
2978
2979 assert!(
2980 !names.contains(&"src/index-abc123.js".to_string()),
2981 "large one-line JS assets should be skipped before parsing"
2982 );
2983
2984 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2985 assert!(
2986 diagnostics.iter().any(|diag| {
2987 diag.path.ends_with("src/index-abc123.js")
2988 && matches!(
2989 diag.kind,
2990 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
2991 )
2992 }),
2993 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
2994 );
2995 }
2996
2997 #[test]
2998 fn unlimited_size_keeps_large_one_line_js() {
2999 let dir = tempfile::tempdir().expect("create temp dir");
3000 let src = dir.path().join("src");
3001 std::fs::create_dir_all(&src).unwrap();
3002 let asset = src.join("index-abc123.js");
3003 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3004
3005 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3006 let files = discover_files(&config);
3007 let names = file_names(&files, dir.path());
3008
3009 assert!(
3010 names.contains(&"src/index-abc123.js".to_string()),
3011 "--max-file-size 0 should opt out of generated JS skipping"
3012 );
3013 }
3014
3015 #[test]
3016 fn keeps_large_multiline_js() {
3017 let dir = tempfile::tempdir().expect("create temp dir");
3018 let src = dir.path().join("src");
3019 std::fs::create_dir_all(&src).unwrap();
3020 let asset = src.join("handwritten.js");
3021 let mut content = String::new();
3022 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3023 content.push_str("export const value = 1;\n");
3024 }
3025 std::fs::write(&asset, content).unwrap();
3026
3027 let config = make_config(dir.path().to_path_buf(), false);
3028 let files = discover_files(&config);
3029 let names = file_names(&files, dir.path());
3030
3031 assert!(
3032 names.contains(&"src/handwritten.js".to_string()),
3033 "large multiline JS should not be treated as a generated minified asset"
3034 );
3035 }
3036 }
3037}