Skip to main content

fallow_core/discover/
walk.rs

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
12/// Process-wide dedupe of the size-skip / largest-files stderr notes, keyed by a
13/// content-derived string, so combined-mode (`fallow` runs check + dupes +
14/// health, each of which can trigger a source walk) emits each note at most once
15/// per distinct content. Mirrors the workspace-diagnostics `should_emit`
16/// pattern (issue #1086).
17fn 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
25/// A discovered file path paired with its on-disk size in bytes, as collected
26/// by the parallel walker before [`DiscoveredFile`] ids are assigned.
27type SizedFile = (PathBuf, u64);
28
29/// Number of example file paths named in the aggregated skipped-large-file and
30/// largest-files stderr notes before the tail collapses to "and N more". Keeps
31/// the notes to one bounded line on a monorepo that skips many files.
32const NOTE_EXAMPLE_CAP: usize = 5;
33
34/// Discovered-file-count threshold above which the pre-parse largest-files note
35/// fires, so an out-of-memory hang at the parse stage has a visible suspect
36/// list (issue #1086).
37const LARGE_SET_THRESHOLD: usize = 20_000;
38
39/// Single-file byte threshold above which the pre-parse largest-files note
40/// fires even on a small project. Set just under the default 5 MB skip so the
41/// note fires for kept files that are approaching the skip limit (the genuine
42/// out-of-memory suspects), not for ordinary large-but-benign files.
43const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
44
45/// Minimum size for a file to appear in the largest-files note. Filters out the
46/// `0.0 MB` entries that would otherwise pad the list once it fires, keeping the
47/// named files to plausible memory contributors.
48const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
49
50/// Minimum size for content-shape based minified-bundle skipping. Smaller
51/// one-line files can be hand-written utilities, while multi-MB one-line JS is
52/// generated output in practice.
53const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
54
55/// Number of bytes inspected when deciding whether a large JS file is minified.
56const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
57
58/// A single line this long in a multi-MB JS file is treated as generated
59/// minified output. This avoids parsing assets that can expand to huge ASTs.
60const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
61
62/// Whether a path is a TypeScript declaration file (`.d.ts`/`.d.mts`/`.d.cts`).
63/// Declaration files are exempt from the per-file size skip because they are
64/// reachability roots for global types: skipping a large `auto-imports.d.ts`
65/// would false-flag the files whose types it provides.
66fn 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
114/// Render a byte count as a megabyte figure with one decimal place.
115fn 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
124/// Join up to [`NOTE_EXAMPLE_CAP`] `path (size)` examples (already ordered) into
125/// one comma-separated string, collapsing the tail to "and N more".
126fn 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
148/// Split discovered `(path, size)` pairs into the kept set and the set skipped
149/// for exceeding `max_file_size_bytes`. Declaration files are never skipped.
150fn 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
161/// Split discovered `(path, size)` pairs into files kept for parsing and files
162/// skipped because they look like generated minified JavaScript.
163fn 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
174/// Record the skipped files in the workspace-diagnostics registry (so they
175/// surface in `workspace_diagnostics[]` JSON) and emit one aggregated
176/// `tracing::warn!` so a human running `fallow` sees what was dropped. Mirrors
177/// the JSON-plus-gated-warn pattern used for undeclared workspaces.
178fn report_skipped_large_files(config: &ResolvedConfig, skipped: &[SizedFile]) {
179    if skipped.is_empty() {
180        return;
181    }
182    let diagnostics: Vec<WorkspaceDiagnostic> = skipped
183        .iter()
184        .map(|(path, size_bytes)| {
185            WorkspaceDiagnostic::new(
186                &config.root,
187                path.clone(),
188                WorkspaceDiagnosticKind::SkippedLargeFile {
189                    size_bytes: *size_bytes,
190                },
191            )
192        })
193        .collect();
194    fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
195
196    let mut sorted: Vec<SizedFile> = skipped.to_vec();
197    sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
198    let count = skipped.len();
199    if !config.quiet
200        && should_emit_note_once(format!(
201            "skip::{}::{count}::{}",
202            config.root.display(),
203            sorted.first().map_or(0, |f| f.1)
204        ))
205    {
206        let examples = summarize_examples(&config.root, &sorted);
207        let noun = if count == 1 { "file" } else { "files" };
208        tracing::warn!(
209            "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
210             Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
211        );
212    }
213}
214
215/// Record generated minified JS files skipped before parsing.
216fn report_skipped_minified_files(config: &ResolvedConfig, skipped: &[SizedFile]) {
217    if skipped.is_empty() {
218        return;
219    }
220    let diagnostics: Vec<WorkspaceDiagnostic> = skipped
221        .iter()
222        .map(|(path, size_bytes)| {
223            WorkspaceDiagnostic::new(
224                &config.root,
225                path.clone(),
226                WorkspaceDiagnosticKind::SkippedMinifiedFile {
227                    size_bytes: *size_bytes,
228                },
229            )
230        })
231        .collect();
232    fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
233
234    let mut sorted: Vec<SizedFile> = skipped.to_vec();
235    sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
236    let count = skipped.len();
237    if !config.quiet
238        && should_emit_note_once(format!(
239            "minified::{}::{count}::{}",
240            config.root.display(),
241            sorted.first().map_or(0, |f| f.1)
242        ))
243    {
244        let examples = summarize_examples(&config.root, &sorted);
245        let noun = if count == 1 { "file" } else { "files" };
246        let pronoun = if count == 1 { "it" } else { "them" };
247        tracing::warn!(
248            "fallow: skipped {count} minified generated JS {noun} ({examples}). \
249             Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
250        );
251    }
252}
253
254/// Build the pre-parse largest-files note, or `None` when the discovered set is
255/// neither unusually large nor contains an unusually large file. Pure so the
256/// pluralization, floor filtering, and count-only fallback are unit-testable
257/// without a tracing subscriber. See issue #1086.
258fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
259    if files.is_empty() {
260        return None;
261    }
262    let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
263    if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
264        return None;
265    }
266    let count = files.len();
267    let noun = if count == 1 { "file" } else { "files" };
268    let mut by_size: Vec<SizedFile> = files
269        .iter()
270        .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
271        .map(|f| (f.path.clone(), f.size_bytes))
272        .collect();
273    by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
274    if by_size.is_empty() {
275        // Large file SET with no individually large file: report the count only,
276        // omitting a "largest:" list that would otherwise be all sub-floor noise.
277        return Some(format!(
278            "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
279             exclude large generated files via ignorePatterns or --max-file-size."
280        ));
281    }
282    let examples = summarize_examples(root, &by_size);
283    Some(format!(
284        "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
285         exclude large generated files via ignorePatterns or --max-file-size."
286    ))
287}
288
289/// Emit a pre-parse note listing the largest kept files when the discovered set
290/// is unusually large or contains an unusually large file, so an out-of-memory
291/// hang at the parse stage is diagnosable (issue #1086). Visible before the
292/// expensive parse begins, so it survives a subsequent crash.
293fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
294    if config.quiet {
295        return;
296    }
297    if let Some(message) = build_largest_files_note(&config.root, files)
298        && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
299    {
300        tracing::warn!("{message}");
301    }
302}
303
304/// Package-scoped hidden directories that source discovery should traverse.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct HiddenDirScope {
307    root: PathBuf,
308    dirs: Vec<String>,
309}
310
311impl HiddenDirScope {
312    /// Build a scope rooted at a package directory that admits the given
313    /// hidden directory names during the walk.
314    #[must_use]
315    pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
316        Self { root, dirs }
317    }
318
319    #[must_use]
320    pub fn root(&self) -> &Path {
321        &self.root
322    }
323
324    #[must_use]
325    pub fn dirs(&self) -> &[String] {
326        &self.dirs
327    }
328
329    fn allows(&self, path: &Path, name: &OsStr) -> bool {
330        path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
331    }
332}
333
334/// Per-thread file collector for the parallel walker.
335///
336/// Source files (by extension) flow to `shared`; when `config_shared` is set,
337/// non-source files admitted by the config-candidate type group flow to it
338/// instead. The two channels are disjoint and the source channel is byte-for-byte
339/// identical to the config-capture-disabled walk.
340struct FileVisitor<'a> {
341    root: &'a Path,
342    canonical_root: Option<&'a Path>,
343    ignore_patterns: &'a globset::GlobSet,
344    production_excludes: &'a Option<globset::GlobSet>,
345    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
346    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
347    local: Vec<(std::path::PathBuf, u64)>,
348    config_local: Vec<std::path::PathBuf>,
349}
350
351impl ignore::ParallelVisitor for FileVisitor<'_> {
352    fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
353        let Ok(entry) = result else {
354            return ignore::WalkState::Continue;
355        };
356        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
357            return ignore::WalkState::Continue;
358        }
359        let relative = entry
360            .path()
361            .strip_prefix(self.root)
362            .unwrap_or_else(|_| entry.path());
363        if self.ignore_patterns.is_match(relative) {
364            return ignore::WalkState::Continue;
365        }
366        if self
367            .production_excludes
368            .as_ref()
369            .is_some_and(|excludes| excludes.is_match(relative))
370        {
371            return ignore::WalkState::Continue;
372        }
373        let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
374            let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
375                tracing::debug!(
376                    path = %entry.path().display(),
377                    "skipping source symlink with a broken, non-file, or outside-root target"
378                );
379                return ignore::WalkState::Continue;
380            };
381            Some(size)
382        } else {
383            None
384        };
385        if has_source_extension(entry.path()) {
386            let size_bytes =
387                symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
388            self.local.push((entry.into_path(), size_bytes));
389        } else if self.config_shared.is_some() {
390            // A non-source file admitted by the config-candidate type group. No
391            // size metadata is needed; these are pattern-matched, never parsed.
392            self.config_local.push(entry.into_path());
393        }
394        ignore::WalkState::Continue
395    }
396}
397
398fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
399    let root = canonical_root?;
400    let target = path.canonicalize().ok()?;
401    if !target.starts_with(root) {
402        return None;
403    }
404    let metadata = target.metadata().ok()?;
405    metadata.is_file().then_some(metadata.len())
406}
407
408impl Drop for FileVisitor<'_> {
409    #[expect(
410        clippy::expect_used,
411        reason = "poisoned walk collector lock means worker state is unrecoverable"
412    )]
413    fn drop(&mut self) {
414        if !self.local.is_empty() {
415            self.shared
416                .lock()
417                .expect("walk collector lock poisoned")
418                .append(&mut self.local);
419        }
420        if let Some(config_shared) = self.config_shared
421            && !self.config_local.is_empty()
422        {
423            config_shared
424                .lock()
425                .expect("walk config collector lock poisoned")
426                .append(&mut self.config_local);
427        }
428    }
429}
430
431/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
432struct FileVisitorBuilder<'a> {
433    root: &'a Path,
434    canonical_root: Option<&'a Path>,
435    ignore_patterns: &'a globset::GlobSet,
436    production_excludes: &'a Option<globset::GlobSet>,
437    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
438    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
439}
440
441impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
442    fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
443        Box::new(FileVisitor {
444            root: self.root,
445            canonical_root: self.canonical_root,
446            ignore_patterns: self.ignore_patterns,
447            production_excludes: self.production_excludes,
448            shared: self.shared,
449            config_shared: self.config_shared,
450            local: Vec::new(),
451            config_local: Vec::new(),
452        })
453    }
454}
455
456/// File extensions discovery treats as analyzable source files.
457pub const SOURCE_EXTENSIONS: &[&str] = &[
458    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
459    "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
460];
461
462/// Glob patterns for test/dev/story files excluded in production mode.
463pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
464    "**/*.test.*",
465    "**/*.spec.*",
466    "**/*.e2e.*",
467    "**/*.e2e-spec.*",
468    "**/*.bench.*",
469    "**/*.fixture.*",
470    "**/*.stories.*",
471    "**/*.story.*",
472    "**/__tests__/**",
473    "**/__mocks__/**",
474    "**/__snapshots__/**",
475    "**/__fixtures__/**",
476    "**/test/**",
477    "**/tests/**",
478    "*.config.*",
479    "**/.*.js",
480    "**/.*.ts",
481    "**/.*.mjs",
482    "**/.*.cjs",
483];
484
485/// Check if a hidden directory name is on the allowlist.
486pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
487    ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
488}
489
490fn is_allowed_scoped_hidden_dir(
491    name: &OsStr,
492    path: &Path,
493    additional_hidden_dir_scopes: &[HiddenDirScope],
494) -> bool {
495    additional_hidden_dir_scopes
496        .iter()
497        .any(|scope| scope.allows(path, name))
498}
499
500/// Check if a hidden directory entry should be allowed through the filter.
501///
502/// Returns `true` if the entry is not hidden or is on the allowlist.
503/// Hidden files (not directories) are always allowed through since the type
504/// filter handles them.
505fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
506    is_allowed_hidden_with_scopes(entry, &[])
507}
508
509fn is_allowed_hidden_with_scopes(
510    entry: &ignore::DirEntry,
511    additional_hidden_dir_scopes: &[HiddenDirScope],
512) -> bool {
513    let name = entry.file_name();
514    let name_str = name.to_string_lossy();
515
516    if !name_str.starts_with('.') {
517        return true;
518    }
519
520    if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
521        return true;
522    }
523
524    is_allowed_hidden_dir(name)
525        || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
526}
527
528/// Discover all source files in the project.
529///
530/// # Panics
531///
532/// Panics if the file type glob or progress template is invalid (compile-time constants).
533pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
534    discover_files_with_additional_hidden_dirs(config, &[])
535}
536
537/// The set of config-file basenames (last path component of every built-in
538/// plugin `config_patterns()` entry, brace forms preserved) that the walk should
539/// additionally admit so non-source configs (`tsconfig.json`, `bunfig.toml`,
540/// `.eslintrc.json`, ...) can be captured in one traversal instead of being
541/// re-discovered by a filesystem re-walk in `discover_config_files`.
542///
543/// Derived live from the built-in plugin list, so it can never drift behind a
544/// new plugin's config patterns. Source-extension config basenames
545/// (`vite.config.{ts,js}`) are admitted too, but the walk visitor routes them
546/// back to the source channel by extension, so the config channel only ever
547/// collects genuinely non-source files.
548fn config_candidate_basename_globs() -> &'static [String] {
549    static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
550    GLOBS.get_or_init(|| {
551        let mut set: FxHashSet<String> = FxHashSet::default();
552        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
553            for pattern in plugin.config_patterns() {
554                let basename = pattern.rsplit('/').next().unwrap_or(pattern);
555                set.insert(basename.to_string());
556            }
557        }
558        let mut globs: Vec<String> = set.into_iter().collect();
559        globs.sort_unstable();
560        globs
561    })
562}
563
564/// True when `path`'s extension is one of the known source extensions, i.e. the
565/// file belongs in the source channel rather than the config-candidate channel.
566fn has_source_extension(path: &Path) -> bool {
567    path.extension()
568        .and_then(OsStr::to_str)
569        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
570}
571
572/// Build the file-type filter. Always selects known source extensions; when
573/// `capture_config` is set, also selects config-candidate basenames so the
574/// walker yields them for the second collection channel.
575#[expect(
576    clippy::expect_used,
577    reason = "source file globs are hard-coded compile-time constants"
578)]
579fn build_walk_types(capture_config: bool) -> ignore::types::Types {
580    let mut types_builder = ignore::types::TypesBuilder::new();
581    let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
582    types_builder
583        .add("source", &source_glob)
584        .expect("valid glob");
585    types_builder.select("source");
586    if capture_config {
587        for glob in config_candidate_basename_globs() {
588            // Ignore individually-invalid plugin patterns rather than panicking;
589            // a malformed pattern simply fails to admit its config file (the
590            // pre-existing filesystem fallback still covers production mode).
591            let _ = types_builder.add("config", glob);
592        }
593        types_builder.select("config");
594    }
595    types_builder.build().expect("valid types")
596}
597
598/// Construct the parallel walker, applying the appropriate hidden-dir filter.
599/// When `capture_config` is set the walk also yields config-candidate files for
600/// the secondary collection channel.
601fn build_source_walk_builder(
602    config: &ResolvedConfig,
603    additional_hidden_dir_scopes: &[HiddenDirScope],
604    capture_config: bool,
605) -> WalkBuilder {
606    let mut walk_builder = WalkBuilder::new(&config.root);
607    walk_builder
608        .hidden(false)
609        .git_ignore(true)
610        .git_global(true)
611        .git_exclude(true)
612        .types(build_walk_types(capture_config))
613        .threads(config.threads);
614    if additional_hidden_dir_scopes.is_empty() {
615        walk_builder.filter_entry(is_allowed_hidden);
616    } else {
617        let scopes = additional_hidden_dir_scopes.to_vec();
618        walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
619    }
620    walk_builder
621}
622
623/// Compile the production-mode exclude glob set, or `None` outside production mode.
624fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
625    if !config.production {
626        return None;
627    }
628    let mut builder = globset::GlobSetBuilder::new();
629    for pattern in PRODUCTION_EXCLUDE_PATTERNS {
630        if let Ok(glob) = globset::GlobBuilder::new(pattern)
631            .literal_separator(true)
632            .build()
633        {
634            builder.add(glob);
635        }
636    }
637    builder.build().ok()
638}
639
640/// Discover all source files in the project, with package-scoped hidden dirs.
641///
642/// # Panics
643///
644/// Panics if the file type glob or progress template is invalid (compile-time constants).
645pub fn discover_files_with_additional_hidden_dirs(
646    config: &ResolvedConfig,
647    additional_hidden_dir_scopes: &[HiddenDirScope],
648) -> Vec<DiscoveredFile> {
649    discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
650}
651
652/// Discover source files AND, in one traversal, the non-source config-candidate
653/// files (`tsconfig.json`, `bunfig.toml`, `.eslintrc.json`, ...) used by
654/// `discover_config_files` to resolve plugin config patterns in-memory instead of
655/// re-walking the filesystem.
656///
657/// The returned `Vec<DiscoveredFile>` is byte-for-byte identical to the
658/// config-capture-disabled walk: config candidates are routed to the second
659/// return value by extension and never enter the source channel. Config capture
660/// is skipped in production mode (where the walk applies `PRODUCTION_EXCLUDE_PATTERNS`
661/// and `discover_config_files` keeps its filesystem path), so the second vector is
662/// empty there.
663///
664/// # Panics
665///
666/// Panics if the file type glob or progress template is invalid (compile-time constants).
667#[expect(
668    clippy::cast_possible_truncation,
669    reason = "file count is bounded by project size, well under u32::MAX"
670)]
671#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
672pub fn discover_files_and_config_candidates(
673    config: &ResolvedConfig,
674    additional_hidden_dir_scopes: &[HiddenDirScope],
675) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
676    let _span = tracing::info_span!("discover_files").entered();
677
678    let capture_config = !config.production;
679    let walk_builder =
680        build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
681    let production_excludes = build_production_excludes(config);
682    let canonical_root = config.root.canonicalize().ok();
683
684    let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
685    let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
686    let mut visitor_builder = FileVisitorBuilder {
687        root: &config.root,
688        canonical_root: canonical_root.as_deref(),
689        ignore_patterns: &config.ignore_patterns,
690        production_excludes: &production_excludes,
691        shared: &collected,
692        config_shared: capture_config.then_some(&config_collected),
693    };
694    walk_builder.build_parallel().visit(&mut visitor_builder);
695
696    let mut raw = collected
697        .into_inner()
698        .expect("walk collector lock poisoned");
699    // ADR-004 (path-sorted FileIds): the parallel walk visits files in
700    // nondeterministic order, so we sort by absolute path BEFORE the
701    // `.enumerate()` FileId assignment below. This is the stable-cross-run
702    // identity invariant the persisted graph cache depends on: an identical
703    // file set yields identical FileIds, so a cache hit (same paths +
704    // fingerprints) can trust graph data persisted by FileId. Do not replace
705    // this with insertion-order assignment.
706    raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
707
708    let mut config_candidates = config_collected
709        .into_inner()
710        .expect("walk config collector lock poisoned");
711    config_candidates.sort_unstable();
712
713    // Drop any source-discovery diagnostics from a previous pass (watch-mode
714    // rerun, combined-mode re-walk) BEFORE re-recording this walk's skips, so a
715    // file that is no longer skipped does not leave a stale entry (issue #1086).
716    fallow_config::clear_source_discovery_diagnostics(&config.root);
717    let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
718    report_skipped_large_files(config, &skipped);
719    let (kept, skipped_minified) =
720        partition_minified_generated_js(kept, config.max_file_size_bytes);
721    report_skipped_minified_files(config, &skipped_minified);
722
723    let files: Vec<DiscoveredFile> = kept
724        .into_iter()
725        .enumerate()
726        .map(|(idx, (path, size_bytes))| DiscoveredFile {
727            id: FileId(idx as u32),
728            path,
729            size_bytes,
730        })
731        .collect();
732
733    note_largest_files(config, &files);
734
735    (files, config_candidates)
736}
737
738#[cfg(test)]
739mod tests {
740    use std::ffi::OsStr;
741
742    use super::*;
743
744    /// Reproduce the FileId-assignment rule used by `walk_source_files`: sort by
745    /// absolute path, then assign `FileId(idx)` in that order.
746    fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
747        raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
748        raw.into_iter()
749            .enumerate()
750            .map(|(idx, (path, size_bytes))| DiscoveredFile {
751                id: FileId(idx as u32),
752                path,
753                size_bytes,
754            })
755            .collect()
756    }
757
758    /// ADR-004: an identical file set must yield identical FileIds regardless of
759    /// the (nondeterministic, parallel) discovery order. The persisted graph
760    /// cache keys persisted graph data by FileId, so a cache HIT (same paths +
761    /// fingerprints) must reproduce the exact same FileId-to-path mapping the
762    /// graph was built against. This guards the cache's soundness prerequisite.
763    #[test]
764    fn file_id_assignment_is_deterministic_for_identical_file_set() {
765        let paths = [
766            "/project/src/z.ts",
767            "/project/src/a.ts",
768            "/project/src/components/Button.tsx",
769            "/project/src/components/Button.module.css",
770            "/project/index.ts",
771        ];
772
773        // Two independent walks that observe the same paths in DIFFERENT orders.
774        let walk_one: Vec<(std::path::PathBuf, u64)> = paths
775            .iter()
776            .map(|p| (std::path::PathBuf::from(p), 10))
777            .collect();
778        let mut walk_two = walk_one.clone();
779        walk_two.reverse();
780
781        let files_one = assign_file_ids(walk_one);
782        let files_two = assign_file_ids(walk_two);
783
784        // Identical (FileId -> path) mapping despite the different walk orders.
785        assert_eq!(files_one.len(), files_two.len());
786        for (a, b) in files_one.iter().zip(files_two.iter()) {
787            assert_eq!(a.id, b.id);
788            assert_eq!(a.path, b.path);
789        }
790
791        // The mapping is the path-sorted order, and each FileId equals its index
792        // (the density invariant `project.rs` asserts and the graph relies on).
793        for (idx, file) in files_one.iter().enumerate() {
794            assert_eq!(file.id, FileId(idx as u32));
795        }
796        assert_eq!(
797            files_one[0].path,
798            std::path::PathBuf::from("/project/index.ts")
799        );
800    }
801
802    #[test]
803    fn file_id_assignment_recomputes_after_rename_or_delete() {
804        let before = assign_file_ids(vec![
805            (std::path::PathBuf::from("/project/src/a.ts"), 10),
806            (std::path::PathBuf::from("/project/src/b.ts"), 10),
807            (std::path::PathBuf::from("/project/src/c.ts"), 10),
808        ]);
809        let after_delete = assign_file_ids(vec![
810            (std::path::PathBuf::from("/project/src/a.ts"), 10),
811            (std::path::PathBuf::from("/project/src/c.ts"), 10),
812        ]);
813        let after_rename = assign_file_ids(vec![
814            (std::path::PathBuf::from("/project/src/a.ts"), 10),
815            (std::path::PathBuf::from("/project/src/c.ts"), 10),
816            (std::path::PathBuf::from("/project/src/d.ts"), 10),
817        ]);
818
819        assert_eq!(before[0].id, FileId(0));
820        assert_eq!(before[1].id, FileId(1));
821        assert_eq!(before[2].id, FileId(2));
822        assert_eq!(after_delete[0].id, FileId(0));
823        assert_eq!(after_delete[1].id, FileId(1));
824        assert_eq!(
825            after_delete[1].path,
826            std::path::PathBuf::from("/project/src/c.ts")
827        );
828        assert_eq!(after_rename[0].id, FileId(0));
829        assert_eq!(after_rename[1].id, FileId(1));
830        assert_eq!(
831            after_rename[1].path,
832            std::path::PathBuf::from("/project/src/c.ts")
833        );
834        assert_eq!(after_rename[2].id, FileId(2));
835        assert_eq!(
836            after_rename[2].path,
837            std::path::PathBuf::from("/project/src/d.ts")
838        );
839    }
840
841    #[test]
842    fn allowed_hidden_dirs() {
843        assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
844        assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
845        assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
846        assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
847        assert!(is_allowed_hidden_dir(OsStr::new(".github")));
848    }
849
850    #[test]
851    fn disallowed_hidden_dirs() {
852        assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
853        assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
854        assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
855        assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
856        assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
857    }
858
859    #[test]
860    fn non_hidden_dirs_not_in_allowlist() {
861        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
862        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
863    }
864
865    #[test]
866    fn walk_types_match_every_supported_source_extension() {
867        for capture_config in [false, true] {
868            let types = build_walk_types(capture_config);
869            for extension in SOURCE_EXTENSIONS {
870                let path = format!("packages/ui/src/nested/component.{extension}");
871                assert!(
872                    types.matched(&path, false).is_whitelist(),
873                    "expected source match for {path} with capture_config={capture_config}"
874                );
875            }
876        }
877    }
878
879    #[test]
880    fn walk_types_match_typescript_declaration_files() {
881        let types = build_walk_types(true);
882        for path in [
883            "src/env.d.ts",
884            "packages/app/types/generated.d.mts",
885            "packages/app/types/compat.d.cts",
886        ] {
887            assert!(
888                types.matched(path, false).is_whitelist(),
889                "expected declaration source match for {path}"
890            );
891        }
892    }
893
894    #[test]
895    fn walk_types_reject_source_extension_near_misses() {
896        for capture_config in [false, true] {
897            let types = build_walk_types(capture_config);
898            for path in [
899                "src/component.tsx.bak",
900                "src/component.tsxmap",
901                "src/component.TS",
902                "src/component.gqlx",
903                "src/component.htm",
904                "src/component",
905                "assets/component.png",
906            ] {
907                assert!(
908                    types.matched(path, false).is_ignore(),
909                    "expected non-source rejection for {path} with capture_config={capture_config}"
910                );
911            }
912        }
913    }
914
915    #[test]
916    fn walk_types_keep_config_candidate_selection_separate() {
917        assert!(
918            build_walk_types(true)
919                .matched("packages/app/tsconfig.json", false)
920                .is_whitelist()
921        );
922        assert!(
923            build_walk_types(false)
924                .matched("packages/app/tsconfig.json", false)
925                .is_ignore()
926        );
927    }
928
929    #[test]
930    fn source_extensions_include_typescript() {
931        assert!(SOURCE_EXTENSIONS.contains(&"ts"));
932        assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
933        assert!(SOURCE_EXTENSIONS.contains(&"mts"));
934        assert!(SOURCE_EXTENSIONS.contains(&"cts"));
935        assert!(SOURCE_EXTENSIONS.contains(&"gts"));
936    }
937
938    #[test]
939    fn source_extensions_include_javascript() {
940        assert!(SOURCE_EXTENSIONS.contains(&"js"));
941        assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
942        assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
943        assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
944        assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
945    }
946
947    #[test]
948    fn source_extensions_include_sfc_formats() {
949        assert!(SOURCE_EXTENSIONS.contains(&"vue"));
950        assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
951        assert!(SOURCE_EXTENSIONS.contains(&"astro"));
952    }
953
954    #[test]
955    fn source_extensions_include_styles() {
956        assert!(SOURCE_EXTENSIONS.contains(&"css"));
957        assert!(SOURCE_EXTENSIONS.contains(&"scss"));
958        assert!(SOURCE_EXTENSIONS.contains(&"sass"));
959        assert!(SOURCE_EXTENSIONS.contains(&"less"));
960    }
961
962    #[test]
963    fn source_extensions_exclude_non_source() {
964        assert!(!SOURCE_EXTENSIONS.contains(&"json"));
965        assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
966        assert!(!SOURCE_EXTENSIONS.contains(&"md"));
967        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
968        assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
969    }
970
971    #[test]
972    fn source_extensions_include_html() {
973        assert!(SOURCE_EXTENSIONS.contains(&"html"));
974    }
975
976    #[test]
977    fn source_extensions_include_graphql_documents() {
978        assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
979        assert!(SOURCE_EXTENSIONS.contains(&"gql"));
980    }
981
982    fn build_production_glob_set() -> globset::GlobSet {
983        let mut builder = globset::GlobSetBuilder::new();
984        for pattern in PRODUCTION_EXCLUDE_PATTERNS {
985            builder.add(
986                globset::GlobBuilder::new(pattern)
987                    .literal_separator(true)
988                    .build()
989                    .expect("valid glob pattern"),
990            );
991        }
992        builder.build().expect("valid glob set")
993    }
994
995    #[test]
996    fn production_excludes_test_files() {
997        let set = build_production_glob_set();
998        assert!(set.is_match("src/Button.test.ts"));
999        assert!(set.is_match("src/utils.spec.tsx"));
1000        assert!(set.is_match("src/__tests__/helper.ts"));
1001        assert!(!set.is_match("src/Button.ts"));
1002        assert!(!set.is_match("src/utils.tsx"));
1003    }
1004
1005    #[test]
1006    fn production_excludes_story_files() {
1007        let set = build_production_glob_set();
1008        assert!(set.is_match("src/Button.stories.tsx"));
1009        assert!(set.is_match("src/Card.story.ts"));
1010        assert!(!set.is_match("src/Button.tsx"));
1011    }
1012
1013    #[test]
1014    fn production_excludes_config_files_at_root_only() {
1015        let set = build_production_glob_set();
1016        assert!(set.is_match("vitest.config.ts"));
1017        assert!(set.is_match("jest.config.js"));
1018        assert!(!set.is_match("src/app/app.config.ts"));
1019        assert!(!set.is_match("src/app/app.config.server.ts"));
1020        assert!(!set.is_match("packages/foo/vitest.config.ts"));
1021        assert!(!set.is_match("src/config.ts"));
1022    }
1023
1024    #[test]
1025    fn production_patterns_are_valid_globs() {
1026        let _ = build_production_glob_set();
1027    }
1028
1029    #[test]
1030    fn disallowed_hidden_dirs_idea() {
1031        assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1032    }
1033
1034    #[test]
1035    fn source_extensions_include_mdx() {
1036        assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1037    }
1038
1039    #[test]
1040    fn source_extensions_exclude_image_and_data_formats() {
1041        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1042        assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1043        assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1044        assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1045        assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1046        assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1047    }
1048
1049    #[test]
1050    fn is_declaration_file_matches_dts_variants() {
1051        assert!(is_declaration_file(Path::new("env.d.ts")));
1052        assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1053        assert!(is_declaration_file(Path::new("mod.d.mts")));
1054        assert!(is_declaration_file(Path::new("compat.d.cts")));
1055        assert!(!is_declaration_file(Path::new("index.ts")));
1056        assert!(!is_declaration_file(Path::new("component.tsx")));
1057        assert!(!is_declaration_file(Path::new("notes.d.txt")));
1058    }
1059
1060    #[test]
1061    fn format_size_mb_renders_one_decimal() {
1062        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1063        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1064        assert_eq!(format_size_mb(0), "0.0 MB");
1065    }
1066
1067    #[test]
1068    fn partition_by_size_no_limit_keeps_all() {
1069        let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1070        let (kept, skipped) = partition_by_size(raw, None);
1071        assert_eq!(kept.len(), 2);
1072        assert!(skipped.is_empty());
1073    }
1074
1075    #[test]
1076    fn partition_by_size_skips_strictly_over_limit() {
1077        let raw = vec![
1078            (PathBuf::from("under.ts"), 99),
1079            (PathBuf::from("exact.ts"), 100),
1080            (PathBuf::from("over.ts"), 101),
1081        ];
1082        let (kept, skipped) = partition_by_size(raw, Some(100));
1083        let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1084        assert!(kept_has("under.ts"));
1085        assert!(
1086            kept_has("exact.ts"),
1087            "a file exactly at the limit is kept (skip is strictly-greater)"
1088        );
1089        assert_eq!(skipped.len(), 1);
1090        assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1091    }
1092
1093    #[test]
1094    fn partition_by_size_exempts_declaration_files() {
1095        let raw = vec![
1096            (PathBuf::from("huge.ts"), 10_000),
1097            (PathBuf::from("auto-imports.d.ts"), 10_000),
1098        ];
1099        let (kept, skipped) = partition_by_size(raw, Some(100));
1100        assert!(
1101            kept.iter()
1102                .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1103            "declaration files are exempt from the size skip regardless of size"
1104        );
1105        assert_eq!(skipped.len(), 1);
1106        assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1107    }
1108
1109    fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1110        DiscoveredFile {
1111            id: FileId(0),
1112            path: PathBuf::from(path),
1113            size_bytes,
1114        }
1115    }
1116
1117    #[test]
1118    fn largest_files_note_below_threshold_is_none() {
1119        let files = [disco("a.ts", 100), disco("b.ts", 200)];
1120        assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1121    }
1122
1123    #[test]
1124    fn largest_files_note_single_file_uses_singular() {
1125        let files = [disco("big.ts", 5 * 1024 * 1024)];
1126        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1127        assert!(
1128            note.contains("discovered 1 file;"),
1129            "singular noun on the single-big-file path (issue #1086 regression): {note}"
1130        );
1131        assert!(!note.contains("discovered 1 files"));
1132        assert!(note.contains("big.ts (5.0 MB)"));
1133    }
1134
1135    #[test]
1136    fn largest_files_note_filters_sub_floor_files() {
1137        let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1138        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1139        assert!(note.contains("discovered 2 files;"));
1140        assert!(note.contains("big.ts (5.0 MB)"));
1141        assert!(
1142            !note.contains("tiny.ts"),
1143            "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1144        );
1145    }
1146
1147    #[test]
1148    fn largest_files_note_large_set_no_big_file_omits_list() {
1149        let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1150            .map(|i| disco(&format!("f{i}.ts"), 100))
1151            .collect();
1152        let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1153        assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1154        assert!(
1155            !note.contains("largest:"),
1156            "no sub-floor `largest:` list when no file clears the floor: {note}"
1157        );
1158    }
1159
1160    mod discover_files_integration {
1161        use std::path::PathBuf;
1162
1163        use fallow_config::{
1164            DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1165            RulesConfig,
1166        };
1167
1168        use super::*;
1169
1170        /// Create a minimal ResolvedConfig pointing at the given root directory.
1171        fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1172            FallowConfig {
1173                production: production.into(),
1174                ..Default::default()
1175            }
1176            .resolve(root, OutputFormat::Human, 1, true, true, None)
1177        }
1178
1179        /// Helper to collect discovered file names (relative to root) for assertions.
1180        /// Normalizes path separators to `/` for cross-platform test consistency.
1181        fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1182            files
1183                .iter()
1184                .map(|f| {
1185                    f.path
1186                        .strip_prefix(root)
1187                        .unwrap_or(&f.path)
1188                        .to_string_lossy()
1189                        .replace('\\', "/")
1190                })
1191                .collect()
1192        }
1193
1194        #[cfg(unix)]
1195        fn symlink_file(target: &Path, link: &Path) {
1196            std::os::unix::fs::symlink(target, link).expect("create file symlink");
1197        }
1198
1199        #[cfg(windows)]
1200        fn symlink_file(target: &Path, link: &Path) {
1201            std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1202        }
1203
1204        #[cfg(unix)]
1205        fn symlink_dir(target: &Path, link: &Path) {
1206            std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1207        }
1208
1209        #[cfg(windows)]
1210        fn symlink_dir(target: &Path, link: &Path) {
1211            std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1212        }
1213
1214        #[test]
1215        fn source_symlinks_must_target_regular_files_inside_root() {
1216            let dir = tempfile::tempdir().expect("create project");
1217            let outside = tempfile::tempdir().expect("create outside dir");
1218            let src = dir.path().join("src");
1219            std::fs::create_dir_all(&src).unwrap();
1220            std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1221            std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1222            std::fs::write(
1223                outside.path().join("outside-target.ts"),
1224                "export const outside = 1;",
1225            )
1226            .unwrap();
1227            std::fs::create_dir_all(src.join("directory-target")).unwrap();
1228
1229            symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1230            symlink_file(
1231                &outside.path().join("outside-target.ts"),
1232                &src.join("outside-link.ts"),
1233            );
1234            symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1235            symlink_dir(
1236                &src.join("directory-target"),
1237                &src.join("directory-link.ts"),
1238            );
1239
1240            let config = make_config(dir.path().to_path_buf(), false);
1241            let names = file_names(&discover_files(&config), dir.path());
1242
1243            assert!(names.contains(&"src/regular.ts".to_string()));
1244            assert!(names.contains(&"src/inside-target.ts".to_string()));
1245            assert!(names.contains(&"src/inside-link.ts".to_string()));
1246            assert!(!names.contains(&"src/outside-link.ts".to_string()));
1247            assert!(!names.contains(&"src/broken-link.ts".to_string()));
1248            assert!(!names.contains(&"src/directory-link.ts".to_string()));
1249        }
1250
1251        #[test]
1252        fn discovers_source_files_with_valid_extensions() {
1253            let dir = tempfile::tempdir().expect("create temp dir");
1254            let src = dir.path().join("src");
1255            std::fs::create_dir_all(&src).unwrap();
1256
1257            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1258            std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1259            std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1260            std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1261            std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1262            std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1263            std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1264            std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1265
1266            let config = make_config(dir.path().to_path_buf(), false);
1267            let files = discover_files(&config);
1268            let names = file_names(&files, dir.path());
1269
1270            assert!(names.contains(&"src/app.ts".to_string()));
1271            assert!(names.contains(&"src/component.tsx".to_string()));
1272            assert!(names.contains(&"src/utils.js".to_string()));
1273            assert!(names.contains(&"src/helper.jsx".to_string()));
1274            assert!(names.contains(&"src/config.mjs".to_string()));
1275            assert!(names.contains(&"src/legacy.cjs".to_string()));
1276            assert!(names.contains(&"src/types.mts".to_string()));
1277            assert!(names.contains(&"src/compat.cts".to_string()));
1278        }
1279
1280        #[test]
1281        fn compact_source_glob_preserves_discovered_file_inventory() {
1282            let dir = tempfile::tempdir().expect("create temp dir");
1283            let nested = dir.path().join("packages/ui/src/nested");
1284            std::fs::create_dir_all(&nested).unwrap();
1285
1286            let mut expected = Vec::new();
1287            for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1288                let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1289                std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1290                expected.push(relative);
1291            }
1292            for relative in [
1293                "packages/ui/src/nested/env.d.ts",
1294                "packages/ui/src/nested/generated.d.mts",
1295                "packages/ui/src/nested/compat.d.cts",
1296            ] {
1297                std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1298                expected.push(relative.to_string());
1299            }
1300            let rejected = [
1301                "packages/ui/src/nested/component.tsx.bak",
1302                "packages/ui/src/nested/component.tsxmap",
1303                "packages/ui/src/nested/component.TS",
1304                "packages/ui/src/nested/component.gqlx",
1305                "packages/ui/src/nested/component.htm",
1306                "packages/ui/src/nested/component",
1307                "packages/ui/src/nested/component.png",
1308            ];
1309            for relative in rejected {
1310                std::fs::write(dir.path().join(relative), "not source").unwrap();
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            for relative in expected {
1317                assert!(
1318                    names.contains(&relative),
1319                    "missing supported source {relative}"
1320                );
1321            }
1322            for relative in rejected {
1323                assert!(
1324                    !names.iter().any(|name| name == relative),
1325                    "unexpected near-miss source {relative}"
1326                );
1327            }
1328        }
1329
1330        #[test]
1331        fn excludes_non_source_extensions() {
1332            let dir = tempfile::tempdir().expect("create temp dir");
1333            let src = dir.path().join("src");
1334            std::fs::create_dir_all(&src).unwrap();
1335
1336            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1337
1338            std::fs::write(src.join("data.json"), "{}").unwrap();
1339            std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1340            std::fs::write(src.join("notes.txt"), "notes").unwrap();
1341            std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1342
1343            let config = make_config(dir.path().to_path_buf(), false);
1344            let files = discover_files(&config);
1345            let names = file_names(&files, dir.path());
1346
1347            assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1348            assert!(names.contains(&"src/app.ts".to_string()));
1349        }
1350
1351        #[test]
1352        fn excludes_disallowed_hidden_directories() {
1353            let dir = tempfile::tempdir().expect("create temp dir");
1354
1355            let git_dir = dir.path().join(".git");
1356            std::fs::create_dir_all(&git_dir).unwrap();
1357            std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1358
1359            let idea_dir = dir.path().join(".idea");
1360            std::fs::create_dir_all(&idea_dir).unwrap();
1361            std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1362
1363            let cache_dir = dir.path().join(".cache");
1364            std::fs::create_dir_all(&cache_dir).unwrap();
1365            std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1366
1367            let src = dir.path().join("src");
1368            std::fs::create_dir_all(&src).unwrap();
1369            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1370
1371            let config = make_config(dir.path().to_path_buf(), false);
1372            let files = discover_files(&config);
1373            let names = file_names(&files, dir.path());
1374
1375            assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1376            assert!(names.contains(&"src/app.ts".to_string()));
1377        }
1378
1379        #[test]
1380        fn includes_allowed_hidden_directories() {
1381            let dir = tempfile::tempdir().expect("create temp dir");
1382
1383            let storybook = dir.path().join(".storybook");
1384            std::fs::create_dir_all(&storybook).unwrap();
1385            std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1386
1387            let github = dir.path().join(".github");
1388            std::fs::create_dir_all(&github).unwrap();
1389            std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1390
1391            let changeset = dir.path().join(".changeset");
1392            std::fs::create_dir_all(&changeset).unwrap();
1393            std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1394
1395            let config = make_config(dir.path().to_path_buf(), false);
1396            let files = discover_files(&config);
1397            let names = file_names(&files, dir.path());
1398
1399            assert!(
1400                names.contains(&".storybook/main.ts".to_string()),
1401                "files in .storybook should be discovered"
1402            );
1403            assert!(
1404                names.contains(&".github/actions.js".to_string()),
1405                "files in .github should be discovered"
1406            );
1407            assert!(
1408                names.contains(&".changeset/config.js".to_string()),
1409                "files in .changeset should be discovered"
1410            );
1411        }
1412
1413        #[test]
1414        fn default_discovery_excludes_client_and_server_hidden_directories() {
1415            let dir = tempfile::tempdir().expect("create temp dir");
1416            let app = dir.path().join("app");
1417            std::fs::create_dir_all(app.join(".client")).unwrap();
1418            std::fs::create_dir_all(app.join(".server")).unwrap();
1419            std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1420            std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1421            std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1422
1423            let config = make_config(dir.path().to_path_buf(), false);
1424            let files = discover_files(&config);
1425            let names = file_names(&files, dir.path());
1426
1427            assert!(names.contains(&"app/root.tsx".to_string()));
1428            assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1429            assert!(!names.contains(&"app/.server/db.ts".to_string()));
1430        }
1431
1432        #[test]
1433        fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1434            let dir = tempfile::tempdir().expect("create temp dir");
1435            let package = dir.path().join("packages/app");
1436            std::fs::create_dir_all(package.join("app/.client")).unwrap();
1437            std::fs::create_dir_all(package.join("app/.server")).unwrap();
1438            std::fs::write(
1439                package.join("app/.client/analytics.ts"),
1440                "export const track = () => {};",
1441            )
1442            .unwrap();
1443            std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1444
1445            let config = make_config(dir.path().to_path_buf(), false);
1446            let scopes = [HiddenDirScope::new(
1447                package,
1448                vec![".client".to_string(), ".server".to_string()],
1449            )];
1450            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1451            let names = file_names(&files, dir.path());
1452
1453            assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1454            assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1455        }
1456
1457        #[test]
1458        fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1459            let dir = tempfile::tempdir().expect("create temp dir");
1460            let active = dir.path().join("packages/active");
1461            let inactive = dir.path().join("packages/inactive");
1462            std::fs::create_dir_all(active.join("app/.server")).unwrap();
1463            std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1464            std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1465            std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1466
1467            let config = make_config(dir.path().to_path_buf(), false);
1468            let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1469            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1470            let names = file_names(&files, dir.path());
1471
1472            assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1473            assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1474        }
1475
1476        #[test]
1477        fn excludes_root_build_directory() {
1478            let dir = tempfile::tempdir().expect("create temp dir");
1479
1480            std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1481
1482            let build_dir = dir.path().join("build");
1483            std::fs::create_dir_all(&build_dir).unwrap();
1484            std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1485
1486            let src = dir.path().join("src");
1487            std::fs::create_dir_all(&src).unwrap();
1488            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1489
1490            let config = make_config(dir.path().to_path_buf(), false);
1491            let files = discover_files(&config);
1492            let names = file_names(&files, dir.path());
1493
1494            assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1495            assert!(names.contains(&"src/app.ts".to_string()));
1496        }
1497
1498        #[test]
1499        fn includes_nested_build_directory() {
1500            let dir = tempfile::tempdir().expect("create temp dir");
1501
1502            let nested_build = dir.path().join("src").join("build");
1503            std::fs::create_dir_all(&nested_build).unwrap();
1504            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1505
1506            let config = make_config(dir.path().to_path_buf(), false);
1507            let files = discover_files(&config);
1508            let names = file_names(&files, dir.path());
1509
1510            assert!(
1511                names.contains(&"src/build/helper.ts".to_string()),
1512                "nested build/ directories should be included"
1513            );
1514        }
1515
1516        #[test]
1517        #[expect(
1518            clippy::cast_possible_truncation,
1519            reason = "test file counts are trivially small"
1520        )]
1521        fn file_ids_are_sequential_after_sorting() {
1522            let dir = tempfile::tempdir().expect("create temp dir");
1523            let src = dir.path().join("src");
1524            std::fs::create_dir_all(&src).unwrap();
1525
1526            std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1527            std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1528            std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1529
1530            let config = make_config(dir.path().to_path_buf(), false);
1531            let files = discover_files(&config);
1532
1533            for (idx, file) in files.iter().enumerate() {
1534                assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1535            }
1536
1537            for pair in files.windows(2) {
1538                assert!(
1539                    pair[0].path < pair[1].path,
1540                    "files should be sorted by path"
1541                );
1542            }
1543        }
1544
1545        #[test]
1546        fn production_mode_excludes_test_files() {
1547            let dir = tempfile::tempdir().expect("create temp dir");
1548            let src = dir.path().join("src");
1549            std::fs::create_dir_all(&src).unwrap();
1550
1551            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1552            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1553            std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1554            std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1555
1556            let config = make_config(dir.path().to_path_buf(), true);
1557            let files = discover_files(&config);
1558            let names = file_names(&files, dir.path());
1559
1560            assert!(
1561                names.contains(&"src/app.ts".to_string()),
1562                "source files should be included in production mode"
1563            );
1564            assert!(
1565                !names.contains(&"src/app.test.ts".to_string()),
1566                "test files should be excluded in production mode"
1567            );
1568            assert!(
1569                !names.contains(&"src/app.spec.ts".to_string()),
1570                "spec files should be excluded in production mode"
1571            );
1572            assert!(
1573                !names.contains(&"src/app.stories.tsx".to_string()),
1574                "story files should be excluded in production mode"
1575            );
1576        }
1577
1578        #[test]
1579        fn non_production_mode_includes_test_files() {
1580            let dir = tempfile::tempdir().expect("create temp dir");
1581            let src = dir.path().join("src");
1582            std::fs::create_dir_all(&src).unwrap();
1583
1584            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1585            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1586
1587            let config = make_config(dir.path().to_path_buf(), false);
1588            let files = discover_files(&config);
1589            let names = file_names(&files, dir.path());
1590
1591            assert!(names.contains(&"src/app.ts".to_string()));
1592            assert!(
1593                names.contains(&"src/app.test.ts".to_string()),
1594                "test files should be included in non-production mode"
1595            );
1596        }
1597
1598        #[test]
1599        fn empty_directory_returns_no_files() {
1600            let dir = tempfile::tempdir().expect("create temp dir");
1601            let config = make_config(dir.path().to_path_buf(), false);
1602            let files = discover_files(&config);
1603            assert!(files.is_empty(), "empty project should discover no files");
1604        }
1605
1606        #[test]
1607        fn hidden_files_not_discovered_as_source() {
1608            let dir = tempfile::tempdir().expect("create temp dir");
1609
1610            std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1611            std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1612            std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1613
1614            let src = dir.path().join("src");
1615            std::fs::create_dir_all(&src).unwrap();
1616            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1617
1618            let config = make_config(dir.path().to_path_buf(), false);
1619            let files = discover_files(&config);
1620            let names = file_names(&files, dir.path());
1621
1622            assert!(
1623                !names.contains(&".env".to_string()),
1624                ".env should not be discovered"
1625            );
1626            assert!(
1627                !names.contains(&".gitignore".to_string()),
1628                ".gitignore should not be discovered"
1629            );
1630        }
1631
1632        /// Create a config with custom ignore patterns.
1633        fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1634            FallowConfig {
1635                type_aware: fallow_config::TypeAwareConfig::default(),
1636                schema: None,
1637                extends: vec![],
1638                entry: vec![],
1639                ignore_patterns: ignores,
1640                ignore_findings: vec![],
1641                framework: vec![],
1642                workspaces: None,
1643                ignore_dependencies: vec![],
1644                ignore_unresolved_imports: vec![],
1645                ignore_exports: vec![],
1646                ignore_catalog_references: vec![],
1647                ignore_dependency_overrides: vec![],
1648                ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1649                ),
1650                used_class_members: vec![],
1651                ignore_decorators: vec![],
1652                unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1653                duplicates: DuplicatesConfig::default(),
1654                health: HealthConfig::default(),
1655                rules: RulesConfig::default(),
1656                boundaries: fallow_config::BoundaryConfig::default(),
1657                production: false.into(),
1658                plugins: vec![],
1659                rule_packs: vec![],
1660                dynamically_loaded: vec![],
1661                overrides: vec![],
1662                regression: None,
1663                audit: fallow_config::AuditConfig::default(),
1664                codeowners: None,
1665                public_packages: vec![],
1666                flags: FlagsConfig::default(),
1667                security: fallow_config::SecurityConfig::default(),
1668                fix: fallow_config::FixConfig::default(),
1669                resolve: ResolveConfig::default(),
1670                sealed: false,
1671                include_entry_exports: false,
1672                auto_imports: false,
1673                cache: fallow_config::CacheConfig::default(),
1674            }
1675            .resolve(root, OutputFormat::Human, 1, true, true, None)
1676        }
1677
1678        #[test]
1679        fn custom_ignore_patterns_exclude_matching_files() {
1680            let dir = tempfile::tempdir().expect("create temp dir");
1681
1682            let generated = dir.path().join("src").join("api").join("generated");
1683            std::fs::create_dir_all(&generated).unwrap();
1684            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1685
1686            let client = dir.path().join("src").join("api").join("client");
1687            std::fs::create_dir_all(&client).unwrap();
1688            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1689
1690            let src = dir.path().join("src");
1691            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1692
1693            let config = make_config_with_ignores(
1694                dir.path().to_path_buf(),
1695                vec![
1696                    "src/api/generated/**".to_string(),
1697                    "src/api/client/**".to_string(),
1698                ],
1699            );
1700            let files = discover_files(&config);
1701            let names = file_names(&files, dir.path());
1702
1703            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1704            assert!(names.contains(&"src/index.ts".to_string()));
1705        }
1706
1707        #[test]
1708        fn leading_dot_ignore_patterns_exclude_matching_files() {
1709            let dir = tempfile::tempdir().expect("create temp dir");
1710
1711            let generated = dir.path().join("src").join("generated");
1712            std::fs::create_dir_all(&generated).unwrap();
1713            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1714
1715            let src = dir.path().join("src");
1716            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1717
1718            let config = make_config_with_ignores(
1719                dir.path().to_path_buf(),
1720                vec!["./src/generated/**".to_string()],
1721            );
1722            let files = discover_files(&config);
1723            let names = file_names(&files, dir.path());
1724
1725            assert_eq!(names, vec!["src/index.ts"]);
1726        }
1727
1728        #[test]
1729        fn default_ignore_patterns_exclude_node_modules_and_dist() {
1730            let dir = tempfile::tempdir().expect("create temp dir");
1731
1732            let nm = dir.path().join("node_modules").join("lodash");
1733            std::fs::create_dir_all(&nm).unwrap();
1734            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1735
1736            let dist = dir.path().join("dist");
1737            std::fs::create_dir_all(&dist).unwrap();
1738            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1739
1740            let src = dir.path().join("src");
1741            std::fs::create_dir_all(&src).unwrap();
1742            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1743
1744            let config = make_config(dir.path().to_path_buf(), false);
1745            let files = discover_files(&config);
1746            let names = file_names(&files, dir.path());
1747
1748            assert_eq!(names.len(), 1);
1749            assert!(names.contains(&"src/index.ts".to_string()));
1750        }
1751
1752        #[test]
1753        fn default_ignore_patterns_exclude_root_build() {
1754            let dir = tempfile::tempdir().expect("create temp dir");
1755
1756            let build = dir.path().join("build");
1757            std::fs::create_dir_all(&build).unwrap();
1758            std::fs::write(build.join("output.js"), "// built").unwrap();
1759
1760            let nested_build = dir.path().join("src").join("build");
1761            std::fs::create_dir_all(&nested_build).unwrap();
1762            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1763
1764            let src = dir.path().join("src");
1765            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1766
1767            let config = make_config(dir.path().to_path_buf(), false);
1768            let files = discover_files(&config);
1769            let names = file_names(&files, dir.path());
1770
1771            assert_eq!(
1772                names.len(),
1773                2,
1774                "root build/ excluded, nested kept: {names:?}"
1775            );
1776            assert!(names.contains(&"src/index.ts".to_string()));
1777            assert!(names.contains(&"src/build/helper.ts".to_string()));
1778        }
1779
1780        /// Resolve a config then override the per-file size limit in bytes.
1781        fn make_config_with_max_file_size(
1782            root: PathBuf,
1783            max_file_size_bytes: Option<u64>,
1784        ) -> ResolvedConfig {
1785            let mut config = make_config(root, false);
1786            config.max_file_size_bytes = max_file_size_bytes;
1787            config
1788        }
1789
1790        #[test]
1791        fn skips_files_over_max_file_size() {
1792            let dir = tempfile::tempdir().expect("create temp dir");
1793            let src = dir.path().join("src");
1794            std::fs::create_dir_all(&src).unwrap();
1795            std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1796            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1797
1798            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1799            let files = discover_files(&config);
1800            let names = file_names(&files, dir.path());
1801
1802            assert!(names.contains(&"src/small.ts".to_string()));
1803            assert!(
1804                !names.contains(&"src/huge.ts".to_string()),
1805                "a file over the size limit must not be discovered"
1806            );
1807        }
1808
1809        #[test]
1810        fn declaration_files_exempt_from_size_skip() {
1811            let dir = tempfile::tempdir().expect("create temp dir");
1812            let src = dir.path().join("src");
1813            std::fs::create_dir_all(&src).unwrap();
1814            std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1815            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1816
1817            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1818            let files = discover_files(&config);
1819            let names = file_names(&files, dir.path());
1820
1821            assert!(
1822                names.contains(&"src/auto-imports.d.ts".to_string()),
1823                "a large .d.ts is exempt from the skip (reachability root for global types)"
1824            );
1825            assert!(!names.contains(&"src/huge.ts".to_string()));
1826        }
1827
1828        #[test]
1829        fn unlimited_size_keeps_large_files() {
1830            let dir = tempfile::tempdir().expect("create temp dir");
1831            let src = dir.path().join("src");
1832            std::fs::create_dir_all(&src).unwrap();
1833            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1834
1835            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1836            let files = discover_files(&config);
1837            let names = file_names(&files, dir.path());
1838
1839            assert!(
1840                names.contains(&"src/huge.ts".to_string()),
1841                "no limit keeps every file"
1842            );
1843        }
1844
1845        #[test]
1846        fn skipped_file_recorded_in_workspace_diagnostics() {
1847            let dir = tempfile::tempdir().expect("create temp dir");
1848            let src = dir.path().join("src");
1849            std::fs::create_dir_all(&src).unwrap();
1850            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1851
1852            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1853            let _ = discover_files(&config);
1854
1855            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1856            let skipped: Vec<_> = diagnostics
1857                .iter()
1858                .filter(|d| {
1859                    matches!(
1860                        d.kind,
1861                        fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1862                    )
1863                })
1864                .collect();
1865            assert_eq!(
1866                skipped.len(),
1867                1,
1868                "the skipped file is recorded in workspace diagnostics for JSON output"
1869            );
1870            assert!(skipped[0].path.ends_with("src/huge.ts"));
1871            assert!(
1872                matches!(
1873                    skipped[0].kind,
1874                    fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1875                        if size_bytes == 5_000
1876                ),
1877                "the recorded diagnostic carries the on-disk byte size"
1878            );
1879        }
1880
1881        #[test]
1882        fn skips_large_one_line_js_as_minified_generated_output() {
1883            let dir = tempfile::tempdir().expect("create temp dir");
1884            let src = dir.path().join("src");
1885            std::fs::create_dir_all(&src).unwrap();
1886            let asset = src.join("index-abc123.js");
1887            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1888
1889            let config = make_config(dir.path().to_path_buf(), false);
1890            let files = discover_files(&config);
1891            let names = file_names(&files, dir.path());
1892
1893            assert!(
1894                !names.contains(&"src/index-abc123.js".to_string()),
1895                "large one-line JS assets should be skipped before parsing"
1896            );
1897
1898            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1899            assert!(
1900                diagnostics.iter().any(|diag| {
1901                    diag.path.ends_with("src/index-abc123.js")
1902                        && matches!(
1903                            diag.kind,
1904                            fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1905                        )
1906                }),
1907                "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1908            );
1909        }
1910
1911        #[test]
1912        fn unlimited_size_keeps_large_one_line_js() {
1913            let dir = tempfile::tempdir().expect("create temp dir");
1914            let src = dir.path().join("src");
1915            std::fs::create_dir_all(&src).unwrap();
1916            let asset = src.join("index-abc123.js");
1917            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1918
1919            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1920            let files = discover_files(&config);
1921            let names = file_names(&files, dir.path());
1922
1923            assert!(
1924                names.contains(&"src/index-abc123.js".to_string()),
1925                "--max-file-size 0 should opt out of generated JS skipping"
1926            );
1927        }
1928
1929        #[test]
1930        fn keeps_large_multiline_js() {
1931            let dir = tempfile::tempdir().expect("create temp dir");
1932            let src = dir.path().join("src");
1933            std::fs::create_dir_all(&src).unwrap();
1934            let asset = src.join("handwritten.js");
1935            let mut content = String::new();
1936            while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1937                content.push_str("export const value = 1;\n");
1938            }
1939            std::fs::write(&asset, content).unwrap();
1940
1941            let config = make_config(dir.path().to_path_buf(), false);
1942            let files = discover_files(&config);
1943            let names = file_names(&files, dir.path());
1944
1945            assert!(
1946                names.contains(&"src/handwritten.js".to_string()),
1947                "large multiline JS should not be treated as a generated minified asset"
1948            );
1949        }
1950    }
1951}