1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{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;
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
29const NOTE_EXAMPLE_CAP: usize = 5;
33
34const LARGE_SET_THRESHOLD: usize = 20_000;
38
39const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
44
45const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
49
50const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
54
55const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
57
58const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
61
62fn is_declaration_file(path: &Path) -> bool {
67 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
68 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
69}
70
71fn is_plain_js_file(path: &Path) -> bool {
72 matches!(
73 path.extension().and_then(|ext| ext.to_str()),
74 Some("js" | "mjs" | "cjs")
75 )
76}
77
78fn has_minified_line_shape(path: &Path) -> bool {
79 use std::io::Read;
80
81 let Ok(mut file) = std::fs::File::open(path) else {
82 return false;
83 };
84 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
85 let Ok(len) = file.read(&mut sample) else {
86 return false;
87 };
88 sample.truncate(len);
89 if sample.is_empty() {
90 return false;
91 }
92
93 let mut current_line = 0usize;
94 for byte in sample {
95 if byte == b'\n' || byte == b'\r' {
96 current_line = 0;
97 continue;
98 }
99 current_line += 1;
100 if current_line >= MINIFIED_LONG_LINE_BYTES {
101 return true;
102 }
103 }
104 false
105}
106
107fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
108 size_bytes >= MINIFIED_FILE_SKIP_BYTES
109 && is_plain_js_file(path)
110 && !is_declaration_file(path)
111 && has_minified_line_shape(path)
112}
113
114fn format_size_mb(bytes: u64) -> String {
116 #[expect(
117 clippy::cast_precision_loss,
118 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
119 )]
120 let mb = bytes as f64 / (1024.0 * 1024.0);
121 format!("{mb:.1} MB")
122}
123
124fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
127 let shown: Vec<String> = examples
128 .iter()
129 .take(NOTE_EXAMPLE_CAP)
130 .map(|(path, size)| {
131 let display = path
132 .strip_prefix(root)
133 .unwrap_or(path)
134 .display()
135 .to_string()
136 .replace('\\', "/");
137 format!("{display} ({})", format_size_mb(*size))
138 })
139 .collect();
140 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
141 if remaining > 0 {
142 format!("{}, and {remaining} more", shown.join(", "))
143 } else {
144 shown.join(", ")
145 }
146}
147
148fn partition_by_size(
151 raw: Vec<SizedFile>,
152 max_file_size_bytes: Option<u64>,
153) -> (Vec<SizedFile>, Vec<SizedFile>) {
154 let Some(limit) = max_file_size_bytes else {
155 return (raw, Vec::new());
156 };
157 raw.into_iter()
158 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
159}
160
161fn partition_minified_generated_js(
164 raw: Vec<SizedFile>,
165 max_file_size_bytes: Option<u64>,
166) -> (Vec<SizedFile>, Vec<SizedFile>) {
167 if max_file_size_bytes.is_none() {
168 return (raw, Vec::new());
169 }
170 raw.into_iter()
171 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
172}
173
174fn report_skipped_large_files(
179 config: &ResolvedConfig,
180 skipped: &[SizedFile],
181) -> Vec<WorkspaceDiagnostic> {
182 if skipped.is_empty() {
183 return Vec::new();
184 }
185 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
186 .iter()
187 .map(|(path, size_bytes)| {
188 WorkspaceDiagnostic::new(
189 &config.root,
190 path.clone(),
191 WorkspaceDiagnosticKind::SkippedLargeFile {
192 size_bytes: *size_bytes,
193 },
194 )
195 })
196 .collect();
197
198 let mut sorted: Vec<SizedFile> = skipped.to_vec();
199 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
200 let count = skipped.len();
201 if !config.quiet
202 && should_emit_note_once(format!(
203 "skip::{}::{count}::{}",
204 config.root.display(),
205 sorted.first().map_or(0, |f| f.1)
206 ))
207 {
208 let examples = summarize_examples(&config.root, &sorted);
209 let noun = if count == 1 { "file" } else { "files" };
210 tracing::warn!(
211 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
212 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
213 );
214 }
215 diagnostics
216}
217
218fn report_skipped_minified_files(
221 config: &ResolvedConfig,
222 skipped: &[SizedFile],
223) -> Vec<WorkspaceDiagnostic> {
224 if skipped.is_empty() {
225 return Vec::new();
226 }
227 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
228 .iter()
229 .map(|(path, size_bytes)| {
230 WorkspaceDiagnostic::new(
231 &config.root,
232 path.clone(),
233 WorkspaceDiagnosticKind::SkippedMinifiedFile {
234 size_bytes: *size_bytes,
235 },
236 )
237 })
238 .collect();
239
240 let mut sorted: Vec<SizedFile> = skipped.to_vec();
241 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
242 let count = skipped.len();
243 if !config.quiet
244 && should_emit_note_once(format!(
245 "minified::{}::{count}::{}",
246 config.root.display(),
247 sorted.first().map_or(0, |f| f.1)
248 ))
249 {
250 let examples = summarize_examples(&config.root, &sorted);
251 let noun = if count == 1 { "file" } else { "files" };
252 let pronoun = if count == 1 { "it" } else { "them" };
253 tracing::warn!(
254 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
255 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
256 );
257 }
258 diagnostics
259}
260
261fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
266 if files.is_empty() {
267 return None;
268 }
269 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
270 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
271 return None;
272 }
273 let count = files.len();
274 let noun = if count == 1 { "file" } else { "files" };
275 let mut by_size: Vec<SizedFile> = files
276 .iter()
277 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
278 .map(|f| (f.path.clone(), f.size_bytes))
279 .collect();
280 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
281 if by_size.is_empty() {
282 return Some(format!(
285 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
286 exclude large generated files via ignorePatterns or --max-file-size."
287 ));
288 }
289 let examples = summarize_examples(root, &by_size);
290 Some(format!(
291 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
292 exclude large generated files via ignorePatterns or --max-file-size."
293 ))
294}
295
296fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
301 if config.quiet {
302 return;
303 }
304 if let Some(message) = build_largest_files_note(&config.root, files)
305 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
306 {
307 tracing::warn!("{message}");
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct HiddenDirScope {
314 root: PathBuf,
315 dirs: Vec<String>,
316}
317
318impl HiddenDirScope {
319 #[must_use]
322 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
323 Self { root, dirs }
324 }
325
326 #[must_use]
327 pub fn root(&self) -> &Path {
328 &self.root
329 }
330
331 #[must_use]
332 pub fn dirs(&self) -> &[String] {
333 &self.dirs
334 }
335
336 fn allows(&self, path: &Path, name: &OsStr) -> bool {
337 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
338 }
339}
340
341struct FileVisitor<'a> {
348 root: &'a Path,
349 canonical_root: Option<&'a Path>,
350 ignore_patterns: &'a globset::GlobSet,
351 production_excludes: &'a Option<globset::GlobSet>,
352 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
353 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
354 local: Vec<(std::path::PathBuf, u64)>,
355 config_local: Vec<std::path::PathBuf>,
356}
357
358impl ignore::ParallelVisitor for FileVisitor<'_> {
359 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
360 let Ok(entry) = result else {
361 return ignore::WalkState::Continue;
362 };
363 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
364 return ignore::WalkState::Continue;
365 }
366 let relative = entry
367 .path()
368 .strip_prefix(self.root)
369 .unwrap_or_else(|_| entry.path());
370 if self.ignore_patterns.is_match(relative) {
371 return ignore::WalkState::Continue;
372 }
373 if self
374 .production_excludes
375 .as_ref()
376 .is_some_and(|excludes| excludes.is_match(relative))
377 {
378 return ignore::WalkState::Continue;
379 }
380 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
381 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
382 tracing::debug!(
383 path = %entry.path().display(),
384 "skipping source symlink with a broken, non-file, or outside-root target"
385 );
386 return ignore::WalkState::Continue;
387 };
388 Some(size)
389 } else {
390 None
391 };
392 if has_source_extension(entry.path()) {
393 let size_bytes =
394 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
395 self.local.push((entry.into_path(), size_bytes));
396 } else if self.config_shared.is_some() {
397 self.config_local.push(entry.into_path());
400 }
401 ignore::WalkState::Continue
402 }
403}
404
405fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
406 let root = canonical_root?;
407 let target = path.canonicalize().ok()?;
408 if !target.starts_with(root) {
409 return None;
410 }
411 let metadata = target.metadata().ok()?;
412 metadata.is_file().then_some(metadata.len())
413}
414
415impl Drop for FileVisitor<'_> {
416 #[expect(
417 clippy::expect_used,
418 reason = "poisoned walk collector lock means worker state is unrecoverable"
419 )]
420 fn drop(&mut self) {
421 if !self.local.is_empty() {
422 self.shared
423 .lock()
424 .expect("walk collector lock poisoned")
425 .append(&mut self.local);
426 }
427 if let Some(config_shared) = self.config_shared
428 && !self.config_local.is_empty()
429 {
430 config_shared
431 .lock()
432 .expect("walk config collector lock poisoned")
433 .append(&mut self.config_local);
434 }
435 }
436}
437
438struct FileVisitorBuilder<'a> {
440 root: &'a Path,
441 canonical_root: Option<&'a Path>,
442 ignore_patterns: &'a globset::GlobSet,
443 production_excludes: &'a Option<globset::GlobSet>,
444 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
445 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
446}
447
448impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
449 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
450 Box::new(FileVisitor {
451 root: self.root,
452 canonical_root: self.canonical_root,
453 ignore_patterns: self.ignore_patterns,
454 production_excludes: self.production_excludes,
455 shared: self.shared,
456 config_shared: self.config_shared,
457 local: Vec::new(),
458 config_local: Vec::new(),
459 })
460 }
461}
462
463pub const SOURCE_EXTENSIONS: &[&str] = &[
465 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
466 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
467];
468
469pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
471 "**/*.test.*",
472 "**/*.spec.*",
473 "**/*.e2e.*",
474 "**/*.e2e-spec.*",
475 "**/*.bench.*",
476 "**/*.fixture.*",
477 "**/*.stories.*",
478 "**/*.story.*",
479 "**/__tests__/**",
480 "**/__mocks__/**",
481 "**/__snapshots__/**",
482 "**/__fixtures__/**",
483 "**/test/**",
484 "**/tests/**",
485 "*.config.*",
486 "**/.*.js",
487 "**/.*.ts",
488 "**/.*.mjs",
489 "**/.*.cjs",
490];
491
492pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
494 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
495}
496
497fn is_allowed_scoped_hidden_dir(
498 name: &OsStr,
499 path: &Path,
500 additional_hidden_dir_scopes: &[HiddenDirScope],
501) -> bool {
502 additional_hidden_dir_scopes
503 .iter()
504 .any(|scope| scope.allows(path, name))
505}
506
507const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
512
513fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
514 YARN_PNP_GENERATED_FILES
515 .iter()
516 .any(|&f| OsStr::new(f) == name)
517}
518
519fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
525 is_allowed_hidden_with_scopes(entry, &[])
526}
527
528fn is_allowed_hidden_with_scopes(
529 entry: &ignore::DirEntry,
530 additional_hidden_dir_scopes: &[HiddenDirScope],
531) -> bool {
532 let name = entry.file_name();
533 let name_str = name.to_string_lossy();
534
535 if !name_str.starts_with('.') {
536 return true;
537 }
538
539 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
540 return !is_yarn_pnp_generated_file(name);
541 }
542
543 is_allowed_hidden_dir(name)
544 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
545}
546
547pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
553 discover_files_with_additional_hidden_dirs(config, &[])
554}
555
556fn config_candidate_basename_globs() -> &'static [String] {
568 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
569 GLOBS.get_or_init(|| {
570 let mut set: FxHashSet<String> = FxHashSet::default();
571 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
572 for pattern in plugin.config_patterns() {
573 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
574 set.insert(basename.to_string());
575 }
576 }
577 let mut globs: Vec<String> = set.into_iter().collect();
578 globs.sort_unstable();
579 globs
580 })
581}
582
583fn has_source_extension(path: &Path) -> bool {
586 path.extension()
587 .and_then(OsStr::to_str)
588 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
589}
590
591#[expect(
595 clippy::expect_used,
596 reason = "source file globs are hard-coded compile-time constants"
597)]
598fn build_walk_types(capture_config: bool) -> ignore::types::Types {
599 static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
600 static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
601
602 let cache = if capture_config {
603 &SOURCE_AND_CONFIG_TYPES
604 } else {
605 &SOURCE_TYPES
606 };
607 cache
608 .get_or_init(|| {
609 let mut types_builder = ignore::types::TypesBuilder::new();
610 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
611 types_builder
612 .add("source", &source_glob)
613 .expect("valid glob");
614 types_builder.select("source");
615 if capture_config {
616 for glob in config_candidate_basename_globs() {
617 let _ = types_builder.add("config", glob);
621 }
622 types_builder.select("config");
623 }
624 types_builder.build().expect("valid types")
625 })
626 .clone()
627}
628
629fn build_source_walk_builder(
633 config: &ResolvedConfig,
634 additional_hidden_dir_scopes: &[HiddenDirScope],
635 capture_config: bool,
636) -> WalkBuilder {
637 let mut walk_builder = WalkBuilder::new(&config.root);
638 walk_builder
639 .hidden(false)
640 .git_ignore(true)
641 .git_global(true)
642 .git_exclude(true)
643 .types(build_walk_types(capture_config))
644 .threads(config.threads);
645 if additional_hidden_dir_scopes.is_empty() {
646 walk_builder.filter_entry(is_allowed_hidden);
647 } else {
648 let scopes = additional_hidden_dir_scopes.to_vec();
649 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
650 }
651 walk_builder
652}
653
654fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
656 if !config.production {
657 return None;
658 }
659 let mut builder = globset::GlobSetBuilder::new();
660 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
661 if let Ok(glob) = globset::GlobBuilder::new(pattern)
662 .literal_separator(true)
663 .build()
664 {
665 builder.add(glob);
666 }
667 }
668 builder.build().ok()
669}
670
671pub fn discover_files_with_additional_hidden_dirs(
677 config: &ResolvedConfig,
678 additional_hidden_dir_scopes: &[HiddenDirScope],
679) -> Vec<DiscoveredFile> {
680 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
681}
682
683pub fn discover_files_and_config_candidates(
699 config: &ResolvedConfig,
700 additional_hidden_dir_scopes: &[HiddenDirScope],
701) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
702 let discovered =
703 discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
704 (discovered.files, discovered.config_candidates)
705}
706
707pub struct DiscoveredSources {
716 pub files: Vec<DiscoveredFile>,
718 pub config_candidates: Vec<PathBuf>,
720 pub diagnostics: Vec<WorkspaceDiagnostic>,
722}
723
724#[expect(
732 clippy::cast_possible_truncation,
733 reason = "file count is bounded by project size, well under u32::MAX"
734)]
735#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
736pub fn discover_files_config_candidates_and_diagnostics(
737 config: &ResolvedConfig,
738 additional_hidden_dir_scopes: &[HiddenDirScope],
739) -> DiscoveredSources {
740 let _span = tracing::info_span!("discover_files").entered();
741
742 let capture_config = !config.production;
743 let walk_builder =
744 build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
745 let production_excludes = build_production_excludes(config);
746 let canonical_root = config.root.canonicalize().ok();
747
748 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
749 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
750 let mut visitor_builder = FileVisitorBuilder {
751 root: &config.root,
752 canonical_root: canonical_root.as_deref(),
753 ignore_patterns: &config.ignore_patterns,
754 production_excludes: &production_excludes,
755 shared: &collected,
756 config_shared: capture_config.then_some(&config_collected),
757 };
758 walk_builder.build_parallel().visit(&mut visitor_builder);
759
760 let mut raw = collected
761 .into_inner()
762 .expect("walk collector lock poisoned");
763 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
771
772 let mut config_candidates = config_collected
773 .into_inner()
774 .expect("walk config collector lock poisoned");
775 config_candidates.sort_unstable();
776
777 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
778 let (kept, skipped_minified) =
779 partition_minified_generated_js(kept, config.max_file_size_bytes);
780 let diagnostics = fallow_config::replace_source_discovery_diagnostics(
785 &config.root,
786 report_skipped_large_files(config, &skipped)
787 .into_iter()
788 .chain(report_skipped_minified_files(config, &skipped_minified))
789 .collect(),
790 );
791
792 let files: Vec<DiscoveredFile> = kept
793 .into_iter()
794 .enumerate()
795 .map(|(idx, (path, size_bytes))| DiscoveredFile {
796 id: FileId(idx as u32),
797 path,
798 size_bytes,
799 })
800 .collect();
801
802 note_largest_files(config, &files);
803
804 DiscoveredSources {
805 files,
806 config_candidates,
807 diagnostics,
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use std::ffi::OsStr;
814
815 use super::*;
816
817 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
820 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
821 raw.into_iter()
822 .enumerate()
823 .map(|(idx, (path, size_bytes))| DiscoveredFile {
824 id: FileId(idx as u32),
825 path,
826 size_bytes,
827 })
828 .collect()
829 }
830
831 #[test]
837 fn file_id_assignment_is_deterministic_for_identical_file_set() {
838 let paths = [
839 "/project/src/z.ts",
840 "/project/src/a.ts",
841 "/project/src/components/Button.tsx",
842 "/project/src/components/Button.module.css",
843 "/project/index.ts",
844 ];
845
846 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
848 .iter()
849 .map(|p| (std::path::PathBuf::from(p), 10))
850 .collect();
851 let mut walk_two = walk_one.clone();
852 walk_two.reverse();
853
854 let files_one = assign_file_ids(walk_one);
855 let files_two = assign_file_ids(walk_two);
856
857 assert_eq!(files_one.len(), files_two.len());
859 for (a, b) in files_one.iter().zip(files_two.iter()) {
860 assert_eq!(a.id, b.id);
861 assert_eq!(a.path, b.path);
862 }
863
864 for (idx, file) in files_one.iter().enumerate() {
867 assert_eq!(file.id, FileId(idx as u32));
868 }
869 assert_eq!(
870 files_one[0].path,
871 std::path::PathBuf::from("/project/index.ts")
872 );
873 }
874
875 #[test]
876 fn file_id_assignment_recomputes_after_rename_or_delete() {
877 let before = assign_file_ids(vec![
878 (std::path::PathBuf::from("/project/src/a.ts"), 10),
879 (std::path::PathBuf::from("/project/src/b.ts"), 10),
880 (std::path::PathBuf::from("/project/src/c.ts"), 10),
881 ]);
882 let after_delete = assign_file_ids(vec![
883 (std::path::PathBuf::from("/project/src/a.ts"), 10),
884 (std::path::PathBuf::from("/project/src/c.ts"), 10),
885 ]);
886 let after_rename = assign_file_ids(vec![
887 (std::path::PathBuf::from("/project/src/a.ts"), 10),
888 (std::path::PathBuf::from("/project/src/c.ts"), 10),
889 (std::path::PathBuf::from("/project/src/d.ts"), 10),
890 ]);
891
892 assert_eq!(before[0].id, FileId(0));
893 assert_eq!(before[1].id, FileId(1));
894 assert_eq!(before[2].id, FileId(2));
895 assert_eq!(after_delete[0].id, FileId(0));
896 assert_eq!(after_delete[1].id, FileId(1));
897 assert_eq!(
898 after_delete[1].path,
899 std::path::PathBuf::from("/project/src/c.ts")
900 );
901 assert_eq!(after_rename[0].id, FileId(0));
902 assert_eq!(after_rename[1].id, FileId(1));
903 assert_eq!(
904 after_rename[1].path,
905 std::path::PathBuf::from("/project/src/c.ts")
906 );
907 assert_eq!(after_rename[2].id, FileId(2));
908 assert_eq!(
909 after_rename[2].path,
910 std::path::PathBuf::from("/project/src/d.ts")
911 );
912 }
913
914 #[test]
915 fn allowed_hidden_dirs() {
916 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
917 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
918 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
919 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
920 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
921 }
922
923 #[test]
924 fn disallowed_hidden_dirs() {
925 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
926 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
927 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
928 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
929 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
930 }
931
932 #[test]
933 fn non_hidden_dirs_not_in_allowlist() {
934 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
935 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
936 }
937
938 #[test]
939 fn walk_types_match_every_supported_source_extension() {
940 for capture_config in [false, true] {
941 let types = build_walk_types(capture_config);
942 for extension in SOURCE_EXTENSIONS {
943 let path = format!("packages/ui/src/nested/component.{extension}");
944 assert!(
945 types.matched(&path, false).is_whitelist(),
946 "expected source match for {path} with capture_config={capture_config}"
947 );
948 }
949 }
950 }
951
952 #[test]
953 fn walk_types_match_typescript_declaration_files() {
954 let types = build_walk_types(true);
955 for path in [
956 "src/env.d.ts",
957 "packages/app/types/generated.d.mts",
958 "packages/app/types/compat.d.cts",
959 ] {
960 assert!(
961 types.matched(path, false).is_whitelist(),
962 "expected declaration source match for {path}"
963 );
964 }
965 }
966
967 #[test]
968 fn walk_types_reject_source_extension_near_misses() {
969 for capture_config in [false, true] {
970 let types = build_walk_types(capture_config);
971 for path in [
972 "src/component.tsx.bak",
973 "src/component.tsxmap",
974 "src/component.TS",
975 "src/component.gqlx",
976 "src/component.htm",
977 "src/component",
978 "assets/component.png",
979 ] {
980 assert!(
981 types.matched(path, false).is_ignore(),
982 "expected non-source rejection for {path} with capture_config={capture_config}"
983 );
984 }
985 }
986 }
987
988 #[test]
989 fn walk_types_keep_config_candidate_selection_separate() {
990 assert!(
991 build_walk_types(true)
992 .matched("packages/app/tsconfig.json", false)
993 .is_whitelist()
994 );
995 assert!(
996 build_walk_types(false)
997 .matched("packages/app/tsconfig.json", false)
998 .is_ignore()
999 );
1000 }
1001
1002 #[test]
1003 fn source_extensions_include_typescript() {
1004 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
1005 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
1006 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
1007 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
1008 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
1009 }
1010
1011 #[test]
1012 fn source_extensions_include_javascript() {
1013 assert!(SOURCE_EXTENSIONS.contains(&"js"));
1014 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
1015 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
1016 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
1017 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
1018 }
1019
1020 #[test]
1021 fn source_extensions_include_sfc_formats() {
1022 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
1023 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
1024 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
1025 }
1026
1027 #[test]
1028 fn source_extensions_include_styles() {
1029 assert!(SOURCE_EXTENSIONS.contains(&"css"));
1030 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
1031 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
1032 assert!(SOURCE_EXTENSIONS.contains(&"less"));
1033 }
1034
1035 #[test]
1036 fn source_extensions_exclude_non_source() {
1037 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
1038 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
1039 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
1040 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1041 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
1042 }
1043
1044 #[test]
1045 fn source_extensions_include_html() {
1046 assert!(SOURCE_EXTENSIONS.contains(&"html"));
1047 }
1048
1049 #[test]
1050 fn source_extensions_include_graphql_documents() {
1051 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
1052 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
1053 }
1054
1055 fn build_production_glob_set() -> globset::GlobSet {
1056 let mut builder = globset::GlobSetBuilder::new();
1057 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1058 builder.add(
1059 globset::GlobBuilder::new(pattern)
1060 .literal_separator(true)
1061 .build()
1062 .expect("valid glob pattern"),
1063 );
1064 }
1065 builder.build().expect("valid glob set")
1066 }
1067
1068 #[test]
1069 fn production_excludes_test_files() {
1070 let set = build_production_glob_set();
1071 assert!(set.is_match("src/Button.test.ts"));
1072 assert!(set.is_match("src/utils.spec.tsx"));
1073 assert!(set.is_match("src/__tests__/helper.ts"));
1074 assert!(!set.is_match("src/Button.ts"));
1075 assert!(!set.is_match("src/utils.tsx"));
1076 }
1077
1078 #[test]
1079 fn production_excludes_story_files() {
1080 let set = build_production_glob_set();
1081 assert!(set.is_match("src/Button.stories.tsx"));
1082 assert!(set.is_match("src/Card.story.ts"));
1083 assert!(!set.is_match("src/Button.tsx"));
1084 }
1085
1086 #[test]
1087 fn production_excludes_config_files_at_root_only() {
1088 let set = build_production_glob_set();
1089 assert!(set.is_match("vitest.config.ts"));
1090 assert!(set.is_match("jest.config.js"));
1091 assert!(!set.is_match("src/app/app.config.ts"));
1092 assert!(!set.is_match("src/app/app.config.server.ts"));
1093 assert!(!set.is_match("packages/foo/vitest.config.ts"));
1094 assert!(!set.is_match("src/config.ts"));
1095 }
1096
1097 #[test]
1098 fn production_patterns_are_valid_globs() {
1099 let _ = build_production_glob_set();
1100 }
1101
1102 #[test]
1103 fn disallowed_hidden_dirs_idea() {
1104 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1105 }
1106
1107 #[test]
1108 fn source_extensions_include_mdx() {
1109 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1110 }
1111
1112 #[test]
1113 fn source_extensions_exclude_image_and_data_formats() {
1114 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1115 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1116 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1117 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1118 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1119 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1120 }
1121
1122 #[test]
1123 fn is_declaration_file_matches_dts_variants() {
1124 assert!(is_declaration_file(Path::new("env.d.ts")));
1125 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1126 assert!(is_declaration_file(Path::new("mod.d.mts")));
1127 assert!(is_declaration_file(Path::new("compat.d.cts")));
1128 assert!(!is_declaration_file(Path::new("index.ts")));
1129 assert!(!is_declaration_file(Path::new("component.tsx")));
1130 assert!(!is_declaration_file(Path::new("notes.d.txt")));
1131 }
1132
1133 #[test]
1134 fn format_size_mb_renders_one_decimal() {
1135 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1136 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1137 assert_eq!(format_size_mb(0), "0.0 MB");
1138 }
1139
1140 #[test]
1141 fn partition_by_size_no_limit_keeps_all() {
1142 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1143 let (kept, skipped) = partition_by_size(raw, None);
1144 assert_eq!(kept.len(), 2);
1145 assert!(skipped.is_empty());
1146 }
1147
1148 #[test]
1149 fn partition_by_size_skips_strictly_over_limit() {
1150 let raw = vec![
1151 (PathBuf::from("under.ts"), 99),
1152 (PathBuf::from("exact.ts"), 100),
1153 (PathBuf::from("over.ts"), 101),
1154 ];
1155 let (kept, skipped) = partition_by_size(raw, Some(100));
1156 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1157 assert!(kept_has("under.ts"));
1158 assert!(
1159 kept_has("exact.ts"),
1160 "a file exactly at the limit is kept (skip is strictly-greater)"
1161 );
1162 assert_eq!(skipped.len(), 1);
1163 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1164 }
1165
1166 #[test]
1167 fn partition_by_size_exempts_declaration_files() {
1168 let raw = vec![
1169 (PathBuf::from("huge.ts"), 10_000),
1170 (PathBuf::from("auto-imports.d.ts"), 10_000),
1171 ];
1172 let (kept, skipped) = partition_by_size(raw, Some(100));
1173 assert!(
1174 kept.iter()
1175 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1176 "declaration files are exempt from the size skip regardless of size"
1177 );
1178 assert_eq!(skipped.len(), 1);
1179 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1180 }
1181
1182 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1183 DiscoveredFile {
1184 id: FileId(0),
1185 path: PathBuf::from(path),
1186 size_bytes,
1187 }
1188 }
1189
1190 #[test]
1191 fn largest_files_note_below_threshold_is_none() {
1192 let files = [disco("a.ts", 100), disco("b.ts", 200)];
1193 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1194 }
1195
1196 #[test]
1197 fn largest_files_note_single_file_uses_singular() {
1198 let files = [disco("big.ts", 5 * 1024 * 1024)];
1199 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1200 assert!(
1201 note.contains("discovered 1 file;"),
1202 "singular noun on the single-big-file path (issue #1086 regression): {note}"
1203 );
1204 assert!(!note.contains("discovered 1 files"));
1205 assert!(note.contains("big.ts (5.0 MB)"));
1206 }
1207
1208 #[test]
1209 fn largest_files_note_filters_sub_floor_files() {
1210 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1211 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1212 assert!(note.contains("discovered 2 files;"));
1213 assert!(note.contains("big.ts (5.0 MB)"));
1214 assert!(
1215 !note.contains("tiny.ts"),
1216 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1217 );
1218 }
1219
1220 #[test]
1221 fn largest_files_note_large_set_no_big_file_omits_list() {
1222 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1223 .map(|i| disco(&format!("f{i}.ts"), 100))
1224 .collect();
1225 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1226 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1227 assert!(
1228 !note.contains("largest:"),
1229 "no sub-floor `largest:` list when no file clears the floor: {note}"
1230 );
1231 }
1232
1233 mod discover_files_integration {
1234 use std::path::PathBuf;
1235
1236 use fallow_config::{
1237 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1238 RulesConfig,
1239 };
1240
1241 use super::*;
1242
1243 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1245 FallowConfig {
1246 production: production.into(),
1247 ..Default::default()
1248 }
1249 .resolve(root, OutputFormat::Human, 1, true, true, None)
1250 }
1251
1252 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1255 files
1256 .iter()
1257 .map(|f| {
1258 f.path
1259 .strip_prefix(root)
1260 .unwrap_or(&f.path)
1261 .to_string_lossy()
1262 .replace('\\', "/")
1263 })
1264 .collect()
1265 }
1266
1267 #[cfg(unix)]
1268 fn symlink_file(target: &Path, link: &Path) {
1269 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1270 }
1271
1272 #[cfg(windows)]
1273 fn symlink_file(target: &Path, link: &Path) {
1274 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1275 }
1276
1277 #[cfg(unix)]
1278 fn symlink_dir(target: &Path, link: &Path) {
1279 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1280 }
1281
1282 #[cfg(windows)]
1283 fn symlink_dir(target: &Path, link: &Path) {
1284 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1285 }
1286
1287 #[test]
1288 fn source_symlinks_must_target_regular_files_inside_root() {
1289 let dir = tempfile::tempdir().expect("create project");
1290 let outside = tempfile::tempdir().expect("create outside dir");
1291 let src = dir.path().join("src");
1292 std::fs::create_dir_all(&src).unwrap();
1293 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1294 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1295 std::fs::write(
1296 outside.path().join("outside-target.ts"),
1297 "export const outside = 1;",
1298 )
1299 .unwrap();
1300 std::fs::create_dir_all(src.join("directory-target")).unwrap();
1301
1302 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1303 symlink_file(
1304 &outside.path().join("outside-target.ts"),
1305 &src.join("outside-link.ts"),
1306 );
1307 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1308 symlink_dir(
1309 &src.join("directory-target"),
1310 &src.join("directory-link.ts"),
1311 );
1312
1313 let config = make_config(dir.path().to_path_buf(), false);
1314 let names = file_names(&discover_files(&config), dir.path());
1315
1316 assert!(names.contains(&"src/regular.ts".to_string()));
1317 assert!(names.contains(&"src/inside-target.ts".to_string()));
1318 assert!(names.contains(&"src/inside-link.ts".to_string()));
1319 assert!(!names.contains(&"src/outside-link.ts".to_string()));
1320 assert!(!names.contains(&"src/broken-link.ts".to_string()));
1321 assert!(!names.contains(&"src/directory-link.ts".to_string()));
1322 }
1323
1324 #[test]
1328 fn skips_yarn_pnp_generated_files() {
1329 let dir = tempfile::tempdir().expect("create temp dir");
1330 std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
1331 std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
1332 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1333
1334 let config = make_config(dir.path().to_path_buf(), false);
1335 let names = file_names(&discover_files(&config), dir.path());
1336
1337 assert_eq!(names, vec!["index.ts".to_string()]);
1338 }
1339
1340 #[test]
1341 fn discovers_source_files_with_valid_extensions() {
1342 let dir = tempfile::tempdir().expect("create temp dir");
1343 let src = dir.path().join("src");
1344 std::fs::create_dir_all(&src).unwrap();
1345
1346 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1347 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1348 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1349 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1350 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1351 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1352 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1353 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1354
1355 let config = make_config(dir.path().to_path_buf(), false);
1356 let files = discover_files(&config);
1357 let names = file_names(&files, dir.path());
1358
1359 assert!(names.contains(&"src/app.ts".to_string()));
1360 assert!(names.contains(&"src/component.tsx".to_string()));
1361 assert!(names.contains(&"src/utils.js".to_string()));
1362 assert!(names.contains(&"src/helper.jsx".to_string()));
1363 assert!(names.contains(&"src/config.mjs".to_string()));
1364 assert!(names.contains(&"src/legacy.cjs".to_string()));
1365 assert!(names.contains(&"src/types.mts".to_string()));
1366 assert!(names.contains(&"src/compat.cts".to_string()));
1367 }
1368
1369 #[test]
1370 fn compact_source_glob_preserves_discovered_file_inventory() {
1371 let dir = tempfile::tempdir().expect("create temp dir");
1372 let nested = dir.path().join("packages/ui/src/nested");
1373 std::fs::create_dir_all(&nested).unwrap();
1374
1375 let mut expected = Vec::new();
1376 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1377 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1378 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1379 expected.push(relative);
1380 }
1381 for relative in [
1382 "packages/ui/src/nested/env.d.ts",
1383 "packages/ui/src/nested/generated.d.mts",
1384 "packages/ui/src/nested/compat.d.cts",
1385 ] {
1386 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1387 expected.push(relative.to_string());
1388 }
1389 let rejected = [
1390 "packages/ui/src/nested/component.tsx.bak",
1391 "packages/ui/src/nested/component.tsxmap",
1392 "packages/ui/src/nested/component.TS",
1393 "packages/ui/src/nested/component.gqlx",
1394 "packages/ui/src/nested/component.htm",
1395 "packages/ui/src/nested/component",
1396 "packages/ui/src/nested/component.png",
1397 ];
1398 for relative in rejected {
1399 std::fs::write(dir.path().join(relative), "not source").unwrap();
1400 }
1401
1402 let config = make_config(dir.path().to_path_buf(), false);
1403 let names = file_names(&discover_files(&config), dir.path());
1404
1405 for relative in expected {
1406 assert!(
1407 names.contains(&relative),
1408 "missing supported source {relative}"
1409 );
1410 }
1411 for relative in rejected {
1412 assert!(
1413 !names.iter().any(|name| name == relative),
1414 "unexpected near-miss source {relative}"
1415 );
1416 }
1417 }
1418
1419 #[test]
1420 fn excludes_non_source_extensions() {
1421 let dir = tempfile::tempdir().expect("create temp dir");
1422 let src = dir.path().join("src");
1423 std::fs::create_dir_all(&src).unwrap();
1424
1425 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1426
1427 std::fs::write(src.join("data.json"), "{}").unwrap();
1428 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1429 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1430 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1431
1432 let config = make_config(dir.path().to_path_buf(), false);
1433 let files = discover_files(&config);
1434 let names = file_names(&files, dir.path());
1435
1436 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1437 assert!(names.contains(&"src/app.ts".to_string()));
1438 }
1439
1440 #[test]
1441 fn excludes_disallowed_hidden_directories() {
1442 let dir = tempfile::tempdir().expect("create temp dir");
1443
1444 let git_dir = dir.path().join(".git");
1445 std::fs::create_dir_all(&git_dir).unwrap();
1446 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1447
1448 let idea_dir = dir.path().join(".idea");
1449 std::fs::create_dir_all(&idea_dir).unwrap();
1450 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1451
1452 let cache_dir = dir.path().join(".cache");
1453 std::fs::create_dir_all(&cache_dir).unwrap();
1454 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1455
1456 let src = dir.path().join("src");
1457 std::fs::create_dir_all(&src).unwrap();
1458 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1459
1460 let config = make_config(dir.path().to_path_buf(), false);
1461 let files = discover_files(&config);
1462 let names = file_names(&files, dir.path());
1463
1464 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1465 assert!(names.contains(&"src/app.ts".to_string()));
1466 }
1467
1468 #[test]
1469 fn includes_allowed_hidden_directories() {
1470 let dir = tempfile::tempdir().expect("create temp dir");
1471
1472 let storybook = dir.path().join(".storybook");
1473 std::fs::create_dir_all(&storybook).unwrap();
1474 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1475
1476 let github = dir.path().join(".github");
1477 std::fs::create_dir_all(&github).unwrap();
1478 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1479
1480 let changeset = dir.path().join(".changeset");
1481 std::fs::create_dir_all(&changeset).unwrap();
1482 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1483
1484 let config = make_config(dir.path().to_path_buf(), false);
1485 let files = discover_files(&config);
1486 let names = file_names(&files, dir.path());
1487
1488 assert!(
1489 names.contains(&".storybook/main.ts".to_string()),
1490 "files in .storybook should be discovered"
1491 );
1492 assert!(
1493 names.contains(&".github/actions.js".to_string()),
1494 "files in .github should be discovered"
1495 );
1496 assert!(
1497 names.contains(&".changeset/config.js".to_string()),
1498 "files in .changeset should be discovered"
1499 );
1500 }
1501
1502 #[test]
1503 fn default_discovery_excludes_client_and_server_hidden_directories() {
1504 let dir = tempfile::tempdir().expect("create temp dir");
1505 let app = dir.path().join("app");
1506 std::fs::create_dir_all(app.join(".client")).unwrap();
1507 std::fs::create_dir_all(app.join(".server")).unwrap();
1508 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1509 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1510 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1511
1512 let config = make_config(dir.path().to_path_buf(), false);
1513 let files = discover_files(&config);
1514 let names = file_names(&files, dir.path());
1515
1516 assert!(names.contains(&"app/root.tsx".to_string()));
1517 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1518 assert!(!names.contains(&"app/.server/db.ts".to_string()));
1519 }
1520
1521 #[test]
1522 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1523 let dir = tempfile::tempdir().expect("create temp dir");
1524 let package = dir.path().join("packages/app");
1525 std::fs::create_dir_all(package.join("app/.client")).unwrap();
1526 std::fs::create_dir_all(package.join("app/.server")).unwrap();
1527 std::fs::write(
1528 package.join("app/.client/analytics.ts"),
1529 "export const track = () => {};",
1530 )
1531 .unwrap();
1532 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1533
1534 let config = make_config(dir.path().to_path_buf(), false);
1535 let scopes = [HiddenDirScope::new(
1536 package,
1537 vec![".client".to_string(), ".server".to_string()],
1538 )];
1539 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1540 let names = file_names(&files, dir.path());
1541
1542 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1543 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1544 }
1545
1546 #[test]
1547 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1548 let dir = tempfile::tempdir().expect("create temp dir");
1549 let active = dir.path().join("packages/active");
1550 let inactive = dir.path().join("packages/inactive");
1551 std::fs::create_dir_all(active.join("app/.server")).unwrap();
1552 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1553 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1554 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1555
1556 let config = make_config(dir.path().to_path_buf(), false);
1557 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1558 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1559 let names = file_names(&files, dir.path());
1560
1561 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1562 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1563 }
1564
1565 #[test]
1566 fn excludes_root_build_directory() {
1567 let dir = tempfile::tempdir().expect("create temp dir");
1568
1569 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1570
1571 let build_dir = dir.path().join("build");
1572 std::fs::create_dir_all(&build_dir).unwrap();
1573 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1574
1575 let src = dir.path().join("src");
1576 std::fs::create_dir_all(&src).unwrap();
1577 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1578
1579 let config = make_config(dir.path().to_path_buf(), false);
1580 let files = discover_files(&config);
1581 let names = file_names(&files, dir.path());
1582
1583 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1584 assert!(names.contains(&"src/app.ts".to_string()));
1585 }
1586
1587 #[test]
1588 fn includes_nested_build_directory() {
1589 let dir = tempfile::tempdir().expect("create temp dir");
1590
1591 let nested_build = dir.path().join("src").join("build");
1592 std::fs::create_dir_all(&nested_build).unwrap();
1593 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1594
1595 let config = make_config(dir.path().to_path_buf(), false);
1596 let files = discover_files(&config);
1597 let names = file_names(&files, dir.path());
1598
1599 assert!(
1600 names.contains(&"src/build/helper.ts".to_string()),
1601 "nested build/ directories should be included"
1602 );
1603 }
1604
1605 #[test]
1606 #[expect(
1607 clippy::cast_possible_truncation,
1608 reason = "test file counts are trivially small"
1609 )]
1610 fn file_ids_are_sequential_after_sorting() {
1611 let dir = tempfile::tempdir().expect("create temp dir");
1612 let src = dir.path().join("src");
1613 std::fs::create_dir_all(&src).unwrap();
1614
1615 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1616 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1617 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1618
1619 let config = make_config(dir.path().to_path_buf(), false);
1620 let files = discover_files(&config);
1621
1622 for (idx, file) in files.iter().enumerate() {
1623 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1624 }
1625
1626 for pair in files.windows(2) {
1627 assert!(
1628 pair[0].path < pair[1].path,
1629 "files should be sorted by path"
1630 );
1631 }
1632 }
1633
1634 #[test]
1635 fn production_mode_excludes_test_files() {
1636 let dir = tempfile::tempdir().expect("create temp dir");
1637 let src = dir.path().join("src");
1638 std::fs::create_dir_all(&src).unwrap();
1639
1640 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1641 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1642 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1643 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1644
1645 let config = make_config(dir.path().to_path_buf(), true);
1646 let files = discover_files(&config);
1647 let names = file_names(&files, dir.path());
1648
1649 assert!(
1650 names.contains(&"src/app.ts".to_string()),
1651 "source files should be included in production mode"
1652 );
1653 assert!(
1654 !names.contains(&"src/app.test.ts".to_string()),
1655 "test files should be excluded in production mode"
1656 );
1657 assert!(
1658 !names.contains(&"src/app.spec.ts".to_string()),
1659 "spec files should be excluded in production mode"
1660 );
1661 assert!(
1662 !names.contains(&"src/app.stories.tsx".to_string()),
1663 "story files should be excluded in production mode"
1664 );
1665 }
1666
1667 #[test]
1668 fn non_production_mode_includes_test_files() {
1669 let dir = tempfile::tempdir().expect("create temp dir");
1670 let src = dir.path().join("src");
1671 std::fs::create_dir_all(&src).unwrap();
1672
1673 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1674 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1675
1676 let config = make_config(dir.path().to_path_buf(), false);
1677 let files = discover_files(&config);
1678 let names = file_names(&files, dir.path());
1679
1680 assert!(names.contains(&"src/app.ts".to_string()));
1681 assert!(
1682 names.contains(&"src/app.test.ts".to_string()),
1683 "test files should be included in non-production mode"
1684 );
1685 }
1686
1687 #[test]
1688 fn empty_directory_returns_no_files() {
1689 let dir = tempfile::tempdir().expect("create temp dir");
1690 let config = make_config(dir.path().to_path_buf(), false);
1691 let files = discover_files(&config);
1692 assert!(files.is_empty(), "empty project should discover no files");
1693 }
1694
1695 #[test]
1696 fn hidden_files_not_discovered_as_source() {
1697 let dir = tempfile::tempdir().expect("create temp dir");
1698
1699 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1700 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1701 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1702
1703 let src = dir.path().join("src");
1704 std::fs::create_dir_all(&src).unwrap();
1705 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1706
1707 let config = make_config(dir.path().to_path_buf(), false);
1708 let files = discover_files(&config);
1709 let names = file_names(&files, dir.path());
1710
1711 assert!(
1712 !names.contains(&".env".to_string()),
1713 ".env should not be discovered"
1714 );
1715 assert!(
1716 !names.contains(&".gitignore".to_string()),
1717 ".gitignore should not be discovered"
1718 );
1719 }
1720
1721 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1723 FallowConfig {
1724 type_aware: fallow_config::TypeAwareConfig::default(),
1725 schema: None,
1726 extends: vec![],
1727 entry: vec![],
1728 ignore_patterns: ignores,
1729 ignore_findings: vec![],
1730 framework: vec![],
1731 workspaces: None,
1732 ignore_dependencies: vec![],
1733 ignore_unresolved_imports: vec![],
1734 ignore_exports: vec![],
1735 ignore_catalog_references: vec![],
1736 ignore_dependency_overrides: vec![],
1737 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1738 ),
1739 used_class_members: vec![],
1740 ignore_decorators: vec![],
1741 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1742 duplicates: DuplicatesConfig::default(),
1743 similar_code: fallow_config::SimilarCodeConfig::default(),
1744 health: HealthConfig::default(),
1745 rules: RulesConfig::default(),
1746 boundaries: fallow_config::BoundaryConfig::default(),
1747 production: false.into(),
1748 plugins: vec![],
1749 rule_packs: vec![],
1750 dynamically_loaded: vec![],
1751 overrides: vec![],
1752 regression: None,
1753 audit: fallow_config::AuditConfig::default(),
1754 codeowners: None,
1755 public_packages: vec![],
1756 flags: FlagsConfig::default(),
1757 security: fallow_config::SecurityConfig::default(),
1758 fix: fallow_config::FixConfig::default(),
1759 resolve: ResolveConfig::default(),
1760 sealed: false,
1761 include_entry_exports: false,
1762 auto_imports: false,
1763 cache: fallow_config::CacheConfig::default(),
1764 }
1765 .resolve(root, OutputFormat::Human, 1, true, true, None)
1766 }
1767
1768 #[test]
1769 fn custom_ignore_patterns_exclude_matching_files() {
1770 let dir = tempfile::tempdir().expect("create temp dir");
1771
1772 let generated = dir.path().join("src").join("api").join("generated");
1773 std::fs::create_dir_all(&generated).unwrap();
1774 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1775
1776 let client = dir.path().join("src").join("api").join("client");
1777 std::fs::create_dir_all(&client).unwrap();
1778 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1779
1780 let src = dir.path().join("src");
1781 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1782
1783 let config = make_config_with_ignores(
1784 dir.path().to_path_buf(),
1785 vec![
1786 "src/api/generated/**".to_string(),
1787 "src/api/client/**".to_string(),
1788 ],
1789 );
1790 let files = discover_files(&config);
1791 let names = file_names(&files, dir.path());
1792
1793 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1794 assert!(names.contains(&"src/index.ts".to_string()));
1795 }
1796
1797 #[test]
1798 fn leading_dot_ignore_patterns_exclude_matching_files() {
1799 let dir = tempfile::tempdir().expect("create temp dir");
1800
1801 let generated = dir.path().join("src").join("generated");
1802 std::fs::create_dir_all(&generated).unwrap();
1803 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1804
1805 let src = dir.path().join("src");
1806 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1807
1808 let config = make_config_with_ignores(
1809 dir.path().to_path_buf(),
1810 vec!["./src/generated/**".to_string()],
1811 );
1812 let files = discover_files(&config);
1813 let names = file_names(&files, dir.path());
1814
1815 assert_eq!(names, vec!["src/index.ts"]);
1816 }
1817
1818 #[test]
1819 fn default_ignore_patterns_exclude_node_modules_and_dist() {
1820 let dir = tempfile::tempdir().expect("create temp dir");
1821
1822 let nm = dir.path().join("node_modules").join("lodash");
1823 std::fs::create_dir_all(&nm).unwrap();
1824 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1825
1826 let dist = dir.path().join("dist");
1827 std::fs::create_dir_all(&dist).unwrap();
1828 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1829
1830 let src = dir.path().join("src");
1831 std::fs::create_dir_all(&src).unwrap();
1832 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1833
1834 let config = make_config(dir.path().to_path_buf(), false);
1835 let files = discover_files(&config);
1836 let names = file_names(&files, dir.path());
1837
1838 assert_eq!(names.len(), 1);
1839 assert!(names.contains(&"src/index.ts".to_string()));
1840 }
1841
1842 #[test]
1843 fn default_ignore_patterns_exclude_root_build() {
1844 let dir = tempfile::tempdir().expect("create temp dir");
1845
1846 let build = dir.path().join("build");
1847 std::fs::create_dir_all(&build).unwrap();
1848 std::fs::write(build.join("output.js"), "// built").unwrap();
1849
1850 let nested_build = dir.path().join("src").join("build");
1851 std::fs::create_dir_all(&nested_build).unwrap();
1852 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1853
1854 let src = dir.path().join("src");
1855 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1856
1857 let config = make_config(dir.path().to_path_buf(), false);
1858 let files = discover_files(&config);
1859 let names = file_names(&files, dir.path());
1860
1861 assert_eq!(
1862 names.len(),
1863 2,
1864 "root build/ excluded, nested kept: {names:?}"
1865 );
1866 assert!(names.contains(&"src/index.ts".to_string()));
1867 assert!(names.contains(&"src/build/helper.ts".to_string()));
1868 }
1869
1870 fn make_config_with_max_file_size(
1872 root: PathBuf,
1873 max_file_size_bytes: Option<u64>,
1874 ) -> ResolvedConfig {
1875 let mut config = make_config(root, false);
1876 config.max_file_size_bytes = max_file_size_bytes;
1877 config
1878 }
1879
1880 #[test]
1881 fn skips_files_over_max_file_size() {
1882 let dir = tempfile::tempdir().expect("create temp dir");
1883 let src = dir.path().join("src");
1884 std::fs::create_dir_all(&src).unwrap();
1885 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1886 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1887
1888 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1889 let files = discover_files(&config);
1890 let names = file_names(&files, dir.path());
1891
1892 assert!(names.contains(&"src/small.ts".to_string()));
1893 assert!(
1894 !names.contains(&"src/huge.ts".to_string()),
1895 "a file over the size limit must not be discovered"
1896 );
1897 }
1898
1899 #[test]
1900 fn declaration_files_exempt_from_size_skip() {
1901 let dir = tempfile::tempdir().expect("create temp dir");
1902 let src = dir.path().join("src");
1903 std::fs::create_dir_all(&src).unwrap();
1904 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1905 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1906
1907 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1908 let files = discover_files(&config);
1909 let names = file_names(&files, dir.path());
1910
1911 assert!(
1912 names.contains(&"src/auto-imports.d.ts".to_string()),
1913 "a large .d.ts is exempt from the skip (reachability root for global types)"
1914 );
1915 assert!(!names.contains(&"src/huge.ts".to_string()));
1916 }
1917
1918 #[test]
1919 fn unlimited_size_keeps_large_files() {
1920 let dir = tempfile::tempdir().expect("create temp dir");
1921 let src = dir.path().join("src");
1922 std::fs::create_dir_all(&src).unwrap();
1923 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1924
1925 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1926 let files = discover_files(&config);
1927 let names = file_names(&files, dir.path());
1928
1929 assert!(
1930 names.contains(&"src/huge.ts".to_string()),
1931 "no limit keeps every file"
1932 );
1933 }
1934
1935 #[test]
1936 fn skipped_file_recorded_in_workspace_diagnostics() {
1937 let dir = tempfile::tempdir().expect("create temp dir");
1938 let src = dir.path().join("src");
1939 std::fs::create_dir_all(&src).unwrap();
1940 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1941
1942 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1943 let _ = discover_files(&config);
1944
1945 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1946 let skipped: Vec<_> = diagnostics
1947 .iter()
1948 .filter(|d| {
1949 matches!(
1950 d.kind,
1951 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1952 )
1953 })
1954 .collect();
1955 assert_eq!(
1956 skipped.len(),
1957 1,
1958 "the skipped file is recorded in workspace diagnostics for JSON output"
1959 );
1960 assert!(skipped[0].path.ends_with("src/huge.ts"));
1961 assert!(
1962 matches!(
1963 skipped[0].kind,
1964 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1965 if size_bytes == 5_000
1966 ),
1967 "the recorded diagnostic carries the on-disk byte size"
1968 );
1969 }
1970
1971 #[test]
1972 fn skips_large_one_line_js_as_minified_generated_output() {
1973 let dir = tempfile::tempdir().expect("create temp dir");
1974 let src = dir.path().join("src");
1975 std::fs::create_dir_all(&src).unwrap();
1976 let asset = src.join("index-abc123.js");
1977 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1978
1979 let config = make_config(dir.path().to_path_buf(), false);
1980 let files = discover_files(&config);
1981 let names = file_names(&files, dir.path());
1982
1983 assert!(
1984 !names.contains(&"src/index-abc123.js".to_string()),
1985 "large one-line JS assets should be skipped before parsing"
1986 );
1987
1988 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1989 assert!(
1990 diagnostics.iter().any(|diag| {
1991 diag.path.ends_with("src/index-abc123.js")
1992 && matches!(
1993 diag.kind,
1994 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1995 )
1996 }),
1997 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1998 );
1999 }
2000
2001 #[test]
2002 fn unlimited_size_keeps_large_one_line_js() {
2003 let dir = tempfile::tempdir().expect("create temp dir");
2004 let src = dir.path().join("src");
2005 std::fs::create_dir_all(&src).unwrap();
2006 let asset = src.join("index-abc123.js");
2007 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2008
2009 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2010 let files = discover_files(&config);
2011 let names = file_names(&files, dir.path());
2012
2013 assert!(
2014 names.contains(&"src/index-abc123.js".to_string()),
2015 "--max-file-size 0 should opt out of generated JS skipping"
2016 );
2017 }
2018
2019 #[test]
2020 fn keeps_large_multiline_js() {
2021 let dir = tempfile::tempdir().expect("create temp dir");
2022 let src = dir.path().join("src");
2023 std::fs::create_dir_all(&src).unwrap();
2024 let asset = src.join("handwritten.js");
2025 let mut content = String::new();
2026 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
2027 content.push_str("export const value = 1;\n");
2028 }
2029 std::fs::write(&asset, content).unwrap();
2030
2031 let config = make_config(dir.path().to_path_buf(), false);
2032 let files = discover_files(&config);
2033 let names = file_names(&files, dir.path());
2034
2035 assert!(
2036 names.contains(&"src/handwritten.js".to_string()),
2037 "large multiline JS should not be treated as a generated minified asset"
2038 );
2039 }
2040 }
2041}