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/// Build the typed diagnostics for the over-limit files this walk dropped and
175/// emit one aggregated `tracing::warn!` so a human running `fallow` sees what
176/// was dropped. Mirrors the JSON-plus-gated-warn pattern used for undeclared
177/// workspaces. The caller writes the returned list to the registry.
178fn 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
218/// Build the typed diagnostics for generated minified JS files skipped before
219/// parsing. The caller writes the returned list to the registry.
220fn 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
261/// Build the pre-parse largest-files note, or `None` when the discovered set is
262/// neither unusually large nor contains an unusually large file. Pure so the
263/// pluralization, floor filtering, and count-only fallback are unit-testable
264/// without a tracing subscriber. See issue #1086.
265fn 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        // Large file SET with no individually large file: report the count only,
283        // omitting a "largest:" list that would otherwise be all sub-floor noise.
284        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
296/// Emit a pre-parse note listing the largest kept files when the discovered set
297/// is unusually large or contains an unusually large file, so an out-of-memory
298/// hang at the parse stage is diagnosable (issue #1086). Visible before the
299/// expensive parse begins, so it survives a subsequent crash.
300fn 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/// Package-scoped hidden directories that source discovery should traverse.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct HiddenDirScope {
314    root: PathBuf,
315    dirs: Vec<String>,
316}
317
318impl HiddenDirScope {
319    /// Build a scope rooted at a package directory that admits the given
320    /// hidden directory names during the walk.
321    #[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
341/// Per-thread file collector for the parallel walker.
342///
343/// Source files (by extension) flow to `shared`; when `config_shared` is set,
344/// non-source files admitted by the config-candidate type group flow to it
345/// instead. The two channels are disjoint and the source channel is byte-for-byte
346/// identical to the config-capture-disabled walk.
347struct 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            // A non-source file admitted by the config-candidate type group. No
398            // size metadata is needed; these are pattern-matched, never parsed.
399            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
438/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
439struct 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
463/// File extensions discovery treats as analyzable source files.
464pub 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
469/// Glob patterns for test/dev/story files excluded in production mode.
470pub 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
492/// Check if a hidden directory name is on the allowlist.
493pub 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
507/// Files Yarn Plug'n'Play writes at the workspace root. They carry source
508/// extensions (`.pnp.cjs` is the multi-megabyte generated loader with the
509/// install state inlined, `.pnp.loader.mjs` its ESM shim) but are install
510/// artifacts, not project source, so the walker drops them by name.
511const 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
519/// Check if a hidden directory entry should be allowed through the filter.
520///
521/// Returns `true` if the entry is not hidden or is on the allowlist.
522/// Hidden files (not directories) are allowed through since the type filter
523/// handles them, except for the generated Yarn PnP files.
524fn 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
547/// Discover all source files in the project.
548///
549/// # Panics
550///
551/// Panics if the file type glob or progress template is invalid (compile-time constants).
552pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
553    discover_files_with_additional_hidden_dirs(config, &[])
554}
555
556/// The set of config-file basenames (last path component of every built-in
557/// plugin `config_patterns()` entry, brace forms preserved) that the walk should
558/// additionally admit so non-source configs (`tsconfig.json`, `bunfig.toml`,
559/// `.eslintrc.json`, ...) can be captured in one traversal instead of being
560/// re-discovered by a filesystem re-walk in `discover_config_files`.
561///
562/// Derived live from the built-in plugin list, so it can never drift behind a
563/// new plugin's config patterns. Source-extension config basenames
564/// (`vite.config.{ts,js}`) are admitted too, but the walk visitor routes them
565/// back to the source channel by extension, so the config channel only ever
566/// collects genuinely non-source files.
567fn 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
583/// True when `path`'s extension is one of the known source extensions, i.e. the
584/// file belongs in the source channel rather than the config-candidate channel.
585fn 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/// Build the file-type filter. Always selects known source extensions; when
592/// `capture_config` is set, also selects config-candidate basenames so the
593/// walker yields them for the second collection channel.
594#[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                    // Ignore individually-invalid plugin patterns rather than panicking;
618                    // a malformed pattern simply fails to admit its config file (the
619                    // pre-existing filesystem fallback still covers production mode).
620                    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
629/// Construct the parallel walker, applying the appropriate hidden-dir filter.
630/// When `capture_config` is set the walk also yields config-candidate files for
631/// the secondary collection channel.
632fn 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
654/// Compile the production-mode exclude glob set, or `None` outside production mode.
655fn 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
671/// Discover all source files in the project, with package-scoped hidden dirs.
672///
673/// # Panics
674///
675/// Panics if the file type glob or progress template is invalid (compile-time constants).
676pub 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
683/// Discover source files AND, in one traversal, the non-source config-candidate
684/// files (`tsconfig.json`, `bunfig.toml`, `.eslintrc.json`, ...) used by
685/// `discover_config_files` to resolve plugin config patterns in-memory instead of
686/// re-walking the filesystem.
687///
688/// The returned `Vec<DiscoveredFile>` is byte-for-byte identical to the
689/// config-capture-disabled walk: config candidates are routed to the second
690/// return value by extension and never enter the source channel. Config capture
691/// is skipped in production mode (where the walk applies `PRODUCTION_EXCLUDE_PATTERNS`
692/// and `discover_config_files` keeps its filesystem path), so the second vector is
693/// empty there.
694///
695/// # Panics
696///
697/// Panics if the file type glob or progress template is invalid (compile-time constants).
698pub 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
707/// Source files, config candidates, and the source-discovery diagnostics one
708/// walk produced.
709///
710/// `diagnostics` is the walk's OWN skip list, not a read of the process-wide
711/// registry: combined mode can run two walks on the same root concurrently, and
712/// each walk replaces the registry's source-discovery entries, so only the
713/// by-value list is a stable answer to "what did THIS analysis skip" (issue
714/// #2366).
715pub struct DiscoveredSources {
716    /// Source files with stable path-sorted [`FileId`]s.
717    pub files: Vec<DiscoveredFile>,
718    /// Non-source config-candidate paths captured in the same traversal.
719    pub config_candidates: Vec<PathBuf>,
720    /// Skipped-large-file and skipped-minified-file diagnostics from this walk.
721    pub diagnostics: Vec<WorkspaceDiagnostic>,
722}
723
724/// [`discover_files_and_config_candidates`] plus the source-discovery
725/// diagnostics this walk recorded, for callers that must carry a per-analysis
726/// snapshot instead of reading the shared registry back (issue #2366).
727///
728/// # Panics
729///
730/// Panics if the file type glob or progress template is invalid (compile-time constants).
731#[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    // ADR-004 (path-sorted FileIds): the parallel walk visits files in
764    // nondeterministic order, so we sort by absolute path BEFORE the
765    // `.enumerate()` FileId assignment below. This is the stable-cross-run
766    // identity invariant the persisted graph cache depends on: an identical
767    // file set yields identical FileIds, so a cache hit (same paths +
768    // fingerprints) can trust graph data persisted by FileId. Do not replace
769    // this with insertion-order assignment.
770    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    // One registry write replaces this root's whole source-discovery set, so a
781    // stale entry from a previous pass drops out (issue #1086) without a window
782    // in which a concurrent walk on the same root can observe or clobber a
783    // half-written set (issue #2366).
784    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    /// Reproduce the FileId-assignment rule used by `walk_source_files`: sort by
818    /// absolute path, then assign `FileId(idx)` in that order.
819    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    /// ADR-004: an identical file set must yield identical FileIds regardless of
832    /// the (nondeterministic, parallel) discovery order. The persisted graph
833    /// cache keys persisted graph data by FileId, so a cache HIT (same paths +
834    /// fingerprints) must reproduce the exact same FileId-to-path mapping the
835    /// graph was built against. This guards the cache's soundness prerequisite.
836    #[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        // Two independent walks that observe the same paths in DIFFERENT orders.
847        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        // Identical (FileId -> path) mapping despite the different walk orders.
858        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        // The mapping is the path-sorted order, and each FileId equals its index
865        // (the density invariant `project.rs` asserts and the graph relies on).
866        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        /// Create a minimal ResolvedConfig pointing at the given root directory.
1244        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        /// Helper to collect discovered file names (relative to root) for assertions.
1253        /// Normalizes path separators to `/` for cross-platform test consistency.
1254        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        /// Yarn PnP writes `.pnp.cjs` and `.pnp.loader.mjs` at the workspace
1325        /// root. They match the source extension filter but are generated
1326        /// install state, not code to analyze.
1327        #[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        /// Create a config with custom ignore patterns.
1722        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        /// Resolve a config then override the per-file size limit in bytes.
1871        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}