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