Skip to main content

fallow_core/discover/
walk.rs

1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use fallow_config::{ResolvedConfig, WorkspaceDiagnostic, WorkspaceDiagnosticKind};
6use fallow_types::discover::{DiscoveredFile, FileId};
7use ignore::WalkBuilder;
8use rustc_hash::FxHashSet;
9
10use super::{ALLOWED_HIDDEN_DIRS, SCRIPT_SCOPE_DENYLIST};
11
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/// Dot-prefixed directories the walk dropped, collected inside the parallel
30/// walker's `filter_entry` predicate.
31///
32/// `Arc<Mutex<..>>` and not a borrow: `WalkBuilder::filter_entry` requires
33/// `Fn(&DirEntry) -> bool + Send + Sync + 'static`, so the closure can neither
34/// borrow a local nor mutate captured state directly, and the predicate runs
35/// on every walker thread.
36type SkippedDotdirSink = Arc<Mutex<Vec<PathBuf>>>;
37
38/// Number of example file paths named in the aggregated skipped-large-file and
39/// largest-files stderr notes before the tail collapses to "and N more". Keeps
40/// the notes to one bounded line on a monorepo that skips many files.
41const NOTE_EXAMPLE_CAP: usize = 5;
42
43/// Directory levels below a skipped dotdir the bounded scan descends. The
44/// dotdir itself is level 0. Two levels reach the conventional
45/// `<dotdir>/<group>/<file>` layout (`.claude/hooks/probe.mjs`) with one level
46/// of headroom, and stop well above a vendored toolchain tree.
47const DOTDIR_SCAN_MAX_DEPTH: usize = 2;
48
49/// Directory entries the bounded scan reads across all levels of ONE skipped
50/// dotdir. The ceiling this buys is a SYSCALL count, not a wall-clock figure:
51/// a directory-heavy dotdir spends budget on subdirectories that each cost an
52/// opendir of their own, so 256 entries can still mean 257 directory reads and
53/// several milliseconds. State the bound in syscalls, never in milliseconds.
54const DOTDIR_SCAN_MAX_ENTRIES: usize = 256;
55
56/// Directory entries the bounded scan reads across ALL skipped dotdirs in one
57/// walk. [`DOTDIR_SCAN_MAX_ENTRIES`] bounds a single directory and nothing
58/// bounded the sum, so the added cost was linear in the candidate count: a
59/// synthetic tree of 1000 directory-heavy dotdirs turned a 191 ms run into
60/// 6.6 s. Candidates are scanned in sorted order and share this budget, so
61/// exhausting it drops the advisory for the remaining candidates
62/// deterministically instead of paying an unbounded cost. With this ceiling
63/// and [`DOTDIR_SCAN_MAX_CANDIDATES`] the same synthetic trees measure about
64/// 30 ms of added work whether they hold 300 or 1000 candidates, and a real
65/// repository stays inside run-to-run noise.
66const DOTDIR_SCAN_TOTAL_ENTRIES: usize = 1024;
67
68/// Skipped dotdirs the bounded scan OPENS in one walk. The entry budget does
69/// not bound the per-candidate setup cost, since each scan builds its own
70/// gitignore matcher chain (about 0.2 ms) before it reads a single entry, so
71/// the candidate count needs a ceiling of its own. Counted after the name
72/// checks, so a monorepo full of `.turbo` and `.next` directories cannot spend
73/// the ceiling on directories that were never going to be scanned.
74const DOTDIR_SCAN_MAX_CANDIDATES: usize = 64;
75
76/// File extensions that put a file in the module graph the advisory talks
77/// about. Narrower than [`SOURCE_EXTENSIONS`] on purpose: the message states
78/// that the directory's imports and exports are not analyzed, and that is only
79/// true of code. A dotdir holding nothing but a generated Lighthouse
80/// `report.html`, a Sanity runtime page, a `schema.graphql`, or a stylesheet
81/// has no imports or exports to lose, so it does not earn the advisory.
82const DOTDIR_MODULE_EXTENSIONS: &[&str] = &[
83    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
84    "mdx",
85];
86
87/// Discovered-file-count threshold above which the pre-parse largest-files note
88/// fires, so an out-of-memory hang at the parse stage has a visible suspect
89/// list (issue #1086).
90const LARGE_SET_THRESHOLD: usize = 20_000;
91
92/// Single-file byte threshold above which the pre-parse largest-files note
93/// fires even on a small project. Set just under the default 5 MB skip so the
94/// note fires for kept files that are approaching the skip limit (the genuine
95/// out-of-memory suspects), not for ordinary large-but-benign files.
96const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
97
98/// Minimum size for a file to appear in the largest-files note. Filters out the
99/// `0.0 MB` entries that would otherwise pad the list once it fires, keeping the
100/// named files to plausible memory contributors.
101const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
102
103/// Minimum size for content-shape based minified-bundle skipping. Smaller
104/// one-line files can be hand-written utilities, while multi-MB one-line JS is
105/// generated output in practice.
106const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
107
108/// Number of bytes inspected when deciding whether a large JS file is minified.
109const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
110
111/// A single line this long in a multi-MB JS file is treated as generated
112/// minified output. This avoids parsing assets that can expand to huge ASTs.
113const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
114
115/// Whether a path is a TypeScript declaration file (`.d.ts`/`.d.mts`/`.d.cts`).
116/// Declaration files are exempt from the per-file size skip because they are
117/// reachability roots for global types: skipping a large `auto-imports.d.ts`
118/// would false-flag the files whose types it provides.
119fn is_declaration_file(path: &Path) -> bool {
120    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
121    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
122}
123
124fn is_plain_js_file(path: &Path) -> bool {
125    matches!(
126        path.extension().and_then(|ext| ext.to_str()),
127        Some("js" | "mjs" | "cjs")
128    )
129}
130
131fn has_minified_line_shape(path: &Path) -> bool {
132    use std::io::Read;
133
134    let Ok(mut file) = std::fs::File::open(path) else {
135        return false;
136    };
137    let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
138    let Ok(len) = file.read(&mut sample) else {
139        return false;
140    };
141    sample.truncate(len);
142    if sample.is_empty() {
143        return false;
144    }
145
146    let mut current_line = 0usize;
147    for byte in sample {
148        if byte == b'\n' || byte == b'\r' {
149            current_line = 0;
150            continue;
151        }
152        current_line += 1;
153        if current_line >= MINIFIED_LONG_LINE_BYTES {
154            return true;
155        }
156    }
157    false
158}
159
160fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
161    size_bytes >= MINIFIED_FILE_SKIP_BYTES
162        && is_plain_js_file(path)
163        && !is_declaration_file(path)
164        && has_minified_line_shape(path)
165}
166
167/// Render a byte count as a megabyte figure with one decimal place.
168fn format_size_mb(bytes: u64) -> String {
169    #[expect(
170        clippy::cast_precision_loss,
171        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
172    )]
173    let mb = bytes as f64 / (1024.0 * 1024.0);
174    format!("{mb:.1} MB")
175}
176
177/// Join up to [`NOTE_EXAMPLE_CAP`] `path (size)` examples (already ordered) into
178/// one comma-separated string, collapsing the tail to "and N more".
179fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
180    let shown: Vec<String> = examples
181        .iter()
182        .take(NOTE_EXAMPLE_CAP)
183        .map(|(path, size)| {
184            let display = display_relative_path(root, path);
185            format!("{display} ({})", format_size_mb(*size))
186        })
187        .collect();
188    let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
189    if remaining > 0 {
190        format!("{}, and {remaining} more", shown.join(", "))
191    } else {
192        shown.join(", ")
193    }
194}
195
196/// Split discovered `(path, size)` pairs into the kept set and the set skipped
197/// for exceeding `max_file_size_bytes`. Declaration files are never skipped.
198fn partition_by_size(
199    raw: Vec<SizedFile>,
200    max_file_size_bytes: Option<u64>,
201) -> (Vec<SizedFile>, Vec<SizedFile>) {
202    let Some(limit) = max_file_size_bytes else {
203        return (raw, Vec::new());
204    };
205    raw.into_iter()
206        .partition(|(path, size)| *size <= limit || is_declaration_file(path))
207}
208
209/// Split discovered `(path, size)` pairs into files kept for parsing and files
210/// skipped because they look like generated minified JavaScript.
211fn partition_minified_generated_js(
212    raw: Vec<SizedFile>,
213    max_file_size_bytes: Option<u64>,
214) -> (Vec<SizedFile>, Vec<SizedFile>) {
215    if max_file_size_bytes.is_none() {
216        return (raw, Vec::new());
217    }
218    raw.into_iter()
219        .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
220}
221
222/// Build the typed diagnostics for the over-limit files this walk dropped and
223/// emit one aggregated `tracing::warn!` so a human running `fallow` sees what
224/// was dropped. Mirrors the JSON-plus-gated-warn pattern used for undeclared
225/// workspaces. The caller writes the returned list to the registry.
226/// Report an uninstalled dependency tree as part of the walk's own
227/// source-discovery set.
228///
229/// It belongs here rather than beside the two pipelines that used to warn
230/// about it: the walk owns this root's source-discovery diagnostics, and any
231/// second writer would be replaced by whichever walk finished last.
232fn report_missing_node_modules(config: &ResolvedConfig) -> Vec<WorkspaceDiagnostic> {
233    let Some(diagnostic) = fallow_config::missing_node_modules_diagnostic(&config.root) else {
234        return Vec::new();
235    };
236    if !config.quiet
237        && should_emit_note_once(format!("node-modules-missing::{}", config.root.display()))
238    {
239        tracing::warn!("fallow: {}", diagnostic.message);
240    }
241    vec![diagnostic]
242}
243
244fn report_skipped_large_files(
245    config: &ResolvedConfig,
246    skipped: &[SizedFile],
247) -> Vec<WorkspaceDiagnostic> {
248    if skipped.is_empty() {
249        return Vec::new();
250    }
251    let diagnostics: Vec<WorkspaceDiagnostic> = skipped
252        .iter()
253        .map(|(path, size_bytes)| {
254            WorkspaceDiagnostic::new(
255                &config.root,
256                path.clone(),
257                WorkspaceDiagnosticKind::SkippedLargeFile {
258                    size_bytes: *size_bytes,
259                },
260            )
261        })
262        .collect();
263
264    let mut sorted: Vec<SizedFile> = skipped.to_vec();
265    sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
266    let count = skipped.len();
267    if !config.quiet
268        && should_emit_note_once(format!(
269            "skip::{}::{count}::{}",
270            config.root.display(),
271            sorted.first().map_or(0, |f| f.1)
272        ))
273    {
274        let examples = summarize_examples(&config.root, &sorted);
275        let noun = if count == 1 { "file" } else { "files" };
276        tracing::warn!(
277            "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
278             Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
279        );
280    }
281    diagnostics
282}
283
284/// Build the typed diagnostics for generated minified JS files skipped before
285/// parsing. The caller writes the returned list to the registry.
286fn report_skipped_minified_files(
287    config: &ResolvedConfig,
288    skipped: &[SizedFile],
289) -> Vec<WorkspaceDiagnostic> {
290    if skipped.is_empty() {
291        return Vec::new();
292    }
293    let diagnostics: Vec<WorkspaceDiagnostic> = skipped
294        .iter()
295        .map(|(path, size_bytes)| {
296            WorkspaceDiagnostic::new(
297                &config.root,
298                path.clone(),
299                WorkspaceDiagnosticKind::SkippedMinifiedFile {
300                    size_bytes: *size_bytes,
301                },
302            )
303        })
304        .collect();
305
306    let mut sorted: Vec<SizedFile> = skipped.to_vec();
307    sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
308    let count = skipped.len();
309    if !config.quiet
310        && should_emit_note_once(format!(
311            "minified::{}::{count}::{}",
312            config.root.display(),
313            sorted.first().map_or(0, |f| f.1)
314        ))
315    {
316        let examples = summarize_examples(&config.root, &sorted);
317        let noun = if count == 1 { "file" } else { "files" };
318        let pronoun = if count == 1 { "it" } else { "them" };
319        tracing::warn!(
320            "fallow: skipped {count} minified generated JS {noun} ({examples}). \
321             Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
322        );
323    }
324    diagnostics
325}
326
327/// Join up to [`NOTE_EXAMPLE_CAP`] root-relative paths (already ordered) into
328/// one comma-separated string, collapsing the tail to "and N more". The
329/// size-bearing sibling is [`summarize_examples`].
330fn summarize_paths(root: &Path, examples: &[&PathBuf]) -> String {
331    let shown: Vec<String> = examples
332        .iter()
333        .take(NOTE_EXAMPLE_CAP)
334        .map(|path| display_relative_path(root, path))
335        .collect();
336    let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
337    if remaining > 0 {
338        format!("{}, and {remaining} more", shown.join(", "))
339    } else {
340        shown.join(", ")
341    }
342}
343
344/// Like [`summarize_paths`], but for a list already known to be incomplete: the
345/// tail reads "and more" rather than naming a count the run cannot vouch for.
346fn summarize_paths_open_ended(root: &Path, examples: &[&PathBuf]) -> String {
347    let shown: Vec<String> = examples
348        .iter()
349        .take(NOTE_EXAMPLE_CAP)
350        .map(|path| display_relative_path(root, path))
351        .collect();
352    if examples.len() > NOTE_EXAMPLE_CAP {
353        format!("{}, and more", shown.join(", "))
354    } else {
355        shown.join(", ")
356    }
357}
358
359/// Render `path` relative to `root` with forward slashes. Cross-platform
360/// output stability depends on the slash normalisation.
361fn display_relative_path(root: &Path, path: &Path) -> String {
362    path.strip_prefix(root)
363        .unwrap_or(path)
364        .display()
365        .to_string()
366        .replace('\\', "/")
367}
368
369/// Whether a candidate file inside a skipped dotdir is one this run had
370/// already excluded from analysis, matching what [`FileVisitor`] applies to
371/// every discovered file: the compiled `ignorePatterns` set (user entries plus
372/// the built-in defaults) against the ROOT-RELATIVE path, plus the production
373/// excludes when the run is a `--production` run.
374///
375/// The root-relative path is what matters. Matching the directory path instead
376/// would miss the pattern a user actually writes, because `.claude/**` does not
377/// match `.claude`.
378///
379/// Production excludes ARE applied, even though the skip the diagnostic reports
380/// is a traversal decision that `--production` does not change. A `--production`
381/// run that named a dotdir holding only `thing.test.ts` would print a remedy
382/// (`fallow --root .qa --production`) that returns nothing, so the advisory has
383/// to agree with the file set the run would actually analyze. Combined mode's
384/// two walks can therefore disagree, which the documented union semantics of
385/// the combined root already cover.
386fn is_excluded_from_analysis(
387    config: &ResolvedConfig,
388    production_excludes: Option<&globset::GlobSet>,
389    path: &Path,
390) -> bool {
391    let relative = path.strip_prefix(&config.root).unwrap_or(path);
392    config.ignore_patterns.is_match(relative)
393        || production_excludes.is_some_and(|excludes| excludes.is_match(relative))
394}
395
396/// True when `path` carries an extension that puts it in the module graph the
397/// advisory describes. See [`DOTDIR_MODULE_EXTENSIONS`] for why this is
398/// narrower than [`has_source_extension`].
399fn has_module_extension(path: &Path) -> bool {
400    path.extension()
401        .and_then(OsStr::to_str)
402        .is_some_and(|ext| DOTDIR_MODULE_EXTENSIONS.contains(&ext))
403}
404
405/// Depth- and entry-capped search for one reportable source file, stopping at
406/// the first hit. Never a recursive walk of an unbounded tree: the ceiling is
407/// `1 + DOTDIR_SCAN_MAX_ENTRIES` directory reads for one dotdir, and
408/// [`DOTDIR_SCAN_TOTAL_ENTRIES`] across the whole walk.
409///
410/// Runs on `ignore::WalkBuilder` with the same git settings as the source walk
411/// rather than a bare `read_dir`, because "the project has not excluded it" has
412/// to mean what git means. Only the DIRECTORY form of a gitignore rule
413/// (`.build-tools/`) prunes a dotdir before the walk's own filter sees it: the
414/// `dir/**`, `dir/*`, `**/dir/**` and file-level (`*.ts`) forms all leave the
415/// directory reaching this scan with every file inside it ignored, and a cache
416/// directory that ignores itself through its own nested `.gitignore` does the
417/// same. Reporting those would advertise two remedies that both do nothing,
418/// since a re-rooted `fallow --root <dir>` still reads the parent repository's
419/// gitignore and would find no files either.
420///
421/// Accepted tradeoff: for a dotdir with more than [`DOTDIR_SCAN_MAX_ENTRIES`]
422/// entries whose only source file sits past the budget, the verdict is
423/// readdir-order dependent, so the advisory can flap between runs. The
424/// alternative is unbounded I/O, and the consequence of a flap is a missing
425/// advisory line, never a changed analysis. No test may depend on that
426/// boundary.
427fn scan_for_reportable_source(
428    config: &ResolvedConfig,
429    production_excludes: Option<&globset::GlobSet>,
430    dir: &Path,
431    budget: &mut usize,
432) -> bool {
433    let mut builder = WalkBuilder::new(dir);
434    builder
435        .hidden(false)
436        .git_ignore(true)
437        .git_global(true)
438        .git_exclude(true)
439        .follow_links(false)
440        .max_depth(Some(DOTDIR_SCAN_MAX_DEPTH + 1))
441        .threads(1);
442    builder.filter_entry(|entry| {
443        if entry.depth() == 0 || !entry.file_type().is_some_and(|ft| ft.is_dir()) {
444            return true;
445        }
446        entry
447            .file_name()
448            .to_str()
449            .is_none_or(|name| !SCRIPT_SCOPE_DENYLIST.contains(&name) && name != "node_modules")
450    });
451
452    let mut per_dotdir = DOTDIR_SCAN_MAX_ENTRIES;
453    for entry in builder.build() {
454        if per_dotdir == 0 || *budget == 0 {
455            return false;
456        }
457        per_dotdir -= 1;
458        *budget -= 1;
459        let Ok(entry) = entry else {
460            continue;
461        };
462        // Regular files only. A symlink is never followed out of the scan, and
463        // a fifo or a socket named `pipe.ts` is not source either.
464        #[expect(
465            clippy::filetype_is_file,
466            reason = "regular files only is the point: !is_dir() would readmit fifos and sockets"
467        )]
468        let is_regular_file = entry
469            .file_type()
470            .is_some_and(|file_type| file_type.is_file());
471        if !is_regular_file {
472            continue;
473        }
474        if has_module_extension(entry.path())
475            && !is_excluded_from_analysis(config, production_excludes, entry.path())
476        {
477            return true;
478        }
479    }
480    false
481}
482
483/// Path components whose subtrees never earn the skipped-source-dotdir
484/// advisory. A hidden directory under one of these is test scaffolding rather
485/// than first-party source the project meant to analyze.
486const DOTDIR_NOISE_PATH_COMPONENTS: &[&str] = &[
487    "__fixtures__",
488    "__mocks__",
489    "__tests__",
490    "e2e",
491    "fixture",
492    "fixtures",
493    "playground",
494    "playgrounds",
495    "spec",
496    "test",
497    "tests",
498];
499
500/// Whether a dropped dotdir is worth opening at all. Decided from the PATH
501/// alone, so it costs no I/O and runs before the scan budget is touched:
502/// `.git` in a large repository, a `.jj` object store, and `node_modules/.pnpm`
503/// are never opened. `ALLOWED_HIDDEN_DIRS` and every plugin- or
504/// script-contributed scope are already excluded by construction, since a
505/// directory they admit is never dropped and so never reaches this list.
506fn dotdir_is_scan_candidate(config: &ResolvedConfig, dir: &Path) -> bool {
507    let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
508        return false;
509    };
510    if SCRIPT_SCOPE_DENYLIST.contains(&name) {
511        return false;
512    }
513    let relative = dir.strip_prefix(&config.root).unwrap_or(dir);
514    // Pure cost saving, not a further condition: the built-in `**/node_modules/**`
515    // ignore default makes every file under a `node_modules` component ignored,
516    // so the scan could only ever return false.
517    if relative
518        .components()
519        .any(|component| component.as_os_str() == OsStr::new("node_modules"))
520    {
521        return false;
522    }
523    // Precision, and the one place this check is deliberately less complete
524    // than it could be. A hidden directory under a test, fixture, or playground
525    // tree is usually there BECAUSE it is hidden: some of these exist purely to
526    // exercise hidden-directory handling, so an advisory about them is wrong
527    // about the project every time it fires. Measured on a ten-repository
528    // corpus, this component filter removes every false positive one framework
529    // contributed and half of another's while keeping the true positives, which
530    // sit at a repository root rather than under a test tree.
531    !relative.components().any(|component| {
532        DOTDIR_NOISE_PATH_COMPONENTS.contains(&component.as_os_str().to_string_lossy().as_ref())
533    })
534}
535
536/// Build the typed diagnostics for the dot-prefixed directories this walk
537/// dropped that hold source files the project has not excluded, and emit one
538/// aggregated `tracing::warn!` so the otherwise silent skip is visible on
539/// stderr too (issue #461). The caller writes the returned list to the
540/// registry.
541fn report_skipped_source_dotdirs(
542    config: &ResolvedConfig,
543    production_excludes: Option<&globset::GlobSet>,
544    candidates: &[PathBuf],
545) -> Vec<WorkspaceDiagnostic> {
546    if candidates.is_empty() {
547        return Vec::new();
548    }
549    // The caller sorted and deduped, so both caps truncate deterministically:
550    // the same tree reports the same prefix on every run.
551    let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
552    let scannable: Vec<&PathBuf> = candidates
553        .iter()
554        .filter(|dir| dotdir_is_scan_candidate(config, dir))
555        .collect();
556    let reportable: Vec<&PathBuf> = scannable
557        .iter()
558        .copied()
559        .take(DOTDIR_SCAN_MAX_CANDIDATES)
560        .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
561        .collect();
562    // Either ceiling can stop the scan with candidates left unexamined, so the
563    // count is a floor rather than a total whenever one of them binds.
564    let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
565    if reportable.is_empty() {
566        return Vec::new();
567    }
568
569    let diagnostics: Vec<WorkspaceDiagnostic> = reportable
570        .iter()
571        .map(|dir| {
572            WorkspaceDiagnostic::new(
573                &config.root,
574                (*dir).clone(),
575                WorkspaceDiagnosticKind::SkippedSourceDotdir,
576            )
577        })
578        .collect();
579
580    let count = reportable.len();
581    if !config.quiet
582        && should_emit_note_once(format!(
583            "dotdir::{}::{count}::{}",
584            config.root.display(),
585            reportable
586                .first()
587                .map_or_else(String::new, |dir| display_relative_path(&config.root, dir))
588        ))
589    {
590        tracing::warn!(
591            "{}",
592            build_skipped_dotdirs_note(&config.root, &reportable, truncated)
593        );
594    }
595    diagnostics
596}
597
598/// Build the skipped-source-dotdir note. Pure so the singular and plural forms,
599/// the truncated prefix, and the single-directory remedy substitution are
600/// unit-testable without a tracing subscriber, mirroring
601/// [`build_largest_files_note`].
602///
603/// With exactly one directory the remedy names it instead of printing a `<dir>`
604/// placeholder: the path is already known and was printed a few words earlier,
605/// so a placeholder would make the one case a user can act on directly the one
606/// case they have to retype.
607fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
608    let count = reportable.len();
609    // An exact remainder inside an explicitly inexact total reads as a
610    // contradiction ("at least 64 ... and 59 more"), so a truncated run drops
611    // the tail count.
612    let examples = if truncated {
613        summarize_paths_open_ended(root, reportable)
614    } else {
615        summarize_paths(root, reportable)
616    };
617    let noun = if count == 1 {
618        "directory"
619    } else {
620        "directories"
621    };
622    let verb = if count == 1 { "contains" } else { "contain" };
623    let at_least = if truncated { "at least " } else { "" };
624    let (target, pronoun) = match reportable {
625        [only] => (display_relative_path(root, only), "it"),
626        _ => ("<dir>".to_owned(), "one"),
627    };
628    format!(
629        "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
630         Hidden directories are not traversed and no config field adds one: analyze {pronoun} \
631         with fallow --root {target} if it holds first-party source, or add '{target}/**' to \
632         ignorePatterns to silence this."
633    )
634}
635
636/// Build the pre-parse largest-files note, or `None` when the discovered set is
637/// neither unusually large nor contains an unusually large file. Pure so the
638/// pluralization, floor filtering, and count-only fallback are unit-testable
639/// without a tracing subscriber. See issue #1086.
640fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
641    if files.is_empty() {
642        return None;
643    }
644    let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
645    if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
646        return None;
647    }
648    let count = files.len();
649    let noun = if count == 1 { "file" } else { "files" };
650    let mut by_size: Vec<SizedFile> = files
651        .iter()
652        .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
653        .map(|f| (f.path.clone(), f.size_bytes))
654        .collect();
655    by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
656    if by_size.is_empty() {
657        // Large file SET with no individually large file: report the count only,
658        // omitting a "largest:" list that would otherwise be all sub-floor noise.
659        return Some(format!(
660            "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
661             exclude large generated files via ignorePatterns or --max-file-size."
662        ));
663    }
664    let examples = summarize_examples(root, &by_size);
665    Some(format!(
666        "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
667         exclude large generated files via ignorePatterns or --max-file-size."
668    ))
669}
670
671/// Emit a pre-parse note listing the largest kept files when the discovered set
672/// is unusually large or contains an unusually large file, so an out-of-memory
673/// hang at the parse stage is diagnosable (issue #1086). Visible before the
674/// expensive parse begins, so it survives a subsequent crash.
675fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
676    if config.quiet {
677        return;
678    }
679    if let Some(message) = build_largest_files_note(&config.root, files)
680        && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
681    {
682        tracing::warn!("{message}");
683    }
684}
685
686/// How a [`HiddenDirScope`] matches a hidden directory during the walk.
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688pub enum HiddenDirMatch {
689    /// Match by directory NAME at any depth beneath the scope root.
690    ///
691    /// Framework plugins declare bundle-boundary conventions like `.client`
692    /// and `.server` that a project may place under any route directory, so
693    /// the name is the whole rule and the depth is not knowable in advance.
694    AnyDepth,
695    /// Match the exact root-relative directory PATH.
696    ///
697    /// A `package.json` script naming `.a/.b/build.mjs` states where the file
698    /// it needs actually lives, so the scope admits `<root>/.a` and
699    /// `<root>/.a/.b` and nothing else. An unrelated `packages/x/.b` stays
700    /// untraversed (issue #461).
701    ExactPath,
702}
703
704/// Package-scoped hidden directories that source discovery should traverse.
705#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct HiddenDirScope {
707    root: PathBuf,
708    dirs: Vec<String>,
709    match_mode: HiddenDirMatch,
710}
711
712impl HiddenDirScope {
713    /// Build a scope rooted at a package directory that admits the given
714    /// hidden directory names at any depth beneath it.
715    ///
716    /// This is the plugin-contributed shape. For a scope inferred from a
717    /// concrete path, use [`HiddenDirScope::new_exact_paths`], which does not
718    /// admit the same name elsewhere in the tree.
719    #[must_use]
720    pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
721        Self {
722            root,
723            dirs,
724            match_mode: HiddenDirMatch::AnyDepth,
725        }
726    }
727
728    /// Build a scope rooted at a package directory that admits exactly the
729    /// given root-relative directory paths.
730    #[must_use]
731    pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
732        Self {
733            root,
734            dirs,
735            match_mode: HiddenDirMatch::ExactPath,
736        }
737    }
738
739    /// Rebuild a scope with an explicit match mode.
740    ///
741    /// Used when a scope crosses a crate boundary and must arrive with the
742    /// same semantics it left with.
743    #[must_use]
744    pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
745        Self {
746            root,
747            dirs,
748            match_mode,
749        }
750    }
751
752    #[must_use]
753    pub fn root(&self) -> &Path {
754        &self.root
755    }
756
757    #[must_use]
758    pub fn dirs(&self) -> &[String] {
759        &self.dirs
760    }
761
762    #[must_use]
763    pub fn match_mode(&self) -> HiddenDirMatch {
764        self.match_mode
765    }
766
767    fn allows(&self, path: &Path, name: &OsStr) -> bool {
768        match self.match_mode {
769            HiddenDirMatch::AnyDepth => {
770                path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
771            }
772            HiddenDirMatch::ExactPath => {
773                // `Path` compares component-wise, so a `/`-separated entry
774                // from a script string matches on every platform.
775                let Ok(relative) = path.strip_prefix(&self.root) else {
776                    return false;
777                };
778                self.dirs.iter().any(|dir| Path::new(dir) == relative)
779            }
780        }
781    }
782}
783
784/// Per-thread file collector for the parallel walker.
785///
786/// Source files (by extension) flow to `shared`; when `config_shared` is set,
787/// non-source files admitted by the config-candidate type group flow to it
788/// instead. The two channels are disjoint and the source channel is byte-for-byte
789/// identical to the config-capture-disabled walk.
790struct FileVisitor<'a> {
791    root: &'a Path,
792    canonical_root: Option<&'a Path>,
793    ignore_patterns: &'a globset::GlobSet,
794    production_excludes: &'a Option<globset::GlobSet>,
795    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
796    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
797    local: Vec<(std::path::PathBuf, u64)>,
798    config_local: Vec<std::path::PathBuf>,
799}
800
801impl ignore::ParallelVisitor for FileVisitor<'_> {
802    fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
803        let Ok(entry) = result else {
804            return ignore::WalkState::Continue;
805        };
806        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
807            return ignore::WalkState::Continue;
808        }
809        let relative = entry
810            .path()
811            .strip_prefix(self.root)
812            .unwrap_or_else(|_| entry.path());
813        if self.ignore_patterns.is_match(relative) {
814            return ignore::WalkState::Continue;
815        }
816        if self
817            .production_excludes
818            .as_ref()
819            .is_some_and(|excludes| excludes.is_match(relative))
820        {
821            return ignore::WalkState::Continue;
822        }
823        let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
824            let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
825                tracing::debug!(
826                    path = %entry.path().display(),
827                    "skipping source symlink with a broken, non-file, or outside-root target"
828                );
829                return ignore::WalkState::Continue;
830            };
831            Some(size)
832        } else {
833            None
834        };
835        if has_source_extension(entry.path()) {
836            let size_bytes =
837                symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
838            self.local.push((entry.into_path(), size_bytes));
839        } else if self.config_shared.is_some() {
840            // A non-source file admitted by the config-candidate type group. No
841            // size metadata is needed; these are pattern-matched, never parsed.
842            self.config_local.push(entry.into_path());
843        }
844        ignore::WalkState::Continue
845    }
846}
847
848fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
849    let root = canonical_root?;
850    let target = path.canonicalize().ok()?;
851    if !target.starts_with(root) {
852        return None;
853    }
854    let metadata = target.metadata().ok()?;
855    metadata.is_file().then_some(metadata.len())
856}
857
858impl Drop for FileVisitor<'_> {
859    #[expect(
860        clippy::expect_used,
861        reason = "poisoned walk collector lock means worker state is unrecoverable"
862    )]
863    fn drop(&mut self) {
864        if !self.local.is_empty() {
865            self.shared
866                .lock()
867                .expect("walk collector lock poisoned")
868                .append(&mut self.local);
869        }
870        if let Some(config_shared) = self.config_shared
871            && !self.config_local.is_empty()
872        {
873            config_shared
874                .lock()
875                .expect("walk config collector lock poisoned")
876                .append(&mut self.config_local);
877        }
878    }
879}
880
881/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
882struct FileVisitorBuilder<'a> {
883    root: &'a Path,
884    canonical_root: Option<&'a Path>,
885    ignore_patterns: &'a globset::GlobSet,
886    production_excludes: &'a Option<globset::GlobSet>,
887    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
888    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
889}
890
891impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
892    fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
893        Box::new(FileVisitor {
894            root: self.root,
895            canonical_root: self.canonical_root,
896            ignore_patterns: self.ignore_patterns,
897            production_excludes: self.production_excludes,
898            shared: self.shared,
899            config_shared: self.config_shared,
900            local: Vec::new(),
901            config_local: Vec::new(),
902        })
903    }
904}
905
906/// File extensions discovery treats as analyzable source files.
907pub const SOURCE_EXTENSIONS: &[&str] = &[
908    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
909    "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
910];
911
912/// Glob patterns for test/dev/story files excluded in production mode.
913pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
914    "**/*.test.*",
915    "**/*.spec.*",
916    "**/*.e2e.*",
917    "**/*.e2e-spec.*",
918    "**/*.bench.*",
919    "**/*.fixture.*",
920    "**/*.stories.*",
921    "**/*.story.*",
922    "**/__tests__/**",
923    "**/__mocks__/**",
924    "**/__snapshots__/**",
925    "**/__fixtures__/**",
926    "**/test/**",
927    "**/tests/**",
928    "*.config.*",
929    "**/.*.js",
930    "**/.*.ts",
931    "**/.*.mjs",
932    "**/.*.cjs",
933];
934
935/// Check if a hidden directory name is on the allowlist.
936pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
937    ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
938}
939
940fn is_allowed_scoped_hidden_dir(
941    name: &OsStr,
942    path: &Path,
943    additional_hidden_dir_scopes: &[HiddenDirScope],
944) -> bool {
945    additional_hidden_dir_scopes
946        .iter()
947        .any(|scope| scope.allows(path, name))
948}
949
950/// Files Yarn Plug'n'Play writes at the workspace root. They carry source
951/// extensions (`.pnp.cjs` is the multi-megabyte generated loader with the
952/// install state inlined, `.pnp.loader.mjs` its ESM shim) but are install
953/// artifacts, not project source, so the walker drops them by name.
954const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
955
956fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
957    YARN_PNP_GENERATED_FILES
958        .iter()
959        .any(|&f| OsStr::new(f) == name)
960}
961
962/// Check if a hidden directory entry should be allowed through the filter.
963///
964/// Returns `true` if the entry is not hidden or is on the allowlist.
965/// Hidden files (not directories) are allowed through since the type filter
966/// handles them, except for the generated Yarn PnP files.
967fn is_allowed_hidden_with_scopes(
968    entry: &ignore::DirEntry,
969    additional_hidden_dir_scopes: &[HiddenDirScope],
970) -> bool {
971    let name = entry.file_name();
972    let name_str = name.to_string_lossy();
973
974    if !name_str.starts_with('.') {
975        return true;
976    }
977
978    if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
979        return !is_yarn_pnp_generated_file(name);
980    }
981
982    is_allowed_hidden_dir(name)
983        || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
984}
985
986/// Discover all source files in the project.
987///
988/// # Panics
989///
990/// Panics if the file type glob or progress template is invalid (compile-time constants).
991pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
992    discover_files_with_additional_hidden_dirs(config, &[])
993}
994
995/// The set of config-file basenames (last path component of every built-in
996/// plugin `config_patterns()` entry, brace forms preserved) that the walk should
997/// additionally admit so non-source configs (`tsconfig.json`, `bunfig.toml`,
998/// `.eslintrc.json`, ...) can be captured in one traversal instead of being
999/// re-discovered by a filesystem re-walk in `discover_config_files`.
1000///
1001/// Derived live from the built-in plugin list, so it can never drift behind a
1002/// new plugin's config patterns. Source-extension config basenames
1003/// (`vite.config.{ts,js}`) are admitted too, but the walk visitor routes them
1004/// back to the source channel by extension, so the config channel only ever
1005/// collects genuinely non-source files.
1006fn config_candidate_basename_globs() -> &'static [String] {
1007    static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
1008    GLOBS.get_or_init(|| {
1009        let mut set: FxHashSet<String> = FxHashSet::default();
1010        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1011            for pattern in plugin.config_patterns() {
1012                let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1013                set.insert(basename.to_string());
1014            }
1015        }
1016        let mut globs: Vec<String> = set.into_iter().collect();
1017        globs.sort_unstable();
1018        globs
1019    })
1020}
1021
1022/// True when `path`'s extension is one of the known source extensions, i.e. the
1023/// file belongs in the source channel rather than the config-candidate channel.
1024fn has_source_extension(path: &Path) -> bool {
1025    path.extension()
1026        .and_then(OsStr::to_str)
1027        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1028}
1029
1030/// Build the file-type filter. Always selects known source extensions; when
1031/// `capture_config` is set, also selects config-candidate basenames so the
1032/// walker yields them for the second collection channel.
1033#[expect(
1034    clippy::expect_used,
1035    reason = "source file globs are hard-coded compile-time constants"
1036)]
1037fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1038    static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1039    static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1040
1041    let cache = if capture_config {
1042        &SOURCE_AND_CONFIG_TYPES
1043    } else {
1044        &SOURCE_TYPES
1045    };
1046    cache
1047        .get_or_init(|| {
1048            let mut types_builder = ignore::types::TypesBuilder::new();
1049            let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1050            types_builder
1051                .add("source", &source_glob)
1052                .expect("valid glob");
1053            types_builder.select("source");
1054            if capture_config {
1055                for glob in config_candidate_basename_globs() {
1056                    // Ignore individually-invalid plugin patterns rather than panicking;
1057                    // a malformed pattern simply fails to admit its config file (the
1058                    // pre-existing filesystem fallback still covers production mode).
1059                    let _ = types_builder.add("config", glob);
1060                }
1061                types_builder.select("config");
1062            }
1063            types_builder.build().expect("valid types")
1064        })
1065        .clone()
1066}
1067
1068/// Construct the parallel walker, applying the appropriate hidden-dir filter.
1069/// When `capture_config` is set the walk also yields config-candidate files for
1070/// the secondary collection channel.
1071fn build_source_walk_builder(
1072    config: &ResolvedConfig,
1073    additional_hidden_dir_scopes: &[HiddenDirScope],
1074    capture_config: bool,
1075    skipped_dotdirs: &SkippedDotdirSink,
1076) -> WalkBuilder {
1077    let mut walk_builder = WalkBuilder::new(&config.root);
1078    walk_builder
1079        .hidden(false)
1080        .git_ignore(true)
1081        .git_global(true)
1082        .git_exclude(true)
1083        .types(build_walk_types(capture_config))
1084        .threads(config.threads);
1085    // One filter, not two: `filter_entry` replaces rather than chains, and the
1086    // dropped-dotdir record has to happen on the same false path that decides
1087    // the skip so the allowlist and every plugin- or script-contributed scope
1088    // are excluded by construction (issue #461).
1089    let scopes = additional_hidden_dir_scopes.to_vec();
1090    let sink = Arc::clone(skipped_dotdirs);
1091    walk_builder.filter_entry(move |entry| {
1092        if is_allowed_hidden_with_scopes(entry, &scopes) {
1093            return true;
1094        }
1095        if entry.file_type().is_some_and(|ft| ft.is_dir())
1096            && let Ok(mut collected) = sink.lock()
1097        {
1098            collected.push(entry.path().to_path_buf());
1099        }
1100        false
1101    });
1102    walk_builder
1103}
1104
1105/// Compile the production-mode exclude glob set, or `None` outside production mode.
1106fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1107    if !config.production {
1108        return None;
1109    }
1110    let mut builder = globset::GlobSetBuilder::new();
1111    for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1112        if let Ok(glob) = globset::GlobBuilder::new(pattern)
1113            .literal_separator(true)
1114            .build()
1115        {
1116            builder.add(glob);
1117        }
1118    }
1119    builder.build().ok()
1120}
1121
1122/// Discover all source files in the project, with package-scoped hidden dirs.
1123///
1124/// # Panics
1125///
1126/// Panics if the file type glob or progress template is invalid (compile-time constants).
1127pub fn discover_files_with_additional_hidden_dirs(
1128    config: &ResolvedConfig,
1129    additional_hidden_dir_scopes: &[HiddenDirScope],
1130) -> Vec<DiscoveredFile> {
1131    discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1132}
1133
1134/// Discover source files AND, in one traversal, the non-source config-candidate
1135/// files (`tsconfig.json`, `bunfig.toml`, `.eslintrc.json`, ...) used by
1136/// `discover_config_files` to resolve plugin config patterns in-memory instead of
1137/// re-walking the filesystem.
1138///
1139/// The returned `Vec<DiscoveredFile>` is byte-for-byte identical to the
1140/// config-capture-disabled walk: config candidates are routed to the second
1141/// return value by extension and never enter the source channel. Config capture
1142/// is skipped in production mode (where the walk applies `PRODUCTION_EXCLUDE_PATTERNS`
1143/// and `discover_config_files` keeps its filesystem path), so the second vector is
1144/// empty there.
1145///
1146/// # Panics
1147///
1148/// Panics if the file type glob or progress template is invalid (compile-time constants).
1149pub fn discover_files_and_config_candidates(
1150    config: &ResolvedConfig,
1151    additional_hidden_dir_scopes: &[HiddenDirScope],
1152) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1153    let discovered =
1154        discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1155    (discovered.files, discovered.config_candidates)
1156}
1157
1158/// Source files, config candidates, and the source-discovery diagnostics one
1159/// walk produced.
1160///
1161/// `diagnostics` is the walk's OWN skip list, not a read of the process-wide
1162/// registry: combined mode can run two walks on the same root concurrently, and
1163/// each walk replaces the registry's source-discovery entries, so only the
1164/// by-value list is a stable answer to "what did THIS analysis skip" (issue
1165/// #2366).
1166pub struct DiscoveredSources {
1167    /// Source files with stable path-sorted [`FileId`]s.
1168    pub files: Vec<DiscoveredFile>,
1169    /// Non-source config-candidate paths captured in the same traversal.
1170    pub config_candidates: Vec<PathBuf>,
1171    /// Skipped-large-file, skipped-minified-file, and skipped-source-dotdir
1172    /// diagnostics from this walk.
1173    pub diagnostics: Vec<WorkspaceDiagnostic>,
1174}
1175
1176/// [`discover_files_and_config_candidates`] plus the source-discovery
1177/// diagnostics this walk recorded, for callers that must carry a per-analysis
1178/// snapshot instead of reading the shared registry back (issue #2366).
1179///
1180/// # Panics
1181///
1182/// Panics if the file type glob or progress template is invalid (compile-time constants).
1183#[expect(
1184    clippy::cast_possible_truncation,
1185    reason = "file count is bounded by project size, well under u32::MAX"
1186)]
1187#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1188pub fn discover_files_config_candidates_and_diagnostics(
1189    config: &ResolvedConfig,
1190    additional_hidden_dir_scopes: &[HiddenDirScope],
1191) -> DiscoveredSources {
1192    let _span = tracing::info_span!("discover_files").entered();
1193
1194    let capture_config = !config.production;
1195    let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1196    let walk_builder = build_source_walk_builder(
1197        config,
1198        additional_hidden_dir_scopes,
1199        capture_config,
1200        &skipped_dotdirs,
1201    );
1202    let production_excludes = build_production_excludes(config);
1203    let canonical_root = config.root.canonicalize().ok();
1204
1205    let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1206    let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1207    let mut visitor_builder = FileVisitorBuilder {
1208        root: &config.root,
1209        canonical_root: canonical_root.as_deref(),
1210        ignore_patterns: &config.ignore_patterns,
1211        production_excludes: &production_excludes,
1212        shared: &collected,
1213        config_shared: capture_config.then_some(&config_collected),
1214    };
1215    walk_builder.build_parallel().visit(&mut visitor_builder);
1216
1217    let mut raw = collected
1218        .into_inner()
1219        .expect("walk collector lock poisoned");
1220    // ADR-004 (path-sorted FileIds): the parallel walk visits files in
1221    // nondeterministic order, so we sort by absolute path BEFORE the
1222    // `.enumerate()` FileId assignment below. This is the stable-cross-run
1223    // identity invariant the persisted graph cache depends on: an identical
1224    // file set yields identical FileIds, so a cache hit (same paths +
1225    // fingerprints) can trust graph data persisted by FileId. Do not replace
1226    // this with insertion-order assignment.
1227    raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1228
1229    let mut config_candidates = config_collected
1230        .into_inner()
1231        .expect("walk config collector lock poisoned");
1232    config_candidates.sort_unstable();
1233
1234    // The parallel walk records dotdirs in nondeterministic thread order, and
1235    // the diagnostic array order is part of the JSON contract, so sort and
1236    // dedupe before the predicate runs (issue #2366).
1237    let mut dotdir_candidates = skipped_dotdirs
1238        .lock()
1239        .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1240    dotdir_candidates.sort_unstable();
1241    dotdir_candidates.dedup();
1242
1243    let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1244    let (kept, skipped_minified) =
1245        partition_minified_generated_js(kept, config.max_file_size_bytes);
1246    // One registry write replaces this root's whole source-discovery set, so a
1247    // stale entry from a previous pass drops out (issue #1086) without a window
1248    // in which a concurrent walk on the same root can observe or clobber a
1249    // half-written set (issue #2366).
1250    let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1251        &config.root,
1252        report_skipped_large_files(config, &skipped)
1253            .into_iter()
1254            .chain(report_skipped_minified_files(config, &skipped_minified))
1255            .chain(report_skipped_source_dotdirs(
1256                config,
1257                production_excludes.as_ref(),
1258                &dotdir_candidates,
1259            ))
1260            .chain(report_missing_node_modules(config))
1261            .collect(),
1262    );
1263
1264    let files: Vec<DiscoveredFile> = kept
1265        .into_iter()
1266        .enumerate()
1267        .map(|(idx, (path, size_bytes))| DiscoveredFile {
1268            id: FileId(idx as u32),
1269            path,
1270            size_bytes,
1271        })
1272        .collect();
1273
1274    note_largest_files(config, &files);
1275
1276    DiscoveredSources {
1277        files,
1278        config_candidates,
1279        diagnostics,
1280    }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use std::ffi::OsStr;
1286    use std::path::MAIN_SEPARATOR;
1287
1288    use super::*;
1289
1290    #[test]
1291    fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1292        let root = Path::new("/repo");
1293        let only = PathBuf::from("/repo/.tooling");
1294        let note = build_skipped_dotdirs_note(root, &[&only], false);
1295        assert!(note.contains("skipped 1 hidden directory that contains source files"));
1296        assert!(note.contains("analyze it with fallow --root .tooling"));
1297        assert!(note.contains("add '.tooling/**' to"));
1298        assert!(
1299            !note.contains("<dir>"),
1300            "the single-directory remedy must be copy-pasteable: {note}"
1301        );
1302    }
1303
1304    #[test]
1305    fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1306        let root = Path::new("/repo");
1307        let a = PathBuf::from("/repo/.a");
1308        let b = PathBuf::from("/repo/.b");
1309        let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1310        assert!(note.contains("skipped 2 hidden directories that contain source files"));
1311        assert!(note.contains("analyze one with fallow --root <dir>"));
1312    }
1313
1314    #[test]
1315    fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1316        let root = Path::new("/repo");
1317        let owned: Vec<PathBuf> = (0..8)
1318            .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1319            .collect();
1320        let reportable: Vec<&PathBuf> = owned.iter().collect();
1321
1322        let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1323        assert!(bounded.contains("skipped at least 8 hidden directories"));
1324        assert!(
1325            bounded.contains("and more") && !bounded.contains("and 3 more"),
1326            "an inexact total must not carry an exact remainder: {bounded}"
1327        );
1328
1329        let complete = build_skipped_dotdirs_note(root, &reportable, false);
1330        assert!(!complete.contains("at least"));
1331        assert!(complete.contains("and 3 more"));
1332    }
1333
1334    #[test]
1335    fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1336        let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1337        sorted.sort_unstable();
1338        assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1339        for component in DOTDIR_NOISE_PATH_COMPONENTS {
1340            assert!(!component.starts_with('.'), "'{component}' is not hidden");
1341            assert_eq!(
1342                *component,
1343                component.to_lowercase(),
1344                "'{component}' is matched verbatim against a path component"
1345            );
1346        }
1347    }
1348
1349    #[test]
1350    fn script_scope_denylist_stays_disjoint_and_sorted() {
1351        let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1352        sorted.sort_unstable();
1353        assert_eq!(
1354            sorted, SCRIPT_SCOPE_DENYLIST,
1355            "keep the list sorted so additions stay reviewable"
1356        );
1357        for dir in SCRIPT_SCOPE_DENYLIST {
1358            assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1359            assert!(
1360                !ALLOWED_HIDDEN_DIRS.contains(dir),
1361                "'{dir}' is traversed, so it can never be a skipped candidate"
1362            );
1363        }
1364    }
1365
1366    #[test]
1367    fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1368        for ext in DOTDIR_MODULE_EXTENSIONS {
1369            assert!(
1370                SOURCE_EXTENSIONS.contains(ext),
1371                "'{ext}' is not discovered as source, so it cannot be a trigger"
1372            );
1373        }
1374        for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1375            assert!(
1376                !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1377                "'{ext}' carries no imports or exports for the message to be about"
1378            );
1379        }
1380    }
1381
1382    /// Reproduce the FileId-assignment rule used by `walk_source_files`: sort by
1383    /// absolute path, then assign `FileId(idx)` in that order.
1384    fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1385        raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1386        raw.into_iter()
1387            .enumerate()
1388            .map(|(idx, (path, size_bytes))| DiscoveredFile {
1389                id: FileId(idx as u32),
1390                path,
1391                size_bytes,
1392            })
1393            .collect()
1394    }
1395
1396    /// ADR-004: an identical file set must yield identical FileIds regardless of
1397    /// the (nondeterministic, parallel) discovery order. The persisted graph
1398    /// cache keys persisted graph data by FileId, so a cache HIT (same paths +
1399    /// fingerprints) must reproduce the exact same FileId-to-path mapping the
1400    /// graph was built against. This guards the cache's soundness prerequisite.
1401    #[test]
1402    fn file_id_assignment_is_deterministic_for_identical_file_set() {
1403        let paths = [
1404            "/project/src/z.ts",
1405            "/project/src/a.ts",
1406            "/project/src/components/Button.tsx",
1407            "/project/src/components/Button.module.css",
1408            "/project/index.ts",
1409        ];
1410
1411        // Two independent walks that observe the same paths in DIFFERENT orders.
1412        let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1413            .iter()
1414            .map(|p| (std::path::PathBuf::from(p), 10))
1415            .collect();
1416        let mut walk_two = walk_one.clone();
1417        walk_two.reverse();
1418
1419        let files_one = assign_file_ids(walk_one);
1420        let files_two = assign_file_ids(walk_two);
1421
1422        // Identical (FileId -> path) mapping despite the different walk orders.
1423        assert_eq!(files_one.len(), files_two.len());
1424        for (a, b) in files_one.iter().zip(files_two.iter()) {
1425            assert_eq!(a.id, b.id);
1426            assert_eq!(a.path, b.path);
1427        }
1428
1429        // The mapping is the path-sorted order, and each FileId equals its index
1430        // (the density invariant `project.rs` asserts and the graph relies on).
1431        for (idx, file) in files_one.iter().enumerate() {
1432            assert_eq!(file.id, FileId(idx as u32));
1433        }
1434        assert_eq!(
1435            files_one[0].path,
1436            std::path::PathBuf::from("/project/index.ts")
1437        );
1438    }
1439
1440    #[test]
1441    fn file_id_assignment_recomputes_after_rename_or_delete() {
1442        let before = assign_file_ids(vec![
1443            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1444            (std::path::PathBuf::from("/project/src/b.ts"), 10),
1445            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1446        ]);
1447        let after_delete = assign_file_ids(vec![
1448            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1449            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1450        ]);
1451        let after_rename = assign_file_ids(vec![
1452            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1453            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1454            (std::path::PathBuf::from("/project/src/d.ts"), 10),
1455        ]);
1456
1457        assert_eq!(before[0].id, FileId(0));
1458        assert_eq!(before[1].id, FileId(1));
1459        assert_eq!(before[2].id, FileId(2));
1460        assert_eq!(after_delete[0].id, FileId(0));
1461        assert_eq!(after_delete[1].id, FileId(1));
1462        assert_eq!(
1463            after_delete[1].path,
1464            std::path::PathBuf::from("/project/src/c.ts")
1465        );
1466        assert_eq!(after_rename[0].id, FileId(0));
1467        assert_eq!(after_rename[1].id, FileId(1));
1468        assert_eq!(
1469            after_rename[1].path,
1470            std::path::PathBuf::from("/project/src/c.ts")
1471        );
1472        assert_eq!(after_rename[2].id, FileId(2));
1473        assert_eq!(
1474            after_rename[2].path,
1475            std::path::PathBuf::from("/project/src/d.ts")
1476        );
1477    }
1478
1479    #[test]
1480    fn allowed_hidden_dirs() {
1481        assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1482        assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1483        assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1484        assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1485        assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1486    }
1487
1488    #[test]
1489    fn disallowed_hidden_dirs() {
1490        assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1491        assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1492        assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1493        assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1494        assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1495    }
1496
1497    #[test]
1498    fn non_hidden_dirs_not_in_allowlist() {
1499        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1500        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1501    }
1502
1503    #[test]
1504    fn walk_types_match_every_supported_source_extension() {
1505        for capture_config in [false, true] {
1506            let types = build_walk_types(capture_config);
1507            for extension in SOURCE_EXTENSIONS {
1508                let path = format!("packages/ui/src/nested/component.{extension}");
1509                assert!(
1510                    types.matched(&path, false).is_whitelist(),
1511                    "expected source match for {path} with capture_config={capture_config}"
1512                );
1513            }
1514        }
1515    }
1516
1517    #[test]
1518    fn walk_types_match_typescript_declaration_files() {
1519        let types = build_walk_types(true);
1520        for path in [
1521            "src/env.d.ts",
1522            "packages/app/types/generated.d.mts",
1523            "packages/app/types/compat.d.cts",
1524        ] {
1525            assert!(
1526                types.matched(path, false).is_whitelist(),
1527                "expected declaration source match for {path}"
1528            );
1529        }
1530    }
1531
1532    #[test]
1533    fn walk_types_reject_source_extension_near_misses() {
1534        for capture_config in [false, true] {
1535            let types = build_walk_types(capture_config);
1536            for path in [
1537                "src/component.tsx.bak",
1538                "src/component.tsxmap",
1539                "src/component.TS",
1540                "src/component.gqlx",
1541                "src/component.htm",
1542                "src/component",
1543                "assets/component.png",
1544            ] {
1545                assert!(
1546                    types.matched(path, false).is_ignore(),
1547                    "expected non-source rejection for {path} with capture_config={capture_config}"
1548                );
1549            }
1550        }
1551    }
1552
1553    #[test]
1554    fn walk_types_keep_config_candidate_selection_separate() {
1555        assert!(
1556            build_walk_types(true)
1557                .matched("packages/app/tsconfig.json", false)
1558                .is_whitelist()
1559        );
1560        assert!(
1561            build_walk_types(false)
1562                .matched("packages/app/tsconfig.json", false)
1563                .is_ignore()
1564        );
1565    }
1566
1567    #[test]
1568    fn source_extensions_include_typescript() {
1569        assert!(SOURCE_EXTENSIONS.contains(&"ts"));
1570        assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
1571        assert!(SOURCE_EXTENSIONS.contains(&"mts"));
1572        assert!(SOURCE_EXTENSIONS.contains(&"cts"));
1573        assert!(SOURCE_EXTENSIONS.contains(&"gts"));
1574    }
1575
1576    #[test]
1577    fn source_extensions_include_javascript() {
1578        assert!(SOURCE_EXTENSIONS.contains(&"js"));
1579        assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
1580        assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
1581        assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
1582        assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
1583    }
1584
1585    #[test]
1586    fn source_extensions_include_sfc_formats() {
1587        assert!(SOURCE_EXTENSIONS.contains(&"vue"));
1588        assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
1589        assert!(SOURCE_EXTENSIONS.contains(&"astro"));
1590    }
1591
1592    #[test]
1593    fn source_extensions_include_styles() {
1594        assert!(SOURCE_EXTENSIONS.contains(&"css"));
1595        assert!(SOURCE_EXTENSIONS.contains(&"scss"));
1596        assert!(SOURCE_EXTENSIONS.contains(&"sass"));
1597        assert!(SOURCE_EXTENSIONS.contains(&"less"));
1598    }
1599
1600    #[test]
1601    fn source_extensions_exclude_non_source() {
1602        assert!(!SOURCE_EXTENSIONS.contains(&"json"));
1603        assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
1604        assert!(!SOURCE_EXTENSIONS.contains(&"md"));
1605        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1606        assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
1607    }
1608
1609    #[test]
1610    fn source_extensions_include_html() {
1611        assert!(SOURCE_EXTENSIONS.contains(&"html"));
1612    }
1613
1614    #[test]
1615    fn source_extensions_include_graphql_documents() {
1616        assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
1617        assert!(SOURCE_EXTENSIONS.contains(&"gql"));
1618    }
1619
1620    fn build_production_glob_set() -> globset::GlobSet {
1621        let mut builder = globset::GlobSetBuilder::new();
1622        for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1623            builder.add(
1624                globset::GlobBuilder::new(pattern)
1625                    .literal_separator(true)
1626                    .build()
1627                    .expect("valid glob pattern"),
1628            );
1629        }
1630        builder.build().expect("valid glob set")
1631    }
1632
1633    #[test]
1634    fn production_excludes_test_files() {
1635        let set = build_production_glob_set();
1636        assert!(set.is_match("src/Button.test.ts"));
1637        assert!(set.is_match("src/utils.spec.tsx"));
1638        assert!(set.is_match("src/__tests__/helper.ts"));
1639        assert!(!set.is_match("src/Button.ts"));
1640        assert!(!set.is_match("src/utils.tsx"));
1641    }
1642
1643    #[test]
1644    fn production_excludes_story_files() {
1645        let set = build_production_glob_set();
1646        assert!(set.is_match("src/Button.stories.tsx"));
1647        assert!(set.is_match("src/Card.story.ts"));
1648        assert!(!set.is_match("src/Button.tsx"));
1649    }
1650
1651    #[test]
1652    fn production_excludes_config_files_at_root_only() {
1653        let set = build_production_glob_set();
1654        assert!(set.is_match("vitest.config.ts"));
1655        assert!(set.is_match("jest.config.js"));
1656        assert!(!set.is_match("src/app/app.config.ts"));
1657        assert!(!set.is_match("src/app/app.config.server.ts"));
1658        assert!(!set.is_match("packages/foo/vitest.config.ts"));
1659        assert!(!set.is_match("src/config.ts"));
1660    }
1661
1662    #[test]
1663    fn production_patterns_are_valid_globs() {
1664        let _ = build_production_glob_set();
1665    }
1666
1667    #[test]
1668    fn disallowed_hidden_dirs_idea() {
1669        assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1670    }
1671
1672    #[test]
1673    fn source_extensions_include_mdx() {
1674        assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1675    }
1676
1677    #[test]
1678    fn source_extensions_exclude_image_and_data_formats() {
1679        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1680        assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1681        assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1682        assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1683        assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1684        assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1685    }
1686
1687    #[test]
1688    fn is_declaration_file_matches_dts_variants() {
1689        assert!(is_declaration_file(Path::new("env.d.ts")));
1690        assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1691        assert!(is_declaration_file(Path::new("mod.d.mts")));
1692        assert!(is_declaration_file(Path::new("compat.d.cts")));
1693        assert!(!is_declaration_file(Path::new("index.ts")));
1694        assert!(!is_declaration_file(Path::new("component.tsx")));
1695        assert!(!is_declaration_file(Path::new("notes.d.txt")));
1696    }
1697
1698    #[test]
1699    fn format_size_mb_renders_one_decimal() {
1700        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1701        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1702        assert_eq!(format_size_mb(0), "0.0 MB");
1703    }
1704
1705    #[test]
1706    fn partition_by_size_no_limit_keeps_all() {
1707        let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1708        let (kept, skipped) = partition_by_size(raw, None);
1709        assert_eq!(kept.len(), 2);
1710        assert!(skipped.is_empty());
1711    }
1712
1713    #[test]
1714    fn partition_by_size_skips_strictly_over_limit() {
1715        let raw = vec![
1716            (PathBuf::from("under.ts"), 99),
1717            (PathBuf::from("exact.ts"), 100),
1718            (PathBuf::from("over.ts"), 101),
1719        ];
1720        let (kept, skipped) = partition_by_size(raw, Some(100));
1721        let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1722        assert!(kept_has("under.ts"));
1723        assert!(
1724            kept_has("exact.ts"),
1725            "a file exactly at the limit is kept (skip is strictly-greater)"
1726        );
1727        assert_eq!(skipped.len(), 1);
1728        assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1729    }
1730
1731    #[test]
1732    fn partition_by_size_exempts_declaration_files() {
1733        let raw = vec![
1734            (PathBuf::from("huge.ts"), 10_000),
1735            (PathBuf::from("auto-imports.d.ts"), 10_000),
1736        ];
1737        let (kept, skipped) = partition_by_size(raw, Some(100));
1738        assert!(
1739            kept.iter()
1740                .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1741            "declaration files are exempt from the size skip regardless of size"
1742        );
1743        assert_eq!(skipped.len(), 1);
1744        assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1745    }
1746
1747    fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1748        DiscoveredFile {
1749            id: FileId(0),
1750            path: PathBuf::from(path),
1751            size_bytes,
1752        }
1753    }
1754
1755    #[test]
1756    fn largest_files_note_below_threshold_is_none() {
1757        let files = [disco("a.ts", 100), disco("b.ts", 200)];
1758        assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1759    }
1760
1761    #[test]
1762    fn largest_files_note_single_file_uses_singular() {
1763        let files = [disco("big.ts", 5 * 1024 * 1024)];
1764        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1765        assert!(
1766            note.contains("discovered 1 file;"),
1767            "singular noun on the single-big-file path (issue #1086 regression): {note}"
1768        );
1769        assert!(!note.contains("discovered 1 files"));
1770        assert!(note.contains("big.ts (5.0 MB)"));
1771    }
1772
1773    #[test]
1774    fn largest_files_note_filters_sub_floor_files() {
1775        let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1776        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1777        assert!(note.contains("discovered 2 files;"));
1778        assert!(note.contains("big.ts (5.0 MB)"));
1779        assert!(
1780            !note.contains("tiny.ts"),
1781            "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1782        );
1783    }
1784
1785    #[test]
1786    fn largest_files_note_large_set_no_big_file_omits_list() {
1787        let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1788            .map(|i| disco(&format!("f{i}.ts"), 100))
1789            .collect();
1790        let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1791        assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1792        assert!(
1793            !note.contains("largest:"),
1794            "no sub-floor `largest:` list when no file clears the floor: {note}"
1795        );
1796    }
1797
1798    mod discover_files_integration {
1799        use std::path::PathBuf;
1800
1801        use fallow_config::{
1802            DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1803            RulesConfig,
1804        };
1805
1806        use super::*;
1807
1808        /// Create a minimal ResolvedConfig pointing at the given root directory.
1809        fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1810            FallowConfig {
1811                production: production.into(),
1812                ..Default::default()
1813            }
1814            .resolve(root, OutputFormat::Human, 1, true, true, None)
1815        }
1816
1817        /// Helper to collect discovered file names (relative to root) for assertions.
1818        /// Normalizes path separators to `/` for cross-platform test consistency.
1819        fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1820            files
1821                .iter()
1822                .map(|f| {
1823                    f.path
1824                        .strip_prefix(root)
1825                        .unwrap_or(&f.path)
1826                        .to_string_lossy()
1827                        .replace('\\', "/")
1828                })
1829                .collect()
1830        }
1831
1832        #[cfg(unix)]
1833        fn symlink_file(target: &Path, link: &Path) {
1834            std::os::unix::fs::symlink(target, link).expect("create file symlink");
1835        }
1836
1837        #[cfg(windows)]
1838        fn symlink_file(target: &Path, link: &Path) {
1839            std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1840        }
1841
1842        #[cfg(unix)]
1843        fn symlink_dir(target: &Path, link: &Path) {
1844            std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1845        }
1846
1847        #[cfg(windows)]
1848        fn symlink_dir(target: &Path, link: &Path) {
1849            std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1850        }
1851
1852        #[test]
1853        fn source_symlinks_must_target_regular_files_inside_root() {
1854            let dir = tempfile::tempdir().expect("create project");
1855            let outside = tempfile::tempdir().expect("create outside dir");
1856            let src = dir.path().join("src");
1857            std::fs::create_dir_all(&src).unwrap();
1858            std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1859            std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1860            std::fs::write(
1861                outside.path().join("outside-target.ts"),
1862                "export const outside = 1;",
1863            )
1864            .unwrap();
1865            std::fs::create_dir_all(src.join("directory-target")).unwrap();
1866
1867            symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1868            symlink_file(
1869                &outside.path().join("outside-target.ts"),
1870                &src.join("outside-link.ts"),
1871            );
1872            symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1873            symlink_dir(
1874                &src.join("directory-target"),
1875                &src.join("directory-link.ts"),
1876            );
1877
1878            let config = make_config(dir.path().to_path_buf(), false);
1879            let names = file_names(&discover_files(&config), dir.path());
1880
1881            assert!(names.contains(&"src/regular.ts".to_string()));
1882            assert!(names.contains(&"src/inside-target.ts".to_string()));
1883            assert!(names.contains(&"src/inside-link.ts".to_string()));
1884            assert!(!names.contains(&"src/outside-link.ts".to_string()));
1885            assert!(!names.contains(&"src/broken-link.ts".to_string()));
1886            assert!(!names.contains(&"src/directory-link.ts".to_string()));
1887        }
1888
1889        /// Yarn PnP writes `.pnp.cjs` and `.pnp.loader.mjs` at the workspace
1890        /// root. They match the source extension filter but are generated
1891        /// install state, not code to analyze.
1892        #[test]
1893        fn skips_yarn_pnp_generated_files() {
1894            let dir = tempfile::tempdir().expect("create temp dir");
1895            std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
1896            std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
1897            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1898
1899            let config = make_config(dir.path().to_path_buf(), false);
1900            let names = file_names(&discover_files(&config), dir.path());
1901
1902            assert_eq!(names, vec!["index.ts".to_string()]);
1903        }
1904
1905        #[test]
1906        fn discovers_source_files_with_valid_extensions() {
1907            let dir = tempfile::tempdir().expect("create temp dir");
1908            let src = dir.path().join("src");
1909            std::fs::create_dir_all(&src).unwrap();
1910
1911            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1912            std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1913            std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1914            std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1915            std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1916            std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1917            std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1918            std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1919
1920            let config = make_config(dir.path().to_path_buf(), false);
1921            let files = discover_files(&config);
1922            let names = file_names(&files, dir.path());
1923
1924            assert!(names.contains(&"src/app.ts".to_string()));
1925            assert!(names.contains(&"src/component.tsx".to_string()));
1926            assert!(names.contains(&"src/utils.js".to_string()));
1927            assert!(names.contains(&"src/helper.jsx".to_string()));
1928            assert!(names.contains(&"src/config.mjs".to_string()));
1929            assert!(names.contains(&"src/legacy.cjs".to_string()));
1930            assert!(names.contains(&"src/types.mts".to_string()));
1931            assert!(names.contains(&"src/compat.cts".to_string()));
1932        }
1933
1934        #[test]
1935        fn compact_source_glob_preserves_discovered_file_inventory() {
1936            let dir = tempfile::tempdir().expect("create temp dir");
1937            let nested = dir.path().join("packages/ui/src/nested");
1938            std::fs::create_dir_all(&nested).unwrap();
1939
1940            let mut expected = Vec::new();
1941            for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1942                let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1943                std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1944                expected.push(relative);
1945            }
1946            for relative in [
1947                "packages/ui/src/nested/env.d.ts",
1948                "packages/ui/src/nested/generated.d.mts",
1949                "packages/ui/src/nested/compat.d.cts",
1950            ] {
1951                std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1952                expected.push(relative.to_string());
1953            }
1954            let rejected = [
1955                "packages/ui/src/nested/component.tsx.bak",
1956                "packages/ui/src/nested/component.tsxmap",
1957                "packages/ui/src/nested/component.TS",
1958                "packages/ui/src/nested/component.gqlx",
1959                "packages/ui/src/nested/component.htm",
1960                "packages/ui/src/nested/component",
1961                "packages/ui/src/nested/component.png",
1962            ];
1963            for relative in rejected {
1964                std::fs::write(dir.path().join(relative), "not source").unwrap();
1965            }
1966
1967            let config = make_config(dir.path().to_path_buf(), false);
1968            let names = file_names(&discover_files(&config), dir.path());
1969
1970            for relative in expected {
1971                assert!(
1972                    names.contains(&relative),
1973                    "missing supported source {relative}"
1974                );
1975            }
1976            for relative in rejected {
1977                assert!(
1978                    !names.iter().any(|name| name == relative),
1979                    "unexpected near-miss source {relative}"
1980                );
1981            }
1982        }
1983
1984        #[test]
1985        fn excludes_non_source_extensions() {
1986            let dir = tempfile::tempdir().expect("create temp dir");
1987            let src = dir.path().join("src");
1988            std::fs::create_dir_all(&src).unwrap();
1989
1990            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1991
1992            std::fs::write(src.join("data.json"), "{}").unwrap();
1993            std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1994            std::fs::write(src.join("notes.txt"), "notes").unwrap();
1995            std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1996
1997            let config = make_config(dir.path().to_path_buf(), false);
1998            let files = discover_files(&config);
1999            let names = file_names(&files, dir.path());
2000
2001            assert_eq!(names.len(), 1, "only the .ts file should be discovered");
2002            assert!(names.contains(&"src/app.ts".to_string()));
2003        }
2004
2005        #[test]
2006        fn excludes_disallowed_hidden_directories() {
2007            let dir = tempfile::tempdir().expect("create temp dir");
2008
2009            let git_dir = dir.path().join(".git");
2010            std::fs::create_dir_all(&git_dir).unwrap();
2011            std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
2012
2013            let idea_dir = dir.path().join(".idea");
2014            std::fs::create_dir_all(&idea_dir).unwrap();
2015            std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
2016
2017            let cache_dir = dir.path().join(".cache");
2018            std::fs::create_dir_all(&cache_dir).unwrap();
2019            std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2020
2021            let src = dir.path().join("src");
2022            std::fs::create_dir_all(&src).unwrap();
2023            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2024
2025            let config = make_config(dir.path().to_path_buf(), false);
2026            let files = discover_files(&config);
2027            let names = file_names(&files, dir.path());
2028
2029            assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2030            assert!(names.contains(&"src/app.ts".to_string()));
2031        }
2032
2033        #[test]
2034        fn includes_allowed_hidden_directories() {
2035            let dir = tempfile::tempdir().expect("create temp dir");
2036
2037            let storybook = dir.path().join(".storybook");
2038            std::fs::create_dir_all(&storybook).unwrap();
2039            std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2040
2041            let github = dir.path().join(".github");
2042            std::fs::create_dir_all(&github).unwrap();
2043            std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2044
2045            let changeset = dir.path().join(".changeset");
2046            std::fs::create_dir_all(&changeset).unwrap();
2047            std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2048
2049            let config = make_config(dir.path().to_path_buf(), false);
2050            let files = discover_files(&config);
2051            let names = file_names(&files, dir.path());
2052
2053            assert!(
2054                names.contains(&".storybook/main.ts".to_string()),
2055                "files in .storybook should be discovered"
2056            );
2057            assert!(
2058                names.contains(&".github/actions.js".to_string()),
2059                "files in .github should be discovered"
2060            );
2061            assert!(
2062                names.contains(&".changeset/config.js".to_string()),
2063                "files in .changeset should be discovered"
2064            );
2065        }
2066
2067        #[test]
2068        fn default_discovery_excludes_client_and_server_hidden_directories() {
2069            let dir = tempfile::tempdir().expect("create temp dir");
2070            let app = dir.path().join("app");
2071            std::fs::create_dir_all(app.join(".client")).unwrap();
2072            std::fs::create_dir_all(app.join(".server")).unwrap();
2073            std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2074            std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2075            std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2076
2077            let config = make_config(dir.path().to_path_buf(), false);
2078            let files = discover_files(&config);
2079            let names = file_names(&files, dir.path());
2080
2081            assert!(names.contains(&"app/root.tsx".to_string()));
2082            assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2083            assert!(!names.contains(&"app/.server/db.ts".to_string()));
2084        }
2085
2086        #[test]
2087        fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2088            let dir = tempfile::tempdir().expect("create temp dir");
2089            let package = dir.path().join("packages/app");
2090            std::fs::create_dir_all(package.join("app/.client")).unwrap();
2091            std::fs::create_dir_all(package.join("app/.server")).unwrap();
2092            std::fs::write(
2093                package.join("app/.client/analytics.ts"),
2094                "export const track = () => {};",
2095            )
2096            .unwrap();
2097            std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2098
2099            let config = make_config(dir.path().to_path_buf(), false);
2100            let scopes = [HiddenDirScope::new(
2101                package,
2102                vec![".client".to_string(), ".server".to_string()],
2103            )];
2104            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2105            let names = file_names(&files, dir.path());
2106
2107            assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2108            assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2109        }
2110
2111        #[test]
2112        fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2113            let dir = tempfile::tempdir().expect("create temp dir");
2114            let active = dir.path().join("packages/active");
2115            let inactive = dir.path().join("packages/inactive");
2116            std::fs::create_dir_all(active.join("app/.server")).unwrap();
2117            std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2118            std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2119            std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2120
2121            let config = make_config(dir.path().to_path_buf(), false);
2122            let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2123            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2124            let names = file_names(&files, dir.path());
2125
2126            assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2127            assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2128        }
2129
2130        #[test]
2131        fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2132            // A script naming `.a/.b/deep.mjs` says where the file it needs
2133            // lives. Before issue #461 the scope stored the bare names, so an
2134            // unrelated `elsewhere/.b` and `unrelated/.a` were pulled in too.
2135            let dir = tempfile::tempdir().expect("create temp dir");
2136            std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2137            std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2138            std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2139            std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2140            std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2141            std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2142
2143            let config = make_config(dir.path().to_path_buf(), false);
2144            let scopes = [HiddenDirScope::new_exact_paths(
2145                dir.path().to_path_buf(),
2146                vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2147            )];
2148            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2149            let names = file_names(&files, dir.path());
2150
2151            assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2152            assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2153            assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2154        }
2155
2156        #[test]
2157        fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2158            let dir = tempfile::tempdir().expect("create temp dir");
2159            std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2160            std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2161            std::fs::write(
2162                dir.path().join("tools/.config/eslint.config.js"),
2163                "export default [];",
2164            )
2165            .unwrap();
2166            std::fs::write(
2167                dir.path().join("other/.config/eslint.config.js"),
2168                "export default [];",
2169            )
2170            .unwrap();
2171
2172            let config = make_config(dir.path().to_path_buf(), false);
2173            let scopes = [HiddenDirScope::new_exact_paths(
2174                dir.path().to_path_buf(),
2175                vec![format!("tools{MAIN_SEPARATOR}.config")],
2176            )];
2177            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2178            let names = file_names(&files, dir.path());
2179
2180            assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2181            assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2182        }
2183
2184        #[test]
2185        fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2186            // Framework plugins declare `.client` / `.server` conventions that
2187            // may sit under any route directory, so the plugin shape must keep
2188            // matching at any depth.
2189            let dir = tempfile::tempdir().expect("create temp dir");
2190            std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2191            std::fs::write(
2192                dir.path().join("app/routes/deep/.server/db.ts"),
2193                "export const db = {};",
2194            )
2195            .unwrap();
2196
2197            let config = make_config(dir.path().to_path_buf(), false);
2198            let scopes = [HiddenDirScope::new(
2199                dir.path().to_path_buf(),
2200                vec![".server".to_string()],
2201            )];
2202            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2203            let names = file_names(&files, dir.path());
2204
2205            assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2206        }
2207
2208        #[test]
2209        fn excludes_root_build_directory() {
2210            let dir = tempfile::tempdir().expect("create temp dir");
2211
2212            std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2213
2214            let build_dir = dir.path().join("build");
2215            std::fs::create_dir_all(&build_dir).unwrap();
2216            std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2217
2218            let src = dir.path().join("src");
2219            std::fs::create_dir_all(&src).unwrap();
2220            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2221
2222            let config = make_config(dir.path().to_path_buf(), false);
2223            let files = discover_files(&config);
2224            let names = file_names(&files, dir.path());
2225
2226            assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2227            assert!(names.contains(&"src/app.ts".to_string()));
2228        }
2229
2230        #[test]
2231        fn includes_nested_build_directory() {
2232            let dir = tempfile::tempdir().expect("create temp dir");
2233
2234            let nested_build = dir.path().join("src").join("build");
2235            std::fs::create_dir_all(&nested_build).unwrap();
2236            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2237
2238            let config = make_config(dir.path().to_path_buf(), false);
2239            let files = discover_files(&config);
2240            let names = file_names(&files, dir.path());
2241
2242            assert!(
2243                names.contains(&"src/build/helper.ts".to_string()),
2244                "nested build/ directories should be included"
2245            );
2246        }
2247
2248        #[test]
2249        #[expect(
2250            clippy::cast_possible_truncation,
2251            reason = "test file counts are trivially small"
2252        )]
2253        fn file_ids_are_sequential_after_sorting() {
2254            let dir = tempfile::tempdir().expect("create temp dir");
2255            let src = dir.path().join("src");
2256            std::fs::create_dir_all(&src).unwrap();
2257
2258            std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2259            std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2260            std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2261
2262            let config = make_config(dir.path().to_path_buf(), false);
2263            let files = discover_files(&config);
2264
2265            for (idx, file) in files.iter().enumerate() {
2266                assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2267            }
2268
2269            for pair in files.windows(2) {
2270                assert!(
2271                    pair[0].path < pair[1].path,
2272                    "files should be sorted by path"
2273                );
2274            }
2275        }
2276
2277        #[test]
2278        fn production_mode_excludes_test_files() {
2279            let dir = tempfile::tempdir().expect("create temp dir");
2280            let src = dir.path().join("src");
2281            std::fs::create_dir_all(&src).unwrap();
2282
2283            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2284            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2285            std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2286            std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2287
2288            let config = make_config(dir.path().to_path_buf(), true);
2289            let files = discover_files(&config);
2290            let names = file_names(&files, dir.path());
2291
2292            assert!(
2293                names.contains(&"src/app.ts".to_string()),
2294                "source files should be included in production mode"
2295            );
2296            assert!(
2297                !names.contains(&"src/app.test.ts".to_string()),
2298                "test files should be excluded in production mode"
2299            );
2300            assert!(
2301                !names.contains(&"src/app.spec.ts".to_string()),
2302                "spec files should be excluded in production mode"
2303            );
2304            assert!(
2305                !names.contains(&"src/app.stories.tsx".to_string()),
2306                "story files should be excluded in production mode"
2307            );
2308        }
2309
2310        #[test]
2311        fn non_production_mode_includes_test_files() {
2312            let dir = tempfile::tempdir().expect("create temp dir");
2313            let src = dir.path().join("src");
2314            std::fs::create_dir_all(&src).unwrap();
2315
2316            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2317            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2318
2319            let config = make_config(dir.path().to_path_buf(), false);
2320            let files = discover_files(&config);
2321            let names = file_names(&files, dir.path());
2322
2323            assert!(names.contains(&"src/app.ts".to_string()));
2324            assert!(
2325                names.contains(&"src/app.test.ts".to_string()),
2326                "test files should be included in non-production mode"
2327            );
2328        }
2329
2330        #[test]
2331        fn empty_directory_returns_no_files() {
2332            let dir = tempfile::tempdir().expect("create temp dir");
2333            let config = make_config(dir.path().to_path_buf(), false);
2334            let files = discover_files(&config);
2335            assert!(files.is_empty(), "empty project should discover no files");
2336        }
2337
2338        #[test]
2339        fn hidden_files_not_discovered_as_source() {
2340            let dir = tempfile::tempdir().expect("create temp dir");
2341
2342            std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2343            std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2344            std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2345
2346            let src = dir.path().join("src");
2347            std::fs::create_dir_all(&src).unwrap();
2348            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2349
2350            let config = make_config(dir.path().to_path_buf(), false);
2351            let files = discover_files(&config);
2352            let names = file_names(&files, dir.path());
2353
2354            assert!(
2355                !names.contains(&".env".to_string()),
2356                ".env should not be discovered"
2357            );
2358            assert!(
2359                !names.contains(&".gitignore".to_string()),
2360                ".gitignore should not be discovered"
2361            );
2362        }
2363
2364        /// Create a config with custom ignore patterns.
2365        fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2366            FallowConfig {
2367                type_aware: fallow_config::TypeAwareConfig::default(),
2368                schema: None,
2369                minimum_version: None,
2370                extends: vec![],
2371                entry: vec![],
2372                ignore_patterns: ignores,
2373                ignore_findings: vec![],
2374                framework: vec![],
2375                workspaces: None,
2376                ignore_dependencies: vec![],
2377                ignore_unresolved_imports: vec![],
2378                ignore_exports: vec![],
2379                ignore_catalog_references: vec![],
2380                ignore_dependency_overrides: vec![],
2381                ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2382                ),
2383                used_class_members: vec![],
2384                ignore_decorators: vec![],
2385                unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2386                duplicates: DuplicatesConfig::default(),
2387                similar_code: fallow_config::SimilarCodeConfig::default(),
2388                health: HealthConfig::default(),
2389                rules: RulesConfig::default(),
2390                boundaries: fallow_config::BoundaryConfig::default(),
2391                production: false.into(),
2392                plugins: vec![],
2393                rule_packs: vec![],
2394                dynamically_loaded: vec![],
2395                overrides: vec![],
2396                regression: None,
2397                audit: fallow_config::AuditConfig::default(),
2398                codeowners: None,
2399                public_packages: vec![],
2400                flags: FlagsConfig::default(),
2401                security: fallow_config::SecurityConfig::default(),
2402                fix: fallow_config::FixConfig::default(),
2403                resolve: ResolveConfig::default(),
2404                sealed: false,
2405                include_entry_exports: false,
2406                auto_imports: false,
2407                cache: fallow_config::CacheConfig::default(),
2408            }
2409            .resolve(root, OutputFormat::Human, 1, true, true, None)
2410        }
2411
2412        #[test]
2413        fn custom_ignore_patterns_exclude_matching_files() {
2414            let dir = tempfile::tempdir().expect("create temp dir");
2415
2416            let generated = dir.path().join("src").join("api").join("generated");
2417            std::fs::create_dir_all(&generated).unwrap();
2418            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2419
2420            let client = dir.path().join("src").join("api").join("client");
2421            std::fs::create_dir_all(&client).unwrap();
2422            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2423
2424            let src = dir.path().join("src");
2425            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2426
2427            let config = make_config_with_ignores(
2428                dir.path().to_path_buf(),
2429                vec![
2430                    "src/api/generated/**".to_string(),
2431                    "src/api/client/**".to_string(),
2432                ],
2433            );
2434            let files = discover_files(&config);
2435            let names = file_names(&files, dir.path());
2436
2437            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2438            assert!(names.contains(&"src/index.ts".to_string()));
2439        }
2440
2441        #[test]
2442        fn leading_dot_ignore_patterns_exclude_matching_files() {
2443            let dir = tempfile::tempdir().expect("create temp dir");
2444
2445            let generated = dir.path().join("src").join("generated");
2446            std::fs::create_dir_all(&generated).unwrap();
2447            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2448
2449            let src = dir.path().join("src");
2450            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2451
2452            let config = make_config_with_ignores(
2453                dir.path().to_path_buf(),
2454                vec!["./src/generated/**".to_string()],
2455            );
2456            let files = discover_files(&config);
2457            let names = file_names(&files, dir.path());
2458
2459            assert_eq!(names, vec!["src/index.ts"]);
2460        }
2461
2462        #[test]
2463        fn default_ignore_patterns_exclude_node_modules_and_dist() {
2464            let dir = tempfile::tempdir().expect("create temp dir");
2465
2466            let nm = dir.path().join("node_modules").join("lodash");
2467            std::fs::create_dir_all(&nm).unwrap();
2468            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2469
2470            let dist = dir.path().join("dist");
2471            std::fs::create_dir_all(&dist).unwrap();
2472            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2473
2474            let src = dir.path().join("src");
2475            std::fs::create_dir_all(&src).unwrap();
2476            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2477
2478            let config = make_config(dir.path().to_path_buf(), false);
2479            let files = discover_files(&config);
2480            let names = file_names(&files, dir.path());
2481
2482            assert_eq!(names.len(), 1);
2483            assert!(names.contains(&"src/index.ts".to_string()));
2484        }
2485
2486        #[test]
2487        fn default_ignore_patterns_exclude_root_build() {
2488            let dir = tempfile::tempdir().expect("create temp dir");
2489
2490            let build = dir.path().join("build");
2491            std::fs::create_dir_all(&build).unwrap();
2492            std::fs::write(build.join("output.js"), "// built").unwrap();
2493
2494            let nested_build = dir.path().join("src").join("build");
2495            std::fs::create_dir_all(&nested_build).unwrap();
2496            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2497
2498            let src = dir.path().join("src");
2499            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2500
2501            let config = make_config(dir.path().to_path_buf(), false);
2502            let files = discover_files(&config);
2503            let names = file_names(&files, dir.path());
2504
2505            assert_eq!(
2506                names.len(),
2507                2,
2508                "root build/ excluded, nested kept: {names:?}"
2509            );
2510            assert!(names.contains(&"src/index.ts".to_string()));
2511            assert!(names.contains(&"src/build/helper.ts".to_string()));
2512        }
2513
2514        /// Resolve a config then override the per-file size limit in bytes.
2515        fn make_config_with_max_file_size(
2516            root: PathBuf,
2517            max_file_size_bytes: Option<u64>,
2518        ) -> ResolvedConfig {
2519            let mut config = make_config(root, false);
2520            config.max_file_size_bytes = max_file_size_bytes;
2521            config
2522        }
2523
2524        #[test]
2525        fn skips_files_over_max_file_size() {
2526            let dir = tempfile::tempdir().expect("create temp dir");
2527            let src = dir.path().join("src");
2528            std::fs::create_dir_all(&src).unwrap();
2529            std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
2530            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2531
2532            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2533            let files = discover_files(&config);
2534            let names = file_names(&files, dir.path());
2535
2536            assert!(names.contains(&"src/small.ts".to_string()));
2537            assert!(
2538                !names.contains(&"src/huge.ts".to_string()),
2539                "a file over the size limit must not be discovered"
2540            );
2541        }
2542
2543        #[test]
2544        fn declaration_files_exempt_from_size_skip() {
2545            let dir = tempfile::tempdir().expect("create temp dir");
2546            let src = dir.path().join("src");
2547            std::fs::create_dir_all(&src).unwrap();
2548            std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
2549            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2550
2551            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2552            let files = discover_files(&config);
2553            let names = file_names(&files, dir.path());
2554
2555            assert!(
2556                names.contains(&"src/auto-imports.d.ts".to_string()),
2557                "a large .d.ts is exempt from the skip (reachability root for global types)"
2558            );
2559            assert!(!names.contains(&"src/huge.ts".to_string()));
2560        }
2561
2562        #[test]
2563        fn unlimited_size_keeps_large_files() {
2564            let dir = tempfile::tempdir().expect("create temp dir");
2565            let src = dir.path().join("src");
2566            std::fs::create_dir_all(&src).unwrap();
2567            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2568
2569            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2570            let files = discover_files(&config);
2571            let names = file_names(&files, dir.path());
2572
2573            assert!(
2574                names.contains(&"src/huge.ts".to_string()),
2575                "no limit keeps every file"
2576            );
2577        }
2578
2579        #[test]
2580        fn skipped_file_recorded_in_workspace_diagnostics() {
2581            let dir = tempfile::tempdir().expect("create temp dir");
2582            let src = dir.path().join("src");
2583            std::fs::create_dir_all(&src).unwrap();
2584            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2585
2586            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2587            let _ = discover_files(&config);
2588
2589            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2590            let skipped: Vec<_> = diagnostics
2591                .iter()
2592                .filter(|d| {
2593                    matches!(
2594                        d.kind,
2595                        fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
2596                    )
2597                })
2598                .collect();
2599            assert_eq!(
2600                skipped.len(),
2601                1,
2602                "the skipped file is recorded in workspace diagnostics for JSON output"
2603            );
2604            assert!(skipped[0].path.ends_with("src/huge.ts"));
2605            assert!(
2606                matches!(
2607                    skipped[0].kind,
2608                    fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
2609                        if size_bytes == 5_000
2610                ),
2611                "the recorded diagnostic carries the on-disk byte size"
2612            );
2613        }
2614
2615        /// The skipped-source-dotdir entries the last walk on `root` recorded.
2616        fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
2617            fallow_config::workspace_diagnostics_for(root)
2618                .into_iter()
2619                .filter(|d| {
2620                    matches!(
2621                        d.kind,
2622                        fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
2623                    )
2624                })
2625                .collect()
2626        }
2627
2628        fn write_at(root: &Path, relative: &str, contents: &str) {
2629            let path = root.join(relative);
2630            std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
2631            std::fs::write(path, contents).unwrap();
2632        }
2633
2634        #[test]
2635        fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
2636            let dir = tempfile::tempdir().expect("create temp dir");
2637            write_at(
2638                dir.path(),
2639                ".claude/hooks/probe.mjs",
2640                "export const a = 1;\n",
2641            );
2642            write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
2643
2644            let config = make_config(dir.path().to_path_buf(), false);
2645            let files = discover_files(&config);
2646            let names = file_names(&files, dir.path());
2647
2648            let reported = dotdir_diagnostics(dir.path());
2649            assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
2650            assert!(reported[0].path.ends_with(".claude"));
2651            assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
2652            assert!(
2653                reported[0].message.contains("--root"),
2654                "message names the real remedy: {}",
2655                reported[0].message
2656            );
2657            assert!(
2658                names.contains(&"src/app.ts".to_string()),
2659                "traversal is unchanged for ordinary directories"
2660            );
2661            assert!(
2662                !names.contains(&".claude/hooks/probe.mjs".to_string()),
2663                "the diagnostic reports the skip, it does not change traversal"
2664            );
2665        }
2666
2667        #[test]
2668        fn allowlisted_dotdir_is_not_reported() {
2669            let dir = tempfile::tempdir().expect("create temp dir");
2670            write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
2671
2672            let config = make_config(dir.path().to_path_buf(), false);
2673            let files = discover_files(&config);
2674            let names = file_names(&files, dir.path());
2675
2676            assert!(dotdir_diagnostics(dir.path()).is_empty());
2677            assert!(
2678                names.contains(&".storybook/main.ts".to_string()),
2679                "an allowlisted dotdir is still traversed"
2680            );
2681        }
2682
2683        #[test]
2684        fn denylisted_dotdir_is_not_reported() {
2685            let dir = tempfile::tempdir().expect("create temp dir");
2686            write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
2687            write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
2688            write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
2689            write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
2690
2691            let config = make_config(dir.path().to_path_buf(), false);
2692            let _ = discover_files(&config);
2693
2694            assert!(
2695                dotdir_diagnostics(dir.path()).is_empty(),
2696                "build caches, VCS and package-manager state never advise"
2697            );
2698        }
2699
2700        #[test]
2701        fn scoped_dotdir_is_traversed_and_not_reported() {
2702            let dir = tempfile::tempdir().expect("create temp dir");
2703            write_at(
2704                dir.path(),
2705                ".claude/hooks/probe.mjs",
2706                "export const a = 1;\n",
2707            );
2708
2709            let config = make_config(dir.path().to_path_buf(), false);
2710            let scopes = [HiddenDirScope::new(
2711                dir.path().to_path_buf(),
2712                vec![".claude".to_owned()],
2713            )];
2714            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2715            let names = file_names(&files, dir.path());
2716
2717            assert!(
2718                dotdir_diagnostics(dir.path()).is_empty(),
2719                "a plugin- or script-contributed scope is admitted, so nothing was skipped"
2720            );
2721            assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
2722        }
2723
2724        #[test]
2725        fn ignore_patterns_silence_the_skipped_source_dotdir() {
2726            let dir = tempfile::tempdir().expect("create temp dir");
2727            write_at(
2728                dir.path(),
2729                ".claude/hooks/probe.mjs",
2730                "export const a = 1;\n",
2731            );
2732
2733            let config =
2734                make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
2735            let _ = discover_files(&config);
2736
2737            assert!(
2738                dotdir_diagnostics(dir.path()).is_empty(),
2739                "the documented silencing route works"
2740            );
2741        }
2742
2743        #[test]
2744        fn dotdir_without_source_files_is_not_reported() {
2745            let dir = tempfile::tempdir().expect("create temp dir");
2746            write_at(dir.path(), ".claude/settings.json", "{}\n");
2747            write_at(dir.path(), ".claude/README.md", "# notes\n");
2748
2749            let config = make_config(dir.path().to_path_buf(), false);
2750            let _ = discover_files(&config);
2751
2752            assert!(dotdir_diagnostics(dir.path()).is_empty());
2753        }
2754
2755        #[test]
2756        fn dotdir_source_at_scan_depth_limit_is_reported() {
2757            let dir = tempfile::tempdir().expect("create temp dir");
2758            write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
2759
2760            let config = make_config(dir.path().to_path_buf(), false);
2761            let _ = discover_files(&config);
2762
2763            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2764        }
2765
2766        #[test]
2767        fn dotdir_source_below_scan_depth_limit_is_not_reported() {
2768            let dir = tempfile::tempdir().expect("create temp dir");
2769            write_at(
2770                dir.path(),
2771                ".claude/a/b/c/deeper.ts",
2772                "export const a = 1;\n",
2773            );
2774
2775            let config = make_config(dir.path().to_path_buf(), false);
2776            let _ = discover_files(&config);
2777
2778            assert!(
2779                dotdir_diagnostics(dir.path()).is_empty(),
2780                "the depth cap is real, so widening it stays a deliberate act"
2781            );
2782        }
2783
2784        /// Mark `root` as a git worktree so the `ignore` crate applies the
2785        /// gitignore files below it. `require_git` is on by default, and it
2786        /// tests for the presence of `.git`, not for a valid object store.
2787        fn mark_as_git_repo(root: &Path) {
2788            std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
2789        }
2790
2791        #[test]
2792        fn gitignored_dotdir_contents_are_not_reported() {
2793            // The directory FORM (`.tooling/`) prunes the dotdir upstream of the
2794            // predicate, so these are the forms that reach it with every file
2795            // inside already ignored.
2796            for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
2797                let dir = tempfile::tempdir().expect("create temp dir");
2798                mark_as_git_repo(dir.path());
2799                write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
2800                write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2801
2802                let config = make_config(dir.path().to_path_buf(), false);
2803                let _ = discover_files(&config);
2804
2805                assert!(
2806                    dotdir_diagnostics(dir.path()).is_empty(),
2807                    "gitignore pattern '{pattern}' excludes the contents, so neither \
2808                     advertised remedy would find anything there"
2809                );
2810            }
2811        }
2812
2813        #[test]
2814        fn self_ignoring_dotdir_is_not_reported() {
2815            let dir = tempfile::tempdir().expect("create temp dir");
2816            mark_as_git_repo(dir.path());
2817            write_at(dir.path(), ".toolcache/.gitignore", "*\n");
2818            write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
2819
2820            let config = make_config(dir.path().to_path_buf(), false);
2821            let _ = discover_files(&config);
2822
2823            assert!(
2824                dotdir_diagnostics(dir.path()).is_empty(),
2825                "a cache directory that ignores itself has excluded its own contents"
2826            );
2827        }
2828
2829        #[test]
2830        fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
2831            let dir = tempfile::tempdir().expect("create temp dir");
2832            mark_as_git_repo(dir.path());
2833            write_at(dir.path(), ".gitignore", "dist/\n");
2834            write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2835
2836            let config = make_config(dir.path().to_path_buf(), false);
2837            let _ = discover_files(&config);
2838
2839            assert_eq!(
2840                dotdir_diagnostics(dir.path()).len(),
2841                1,
2842                "the gitignore check must not swallow the case the diagnostic exists for"
2843            );
2844        }
2845
2846        #[test]
2847        fn production_run_does_not_report_a_test_only_dotdir() {
2848            let dir = tempfile::tempdir().expect("create temp dir");
2849            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2850            write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
2851
2852            let config = make_config(dir.path().to_path_buf(), true);
2853            let _ = discover_files(&config);
2854
2855            assert!(
2856                dotdir_diagnostics(dir.path()).is_empty(),
2857                "a --production run would analyze none of those files, so the \
2858                 --root remedy would return nothing"
2859            );
2860        }
2861
2862        #[test]
2863        fn production_run_still_reports_a_dotdir_with_production_source() {
2864            let dir = tempfile::tempdir().expect("create temp dir");
2865            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2866            write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
2867
2868            let config = make_config(dir.path().to_path_buf(), true);
2869            let _ = discover_files(&config);
2870
2871            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2872        }
2873
2874        #[test]
2875        fn dotdir_with_only_generated_markup_is_not_reported() {
2876            let dir = tempfile::tempdir().expect("create temp dir");
2877            write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
2878            write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
2879            write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
2880
2881            let config = make_config(dir.path().to_path_buf(), false);
2882            let _ = discover_files(&config);
2883
2884            assert!(
2885                dotdir_diagnostics(dir.path()).is_empty(),
2886                "the message claims imports and exports are lost, and these have none"
2887            );
2888        }
2889
2890        #[test]
2891        fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
2892            let dir = tempfile::tempdir().expect("create temp dir");
2893            write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
2894            write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
2895            write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
2896            write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
2897            write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
2898
2899            let config = make_config(dir.path().to_path_buf(), false);
2900            let _ = discover_files(&config);
2901
2902            assert!(
2903                dotdir_diagnostics(dir.path()).is_empty(),
2904                "generated output and foreign VCS metadata are not first-party source"
2905            );
2906        }
2907
2908        #[test]
2909        fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
2910            let dir = tempfile::tempdir().expect("create temp dir");
2911            // Sorted before the real candidate, and more of them than the
2912            // ceiling, so a cap applied before the name checks would hide it.
2913            for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
2914                write_at(
2915                    dir.path(),
2916                    &format!("packages/pkg{index:03}/.turbo/blob.js"),
2917                    "export const a = 1;\n",
2918                );
2919            }
2920            write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
2921
2922            let config = make_config(dir.path().to_path_buf(), false);
2923            let _ = discover_files(&config);
2924
2925            let reported = dotdir_diagnostics(dir.path());
2926            assert_eq!(reported.len(), 1, "{reported:?}");
2927            assert!(reported[0].path.ends_with(".tooling"));
2928        }
2929
2930        #[test]
2931        fn one_pathological_dotdir_cannot_starve_the_rest() {
2932            let dir = tempfile::tempdir().expect("create temp dir");
2933            // Wide and shallow, no source: exhausts this candidate's own budget.
2934            for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
2935                write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
2936            }
2937            write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
2938
2939            let config = make_config(dir.path().to_path_buf(), false);
2940            let _ = discover_files(&config);
2941
2942            let reported = dotdir_diagnostics(dir.path());
2943            assert_eq!(reported.len(), 1, "{reported:?}");
2944            assert!(reported[0].path.ends_with(".zzz-real"));
2945        }
2946
2947        #[test]
2948        fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
2949            let dir = tempfile::tempdir().expect("create temp dir");
2950            write_at(
2951                dir.path(),
2952                ".claude/hooks/probe.mjs",
2953                "export const a = 1;\n",
2954            );
2955
2956            let config = make_config(dir.path().to_path_buf(), false);
2957            let _ = discover_files(&config);
2958            let _ = discover_files(&config);
2959
2960            assert_eq!(
2961                dotdir_diagnostics(dir.path()).len(),
2962                1,
2963                "each walk replaces its own root's source-discovery set"
2964            );
2965        }
2966
2967        #[test]
2968        fn skips_large_one_line_js_as_minified_generated_output() {
2969            let dir = tempfile::tempdir().expect("create temp dir");
2970            let src = dir.path().join("src");
2971            std::fs::create_dir_all(&src).unwrap();
2972            let asset = src.join("index-abc123.js");
2973            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2974
2975            let config = make_config(dir.path().to_path_buf(), false);
2976            let files = discover_files(&config);
2977            let names = file_names(&files, dir.path());
2978
2979            assert!(
2980                !names.contains(&"src/index-abc123.js".to_string()),
2981                "large one-line JS assets should be skipped before parsing"
2982            );
2983
2984            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2985            assert!(
2986                diagnostics.iter().any(|diag| {
2987                    diag.path.ends_with("src/index-abc123.js")
2988                        && matches!(
2989                            diag.kind,
2990                            fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
2991                        )
2992                }),
2993                "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
2994            );
2995        }
2996
2997        #[test]
2998        fn unlimited_size_keeps_large_one_line_js() {
2999            let dir = tempfile::tempdir().expect("create temp dir");
3000            let src = dir.path().join("src");
3001            std::fs::create_dir_all(&src).unwrap();
3002            let asset = src.join("index-abc123.js");
3003            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3004
3005            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3006            let files = discover_files(&config);
3007            let names = file_names(&files, dir.path());
3008
3009            assert!(
3010                names.contains(&"src/index-abc123.js".to_string()),
3011                "--max-file-size 0 should opt out of generated JS skipping"
3012            );
3013        }
3014
3015        #[test]
3016        fn keeps_large_multiline_js() {
3017            let dir = tempfile::tempdir().expect("create temp dir");
3018            let src = dir.path().join("src");
3019            std::fs::create_dir_all(&src).unwrap();
3020            let asset = src.join("handwritten.js");
3021            let mut content = String::new();
3022            while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3023                content.push_str("export const value = 1;\n");
3024            }
3025            std::fs::write(&asset, content).unwrap();
3026
3027            let config = make_config(dir.path().to_path_buf(), false);
3028            let files = discover_files(&config);
3029            let names = file_names(&files, dir.path());
3030
3031            assert!(
3032                names.contains(&"src/handwritten.js".to_string()),
3033                "large multiline JS should not be treated as a generated minified asset"
3034            );
3035        }
3036    }
3037}