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                extends: vec![],
2351                entry: vec![],
2352                ignore_patterns: ignores,
2353                ignore_findings: vec![],
2354                framework: vec![],
2355                workspaces: None,
2356                ignore_dependencies: vec![],
2357                ignore_unresolved_imports: vec![],
2358                ignore_exports: vec![],
2359                ignore_catalog_references: vec![],
2360                ignore_dependency_overrides: vec![],
2361                ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2362                ),
2363                used_class_members: vec![],
2364                ignore_decorators: vec![],
2365                unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2366                duplicates: DuplicatesConfig::default(),
2367                similar_code: fallow_config::SimilarCodeConfig::default(),
2368                health: HealthConfig::default(),
2369                rules: RulesConfig::default(),
2370                boundaries: fallow_config::BoundaryConfig::default(),
2371                production: false.into(),
2372                plugins: vec![],
2373                rule_packs: vec![],
2374                dynamically_loaded: vec![],
2375                overrides: vec![],
2376                regression: None,
2377                audit: fallow_config::AuditConfig::default(),
2378                codeowners: None,
2379                public_packages: vec![],
2380                flags: FlagsConfig::default(),
2381                security: fallow_config::SecurityConfig::default(),
2382                fix: fallow_config::FixConfig::default(),
2383                resolve: ResolveConfig::default(),
2384                sealed: false,
2385                include_entry_exports: false,
2386                auto_imports: false,
2387                cache: fallow_config::CacheConfig::default(),
2388            }
2389            .resolve(root, OutputFormat::Human, 1, true, true, None)
2390        }
2391
2392        #[test]
2393        fn custom_ignore_patterns_exclude_matching_files() {
2394            let dir = tempfile::tempdir().expect("create temp dir");
2395
2396            let generated = dir.path().join("src").join("api").join("generated");
2397            std::fs::create_dir_all(&generated).unwrap();
2398            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2399
2400            let client = dir.path().join("src").join("api").join("client");
2401            std::fs::create_dir_all(&client).unwrap();
2402            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2403
2404            let src = dir.path().join("src");
2405            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2406
2407            let config = make_config_with_ignores(
2408                dir.path().to_path_buf(),
2409                vec![
2410                    "src/api/generated/**".to_string(),
2411                    "src/api/client/**".to_string(),
2412                ],
2413            );
2414            let files = discover_files(&config);
2415            let names = file_names(&files, dir.path());
2416
2417            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2418            assert!(names.contains(&"src/index.ts".to_string()));
2419        }
2420
2421        #[test]
2422        fn leading_dot_ignore_patterns_exclude_matching_files() {
2423            let dir = tempfile::tempdir().expect("create temp dir");
2424
2425            let generated = dir.path().join("src").join("generated");
2426            std::fs::create_dir_all(&generated).unwrap();
2427            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2428
2429            let src = dir.path().join("src");
2430            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2431
2432            let config = make_config_with_ignores(
2433                dir.path().to_path_buf(),
2434                vec!["./src/generated/**".to_string()],
2435            );
2436            let files = discover_files(&config);
2437            let names = file_names(&files, dir.path());
2438
2439            assert_eq!(names, vec!["src/index.ts"]);
2440        }
2441
2442        #[test]
2443        fn default_ignore_patterns_exclude_node_modules_and_dist() {
2444            let dir = tempfile::tempdir().expect("create temp dir");
2445
2446            let nm = dir.path().join("node_modules").join("lodash");
2447            std::fs::create_dir_all(&nm).unwrap();
2448            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2449
2450            let dist = dir.path().join("dist");
2451            std::fs::create_dir_all(&dist).unwrap();
2452            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2453
2454            let src = dir.path().join("src");
2455            std::fs::create_dir_all(&src).unwrap();
2456            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2457
2458            let config = make_config(dir.path().to_path_buf(), false);
2459            let files = discover_files(&config);
2460            let names = file_names(&files, dir.path());
2461
2462            assert_eq!(names.len(), 1);
2463            assert!(names.contains(&"src/index.ts".to_string()));
2464        }
2465
2466        #[test]
2467        fn default_ignore_patterns_exclude_root_build() {
2468            let dir = tempfile::tempdir().expect("create temp dir");
2469
2470            let build = dir.path().join("build");
2471            std::fs::create_dir_all(&build).unwrap();
2472            std::fs::write(build.join("output.js"), "// built").unwrap();
2473
2474            let nested_build = dir.path().join("src").join("build");
2475            std::fs::create_dir_all(&nested_build).unwrap();
2476            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2477
2478            let src = dir.path().join("src");
2479            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2480
2481            let config = make_config(dir.path().to_path_buf(), false);
2482            let files = discover_files(&config);
2483            let names = file_names(&files, dir.path());
2484
2485            assert_eq!(
2486                names.len(),
2487                2,
2488                "root build/ excluded, nested kept: {names:?}"
2489            );
2490            assert!(names.contains(&"src/index.ts".to_string()));
2491            assert!(names.contains(&"src/build/helper.ts".to_string()));
2492        }
2493
2494        /// Resolve a config then override the per-file size limit in bytes.
2495        fn make_config_with_max_file_size(
2496            root: PathBuf,
2497            max_file_size_bytes: Option<u64>,
2498        ) -> ResolvedConfig {
2499            let mut config = make_config(root, false);
2500            config.max_file_size_bytes = max_file_size_bytes;
2501            config
2502        }
2503
2504        #[test]
2505        fn skips_files_over_max_file_size() {
2506            let dir = tempfile::tempdir().expect("create temp dir");
2507            let src = dir.path().join("src");
2508            std::fs::create_dir_all(&src).unwrap();
2509            std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
2510            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2511
2512            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2513            let files = discover_files(&config);
2514            let names = file_names(&files, dir.path());
2515
2516            assert!(names.contains(&"src/small.ts".to_string()));
2517            assert!(
2518                !names.contains(&"src/huge.ts".to_string()),
2519                "a file over the size limit must not be discovered"
2520            );
2521        }
2522
2523        #[test]
2524        fn declaration_files_exempt_from_size_skip() {
2525            let dir = tempfile::tempdir().expect("create temp dir");
2526            let src = dir.path().join("src");
2527            std::fs::create_dir_all(&src).unwrap();
2528            std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
2529            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2530
2531            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2532            let files = discover_files(&config);
2533            let names = file_names(&files, dir.path());
2534
2535            assert!(
2536                names.contains(&"src/auto-imports.d.ts".to_string()),
2537                "a large .d.ts is exempt from the skip (reachability root for global types)"
2538            );
2539            assert!(!names.contains(&"src/huge.ts".to_string()));
2540        }
2541
2542        #[test]
2543        fn unlimited_size_keeps_large_files() {
2544            let dir = tempfile::tempdir().expect("create temp dir");
2545            let src = dir.path().join("src");
2546            std::fs::create_dir_all(&src).unwrap();
2547            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2548
2549            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2550            let files = discover_files(&config);
2551            let names = file_names(&files, dir.path());
2552
2553            assert!(
2554                names.contains(&"src/huge.ts".to_string()),
2555                "no limit keeps every file"
2556            );
2557        }
2558
2559        #[test]
2560        fn skipped_file_recorded_in_workspace_diagnostics() {
2561            let dir = tempfile::tempdir().expect("create temp dir");
2562            let src = dir.path().join("src");
2563            std::fs::create_dir_all(&src).unwrap();
2564            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2565
2566            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2567            let _ = discover_files(&config);
2568
2569            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2570            let skipped: Vec<_> = diagnostics
2571                .iter()
2572                .filter(|d| {
2573                    matches!(
2574                        d.kind,
2575                        fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
2576                    )
2577                })
2578                .collect();
2579            assert_eq!(
2580                skipped.len(),
2581                1,
2582                "the skipped file is recorded in workspace diagnostics for JSON output"
2583            );
2584            assert!(skipped[0].path.ends_with("src/huge.ts"));
2585            assert!(
2586                matches!(
2587                    skipped[0].kind,
2588                    fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
2589                        if size_bytes == 5_000
2590                ),
2591                "the recorded diagnostic carries the on-disk byte size"
2592            );
2593        }
2594
2595        /// The skipped-source-dotdir entries the last walk on `root` recorded.
2596        fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
2597            fallow_config::workspace_diagnostics_for(root)
2598                .into_iter()
2599                .filter(|d| {
2600                    matches!(
2601                        d.kind,
2602                        fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
2603                    )
2604                })
2605                .collect()
2606        }
2607
2608        fn write_at(root: &Path, relative: &str, contents: &str) {
2609            let path = root.join(relative);
2610            std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
2611            std::fs::write(path, contents).unwrap();
2612        }
2613
2614        #[test]
2615        fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
2616            let dir = tempfile::tempdir().expect("create temp dir");
2617            write_at(
2618                dir.path(),
2619                ".claude/hooks/probe.mjs",
2620                "export const a = 1;\n",
2621            );
2622            write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
2623
2624            let config = make_config(dir.path().to_path_buf(), false);
2625            let files = discover_files(&config);
2626            let names = file_names(&files, dir.path());
2627
2628            let reported = dotdir_diagnostics(dir.path());
2629            assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
2630            assert!(reported[0].path.ends_with(".claude"));
2631            assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
2632            assert!(
2633                reported[0].message.contains("--root"),
2634                "message names the real remedy: {}",
2635                reported[0].message
2636            );
2637            assert!(
2638                names.contains(&"src/app.ts".to_string()),
2639                "traversal is unchanged for ordinary directories"
2640            );
2641            assert!(
2642                !names.contains(&".claude/hooks/probe.mjs".to_string()),
2643                "the diagnostic reports the skip, it does not change traversal"
2644            );
2645        }
2646
2647        #[test]
2648        fn allowlisted_dotdir_is_not_reported() {
2649            let dir = tempfile::tempdir().expect("create temp dir");
2650            write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
2651
2652            let config = make_config(dir.path().to_path_buf(), false);
2653            let files = discover_files(&config);
2654            let names = file_names(&files, dir.path());
2655
2656            assert!(dotdir_diagnostics(dir.path()).is_empty());
2657            assert!(
2658                names.contains(&".storybook/main.ts".to_string()),
2659                "an allowlisted dotdir is still traversed"
2660            );
2661        }
2662
2663        #[test]
2664        fn denylisted_dotdir_is_not_reported() {
2665            let dir = tempfile::tempdir().expect("create temp dir");
2666            write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
2667            write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
2668            write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
2669            write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
2670
2671            let config = make_config(dir.path().to_path_buf(), false);
2672            let _ = discover_files(&config);
2673
2674            assert!(
2675                dotdir_diagnostics(dir.path()).is_empty(),
2676                "build caches, VCS and package-manager state never advise"
2677            );
2678        }
2679
2680        #[test]
2681        fn scoped_dotdir_is_traversed_and_not_reported() {
2682            let dir = tempfile::tempdir().expect("create temp dir");
2683            write_at(
2684                dir.path(),
2685                ".claude/hooks/probe.mjs",
2686                "export const a = 1;\n",
2687            );
2688
2689            let config = make_config(dir.path().to_path_buf(), false);
2690            let scopes = [HiddenDirScope::new(
2691                dir.path().to_path_buf(),
2692                vec![".claude".to_owned()],
2693            )];
2694            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2695            let names = file_names(&files, dir.path());
2696
2697            assert!(
2698                dotdir_diagnostics(dir.path()).is_empty(),
2699                "a plugin- or script-contributed scope is admitted, so nothing was skipped"
2700            );
2701            assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
2702        }
2703
2704        #[test]
2705        fn ignore_patterns_silence_the_skipped_source_dotdir() {
2706            let dir = tempfile::tempdir().expect("create temp dir");
2707            write_at(
2708                dir.path(),
2709                ".claude/hooks/probe.mjs",
2710                "export const a = 1;\n",
2711            );
2712
2713            let config =
2714                make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
2715            let _ = discover_files(&config);
2716
2717            assert!(
2718                dotdir_diagnostics(dir.path()).is_empty(),
2719                "the documented silencing route works"
2720            );
2721        }
2722
2723        #[test]
2724        fn dotdir_without_source_files_is_not_reported() {
2725            let dir = tempfile::tempdir().expect("create temp dir");
2726            write_at(dir.path(), ".claude/settings.json", "{}\n");
2727            write_at(dir.path(), ".claude/README.md", "# notes\n");
2728
2729            let config = make_config(dir.path().to_path_buf(), false);
2730            let _ = discover_files(&config);
2731
2732            assert!(dotdir_diagnostics(dir.path()).is_empty());
2733        }
2734
2735        #[test]
2736        fn dotdir_source_at_scan_depth_limit_is_reported() {
2737            let dir = tempfile::tempdir().expect("create temp dir");
2738            write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
2739
2740            let config = make_config(dir.path().to_path_buf(), false);
2741            let _ = discover_files(&config);
2742
2743            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2744        }
2745
2746        #[test]
2747        fn dotdir_source_below_scan_depth_limit_is_not_reported() {
2748            let dir = tempfile::tempdir().expect("create temp dir");
2749            write_at(
2750                dir.path(),
2751                ".claude/a/b/c/deeper.ts",
2752                "export const a = 1;\n",
2753            );
2754
2755            let config = make_config(dir.path().to_path_buf(), false);
2756            let _ = discover_files(&config);
2757
2758            assert!(
2759                dotdir_diagnostics(dir.path()).is_empty(),
2760                "the depth cap is real, so widening it stays a deliberate act"
2761            );
2762        }
2763
2764        /// Mark `root` as a git worktree so the `ignore` crate applies the
2765        /// gitignore files below it. `require_git` is on by default, and it
2766        /// tests for the presence of `.git`, not for a valid object store.
2767        fn mark_as_git_repo(root: &Path) {
2768            std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
2769        }
2770
2771        #[test]
2772        fn gitignored_dotdir_contents_are_not_reported() {
2773            // The directory FORM (`.tooling/`) prunes the dotdir upstream of the
2774            // predicate, so these are the forms that reach it with every file
2775            // inside already ignored.
2776            for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
2777                let dir = tempfile::tempdir().expect("create temp dir");
2778                mark_as_git_repo(dir.path());
2779                write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
2780                write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2781
2782                let config = make_config(dir.path().to_path_buf(), false);
2783                let _ = discover_files(&config);
2784
2785                assert!(
2786                    dotdir_diagnostics(dir.path()).is_empty(),
2787                    "gitignore pattern '{pattern}' excludes the contents, so neither \
2788                     advertised remedy would find anything there"
2789                );
2790            }
2791        }
2792
2793        #[test]
2794        fn self_ignoring_dotdir_is_not_reported() {
2795            let dir = tempfile::tempdir().expect("create temp dir");
2796            mark_as_git_repo(dir.path());
2797            write_at(dir.path(), ".toolcache/.gitignore", "*\n");
2798            write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
2799
2800            let config = make_config(dir.path().to_path_buf(), false);
2801            let _ = discover_files(&config);
2802
2803            assert!(
2804                dotdir_diagnostics(dir.path()).is_empty(),
2805                "a cache directory that ignores itself has excluded its own contents"
2806            );
2807        }
2808
2809        #[test]
2810        fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
2811            let dir = tempfile::tempdir().expect("create temp dir");
2812            mark_as_git_repo(dir.path());
2813            write_at(dir.path(), ".gitignore", "dist/\n");
2814            write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2815
2816            let config = make_config(dir.path().to_path_buf(), false);
2817            let _ = discover_files(&config);
2818
2819            assert_eq!(
2820                dotdir_diagnostics(dir.path()).len(),
2821                1,
2822                "the gitignore check must not swallow the case the diagnostic exists for"
2823            );
2824        }
2825
2826        #[test]
2827        fn production_run_does_not_report_a_test_only_dotdir() {
2828            let dir = tempfile::tempdir().expect("create temp dir");
2829            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2830            write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
2831
2832            let config = make_config(dir.path().to_path_buf(), true);
2833            let _ = discover_files(&config);
2834
2835            assert!(
2836                dotdir_diagnostics(dir.path()).is_empty(),
2837                "a --production run would analyze none of those files, so the \
2838                 --root remedy would return nothing"
2839            );
2840        }
2841
2842        #[test]
2843        fn production_run_still_reports_a_dotdir_with_production_source() {
2844            let dir = tempfile::tempdir().expect("create temp dir");
2845            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2846            write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
2847
2848            let config = make_config(dir.path().to_path_buf(), true);
2849            let _ = discover_files(&config);
2850
2851            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2852        }
2853
2854        #[test]
2855        fn dotdir_with_only_generated_markup_is_not_reported() {
2856            let dir = tempfile::tempdir().expect("create temp dir");
2857            write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
2858            write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
2859            write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
2860
2861            let config = make_config(dir.path().to_path_buf(), false);
2862            let _ = discover_files(&config);
2863
2864            assert!(
2865                dotdir_diagnostics(dir.path()).is_empty(),
2866                "the message claims imports and exports are lost, and these have none"
2867            );
2868        }
2869
2870        #[test]
2871        fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
2872            let dir = tempfile::tempdir().expect("create temp dir");
2873            write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
2874            write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
2875            write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
2876            write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
2877            write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
2878
2879            let config = make_config(dir.path().to_path_buf(), false);
2880            let _ = discover_files(&config);
2881
2882            assert!(
2883                dotdir_diagnostics(dir.path()).is_empty(),
2884                "generated output and foreign VCS metadata are not first-party source"
2885            );
2886        }
2887
2888        #[test]
2889        fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
2890            let dir = tempfile::tempdir().expect("create temp dir");
2891            // Sorted before the real candidate, and more of them than the
2892            // ceiling, so a cap applied before the name checks would hide it.
2893            for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
2894                write_at(
2895                    dir.path(),
2896                    &format!("packages/pkg{index:03}/.turbo/blob.js"),
2897                    "export const a = 1;\n",
2898                );
2899            }
2900            write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
2901
2902            let config = make_config(dir.path().to_path_buf(), false);
2903            let _ = discover_files(&config);
2904
2905            let reported = dotdir_diagnostics(dir.path());
2906            assert_eq!(reported.len(), 1, "{reported:?}");
2907            assert!(reported[0].path.ends_with(".tooling"));
2908        }
2909
2910        #[test]
2911        fn one_pathological_dotdir_cannot_starve_the_rest() {
2912            let dir = tempfile::tempdir().expect("create temp dir");
2913            // Wide and shallow, no source: exhausts this candidate's own budget.
2914            for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
2915                write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
2916            }
2917            write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
2918
2919            let config = make_config(dir.path().to_path_buf(), false);
2920            let _ = discover_files(&config);
2921
2922            let reported = dotdir_diagnostics(dir.path());
2923            assert_eq!(reported.len(), 1, "{reported:?}");
2924            assert!(reported[0].path.ends_with(".zzz-real"));
2925        }
2926
2927        #[test]
2928        fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
2929            let dir = tempfile::tempdir().expect("create temp dir");
2930            write_at(
2931                dir.path(),
2932                ".claude/hooks/probe.mjs",
2933                "export const a = 1;\n",
2934            );
2935
2936            let config = make_config(dir.path().to_path_buf(), false);
2937            let _ = discover_files(&config);
2938            let _ = discover_files(&config);
2939
2940            assert_eq!(
2941                dotdir_diagnostics(dir.path()).len(),
2942                1,
2943                "each walk replaces its own root's source-discovery set"
2944            );
2945        }
2946
2947        #[test]
2948        fn skips_large_one_line_js_as_minified_generated_output() {
2949            let dir = tempfile::tempdir().expect("create temp dir");
2950            let src = dir.path().join("src");
2951            std::fs::create_dir_all(&src).unwrap();
2952            let asset = src.join("index-abc123.js");
2953            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2954
2955            let config = make_config(dir.path().to_path_buf(), false);
2956            let files = discover_files(&config);
2957            let names = file_names(&files, dir.path());
2958
2959            assert!(
2960                !names.contains(&"src/index-abc123.js".to_string()),
2961                "large one-line JS assets should be skipped before parsing"
2962            );
2963
2964            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2965            assert!(
2966                diagnostics.iter().any(|diag| {
2967                    diag.path.ends_with("src/index-abc123.js")
2968                        && matches!(
2969                            diag.kind,
2970                            fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
2971                        )
2972                }),
2973                "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
2974            );
2975        }
2976
2977        #[test]
2978        fn unlimited_size_keeps_large_one_line_js() {
2979            let dir = tempfile::tempdir().expect("create temp dir");
2980            let src = dir.path().join("src");
2981            std::fs::create_dir_all(&src).unwrap();
2982            let asset = src.join("index-abc123.js");
2983            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2984
2985            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2986            let files = discover_files(&config);
2987            let names = file_names(&files, dir.path());
2988
2989            assert!(
2990                names.contains(&"src/index-abc123.js".to_string()),
2991                "--max-file-size 0 should opt out of generated JS skipping"
2992            );
2993        }
2994
2995        #[test]
2996        fn keeps_large_multiline_js() {
2997            let dir = tempfile::tempdir().expect("create temp dir");
2998            let src = dir.path().join("src");
2999            std::fs::create_dir_all(&src).unwrap();
3000            let asset = src.join("handwritten.js");
3001            let mut content = String::new();
3002            while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3003                content.push_str("export const value = 1;\n");
3004            }
3005            std::fs::write(&asset, content).unwrap();
3006
3007            let config = make_config(dir.path().to_path_buf(), false);
3008            let files = discover_files(&config);
3009            let names = file_names(&files, dir.path());
3010
3011            assert!(
3012                names.contains(&"src/handwritten.js".to_string()),
3013                "large multiline JS should not be treated as a generated minified asset"
3014            );
3015        }
3016    }
3017}