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