1mod entry_points;
2mod infrastructure;
3mod parse_scripts;
4mod walk;
5
6use std::path::{Component, Path};
7
8use fallow_config::{PackageJson, ResolvedConfig};
9use rustc_hash::FxHashSet;
10
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
13
14pub use entry_points::{
16 CategorizedEntryPoints, compile_glob_set, discover_dynamically_loaded_entry_points,
17 discover_entry_points, discover_plugin_entry_point_sets, discover_plugin_entry_points,
18 discover_workspace_entry_points,
19};
20pub(crate) use entry_points::{
21 EntryPointDiscovery, discover_entry_points_with_warnings_from_pkg,
22 discover_workspace_entry_points_with_warnings_from_pkg, warn_skipped_entry_summary,
23};
24pub use infrastructure::discover_infrastructure_entry_points;
25pub use walk::{
26 HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS, discover_files,
27 discover_files_with_additional_hidden_dirs,
28};
29
30#[must_use]
39pub fn collect_plugin_hidden_dir_scopes(
40 config: &ResolvedConfig,
41 root_pkg: Option<&PackageJson>,
42 workspaces: &[fallow_config::WorkspaceInfo],
43) -> Vec<HiddenDirScope> {
44 let registry = crate::plugins::PluginRegistry::new(config.external_plugins.clone());
45 let mut scopes = Vec::new();
46
47 if let Some(pkg) = root_pkg {
48 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
49 }
50
51 for ws in workspaces {
52 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
53 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
54 }
55 }
56
57 scopes
58}
59
60#[must_use]
70pub fn collect_hidden_dir_scopes(
71 config: &ResolvedConfig,
72 root_pkg: Option<&PackageJson>,
73 workspaces: &[fallow_config::WorkspaceInfo],
74) -> Vec<HiddenDirScope> {
75 let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
76 let registry = crate::plugins::PluginRegistry::new(config.external_plugins.clone());
77 let mut scopes = Vec::new();
78
79 if let Some(pkg) = root_pkg {
80 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
81 if let Some(scope) = build_script_scope(pkg, &config.root) {
82 scopes.push(scope);
83 }
84 }
85
86 for ws in workspaces {
87 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
88 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
89 if let Some(scope) = build_script_scope(&pkg, &ws.root) {
90 scopes.push(scope);
91 }
92 }
93 }
94
95 scopes
96}
97
98fn push_plugin_hidden_dir_scope(
99 scopes: &mut Vec<HiddenDirScope>,
100 registry: &crate::plugins::PluginRegistry,
101 pkg: &PackageJson,
102 root: &Path,
103) {
104 let dirs = registry.discovery_hidden_dirs(pkg, root);
105 if !dirs.is_empty() {
106 scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
107 }
108}
109
110#[must_use]
120pub fn discover_files_with_plugin_scopes(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
121 let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
122 let workspaces = fallow_config::discover_workspaces(&config.root);
123 let scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
124 discover_files_with_additional_hidden_dirs(config, &scopes)
125}
126
127const ALLOWED_HIDDEN_DIRS: &[&str] = &[
137 ".storybook",
138 ".vitepress",
139 ".well-known",
140 ".changeset",
141 ".github",
142];
143
144const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
150 ".git",
151 ".next",
152 ".nuxt",
153 ".output",
154 ".svelte-kit",
155 ".turbo",
156 ".nx",
157 ".cache",
158 ".parcel-cache",
159 ".vercel",
160 ".netlify",
161 ".yarn",
162 ".pnpm-store",
163 ".docusaurus",
164 ".vscode",
165 ".idea",
166 ".fallow",
167 ".husky",
168];
169
170#[must_use]
190pub fn collect_script_hidden_dir_scopes(
191 config: &ResolvedConfig,
192 root_pkg: Option<&PackageJson>,
193 workspaces: &[fallow_config::WorkspaceInfo],
194) -> Vec<HiddenDirScope> {
195 let _span = tracing::info_span!("collect_script_hidden_dir_scopes").entered();
196 let mut scopes = Vec::new();
197
198 if let Some(pkg) = root_pkg
199 && let Some(scope) = build_script_scope(pkg, &config.root)
200 {
201 scopes.push(scope);
202 }
203 for ws in workspaces {
204 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json"))
205 && let Some(scope) = build_script_scope(&pkg, &ws.root)
206 {
207 scopes.push(scope);
208 }
209 }
210 scopes
211}
212
213fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
214 let scripts = pkg.scripts.as_ref()?;
215 let mut seen = FxHashSet::default();
216 let mut dirs: Vec<String> = Vec::new();
217
218 for (script_name, script_value) in scripts {
219 for cmd in crate::scripts::parse_script(script_value) {
220 for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
221 for hidden in extract_hidden_segments(path) {
222 if SCRIPT_SCOPE_DENYLIST.contains(&hidden.as_str()) {
223 continue;
224 }
225 if seen.insert(hidden.clone()) {
226 tracing::debug!(
227 dir = %hidden,
228 script = %script_name,
229 package_root = %root.display(),
230 "inferred hidden_dir_scope from package.json#scripts"
231 );
232 dirs.push(hidden);
233 }
234 }
235 }
236 }
237 }
238
239 if dirs.is_empty() {
240 None
241 } else {
242 Some(HiddenDirScope::new(root.to_path_buf(), dirs))
243 }
244}
245
246fn extract_hidden_segments(path: &str) -> Vec<String> {
258 let p = Path::new(path);
259 if p.is_absolute() {
260 return Vec::new();
261 }
262 let components: Vec<Component> = p.components().collect();
263 if components.iter().any(|c| matches!(c, Component::ParentDir)) {
264 return Vec::new();
265 }
266 let mut out = Vec::new();
267 let upto = components.len().saturating_sub(1);
271 for component in &components[..upto] {
272 if let Component::Normal(name) = component {
273 let s = name.to_string_lossy();
274 if s.starts_with('.') && s.len() > 1 {
275 out.push(s.into_owned());
276 }
277 }
278 }
279 out
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
289 fn allowed_hidden_dirs_count() {
290 assert_eq!(
292 ALLOWED_HIDDEN_DIRS.len(),
293 5,
294 "update tests when adding new allowed hidden dirs"
295 );
296 }
297
298 #[test]
299 fn allowed_hidden_dirs_all_start_with_dot() {
300 for dir in ALLOWED_HIDDEN_DIRS {
301 assert!(
302 dir.starts_with('.'),
303 "allowed hidden dir '{dir}' must start with '.'"
304 );
305 }
306 }
307
308 #[test]
309 fn allowed_hidden_dirs_no_duplicates() {
310 let mut seen = rustc_hash::FxHashSet::default();
311 for dir in ALLOWED_HIDDEN_DIRS {
312 assert!(seen.insert(*dir), "duplicate allowed hidden dir: {dir}");
313 }
314 }
315
316 #[test]
317 fn allowed_hidden_dirs_no_trailing_slash() {
318 for dir in ALLOWED_HIDDEN_DIRS {
319 assert!(
320 !dir.ends_with('/'),
321 "allowed hidden dir '{dir}' should not have trailing slash"
322 );
323 }
324 }
325
326 #[test]
329 fn file_id_re_exported() {
330 let id = FileId(42);
332 assert_eq!(id.0, 42);
333 }
334
335 #[test]
336 fn source_extensions_re_exported() {
337 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
338 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
339 }
340
341 #[test]
342 fn compile_glob_set_re_exported() {
343 let result = compile_glob_set(&["**/*.ts".to_string()]);
344 assert!(result.is_some());
345 }
346
347 #[test]
350 fn script_scope_denylist_all_start_with_dot() {
351 for dir in SCRIPT_SCOPE_DENYLIST {
352 assert!(
353 dir.starts_with('.'),
354 "denylisted dir '{dir}' must start with '.'"
355 );
356 }
357 }
358
359 #[test]
360 fn script_scope_denylist_no_duplicates() {
361 let mut seen = rustc_hash::FxHashSet::default();
362 for dir in SCRIPT_SCOPE_DENYLIST {
363 assert!(seen.insert(*dir), "duplicate denylisted dir: {dir}");
364 }
365 }
366
367 #[test]
368 fn script_scope_denylist_does_not_overlap_allowlist() {
369 for dir in SCRIPT_SCOPE_DENYLIST {
370 assert!(
371 !ALLOWED_HIDDEN_DIRS.contains(dir),
372 "denylisted dir '{dir}' must not also appear in ALLOWED_HIDDEN_DIRS"
373 );
374 }
375 }
376
377 #[test]
380 fn extract_hidden_segments_single_segment() {
381 assert_eq!(
382 extract_hidden_segments(".config/eslint.config.js"),
383 vec![".config".to_string()]
384 );
385 }
386
387 #[test]
388 fn extract_hidden_segments_with_leading_dot_slash() {
389 assert_eq!(
390 extract_hidden_segments("./.config/eslint.config.js"),
391 vec![".config".to_string()]
392 );
393 }
394
395 #[test]
396 fn extract_hidden_segments_nested_hidden() {
397 assert_eq!(
398 extract_hidden_segments(".foo/.bar/x.js"),
399 vec![".foo".to_string(), ".bar".to_string()]
400 );
401 }
402
403 #[test]
404 fn extract_hidden_segments_hidden_inside_normal_parent() {
405 assert_eq!(
406 extract_hidden_segments("sub/.config/eslint.config.js"),
407 vec![".config".to_string()]
408 );
409 }
410
411 #[test]
412 fn extract_hidden_segments_no_hidden_returns_empty() {
413 assert!(extract_hidden_segments("src/index.ts").is_empty());
414 }
415
416 #[test]
417 fn extract_hidden_segments_skips_trailing_filename() {
418 assert!(extract_hidden_segments(".env").is_empty());
421 assert!(extract_hidden_segments("src/.eslintrc.js").is_empty());
422 }
423
424 #[test]
425 fn extract_hidden_segments_skips_paths_with_parent_dir() {
426 assert!(extract_hidden_segments("../.config/eslint.config.js").is_empty());
428 assert!(extract_hidden_segments(".config/../other/x.js").is_empty());
429 assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
430 }
431
432 #[test]
433 fn extract_hidden_segments_skips_absolute_paths() {
434 #[cfg(unix)]
436 {
437 assert!(extract_hidden_segments("/etc/.config/eslint.config.js").is_empty());
438 }
439 #[cfg(windows)]
440 {
441 assert!(extract_hidden_segments(r"C:\etc\.config\eslint.config.js").is_empty());
442 }
443 }
444
445 #[test]
446 fn extract_hidden_segments_ignores_bare_dot() {
447 assert!(extract_hidden_segments(".").is_empty());
449 assert!(extract_hidden_segments("./src/index.ts").is_empty());
450 }
451
452 #[expect(
455 clippy::disallowed_types,
456 reason = "PackageJson::scripts uses std HashMap for serde compatibility"
457 )]
458 fn make_pkg_with_scripts(entries: &[(&str, &str)]) -> PackageJson {
459 let mut pkg = PackageJson::default();
460 let mut scripts: std::collections::HashMap<String, String> =
461 std::collections::HashMap::new();
462 for (name, value) in entries {
463 scripts.insert((*name).to_string(), (*value).to_string());
464 }
465 pkg.scripts = Some(scripts);
466 pkg
467 }
468
469 fn make_config(root: std::path::PathBuf) -> ResolvedConfig {
470 fallow_config::FallowConfig::default().resolve(
471 root,
472 fallow_config::OutputFormat::Human,
473 1,
474 true,
475 true,
476 None,
477 )
478 }
479
480 #[test]
481 fn script_scope_extracts_dash_c_config_arg() {
482 let dir = tempfile::tempdir().expect("tempdir");
483 let config = make_config(dir.path().to_path_buf());
484 let pkg = make_pkg_with_scripts(&[("lint", "eslint -c .config/eslint.config.js")]);
485 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
486
487 assert_eq!(scopes.len(), 1, "one scope for the root package");
488 let target_dir = dir.path().join(".config");
491 std::fs::create_dir_all(&target_dir).unwrap();
492 std::fs::write(target_dir.join("eslint.config.js"), "export default {};").unwrap();
493 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
494 let names: Vec<String> = files
495 .iter()
496 .map(|f| {
497 f.path
498 .strip_prefix(dir.path())
499 .unwrap_or(&f.path)
500 .to_string_lossy()
501 .replace('\\', "/")
502 })
503 .collect();
504 assert!(
505 names.contains(&".config/eslint.config.js".to_string()),
506 "expected .config/eslint.config.js to be discovered; got {names:?}"
507 );
508 }
509
510 #[test]
511 fn script_scope_extracts_long_config_arg_with_equals() {
512 let dir = tempfile::tempdir().expect("tempdir");
513 let config = make_config(dir.path().to_path_buf());
514 let pkg = make_pkg_with_scripts(&[("test", "vitest --config=.config/vitest.config.ts")]);
515 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
516 assert_eq!(scopes.len(), 1);
517 }
518
519 #[test]
520 fn script_scope_extracts_positional_file_arg() {
521 let dir = tempfile::tempdir().expect("tempdir");
522 let config = make_config(dir.path().to_path_buf());
523 let pkg = make_pkg_with_scripts(&[("build", "tsx ./.scripts/build.ts")]);
524 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
525 assert_eq!(scopes.len(), 1);
526 }
527
528 #[test]
529 fn script_scope_denies_known_bad_dirs() {
530 let dir = tempfile::tempdir().expect("tempdir");
531 let config = make_config(dir.path().to_path_buf());
532 let pkg = make_pkg_with_scripts(&[
534 ("cache", "tsx .nx/scripts/cache.ts"),
535 ("vscode", "node .vscode/build.js"),
536 ("yarn-state", "node .yarn/releases/yarn-4.0.0.cjs"),
537 ]);
538 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
539 assert!(
540 scopes.is_empty(),
541 "denylisted dirs must not produce scopes; got {scopes:?}"
542 );
543 }
544
545 #[test]
546 fn script_scope_mixes_denied_and_allowed_dirs() {
547 let dir = tempfile::tempdir().expect("tempdir");
548 let config = make_config(dir.path().to_path_buf());
549 let pkg = make_pkg_with_scripts(&[(
551 "lint",
552 "nx run-many --target=lint && eslint -c .config/eslint.config.js",
553 )]);
554 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
555 assert_eq!(scopes.len(), 1, "one scope for the .config reference");
556
557 std::fs::create_dir_all(dir.path().join(".config")).unwrap();
559 std::fs::write(
560 dir.path().join(".config/eslint.config.js"),
561 "export default {};",
562 )
563 .unwrap();
564 std::fs::create_dir_all(dir.path().join(".nx/cache")).unwrap();
565 std::fs::write(dir.path().join(".nx/cache/build.js"), "// cache").unwrap();
566
567 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
568 let names: Vec<String> = files
569 .iter()
570 .map(|f| {
571 f.path
572 .strip_prefix(dir.path())
573 .unwrap_or(&f.path)
574 .to_string_lossy()
575 .replace('\\', "/")
576 })
577 .collect();
578 assert!(names.contains(&".config/eslint.config.js".to_string()));
579 assert!(
580 !names.contains(&".nx/cache/build.js".to_string()),
581 "denylisted .nx must stay hidden"
582 );
583 }
584
585 #[test]
586 fn script_scope_skips_parent_dir_paths() {
587 let dir = tempfile::tempdir().expect("tempdir");
588 let config = make_config(dir.path().to_path_buf());
589 let pkg = make_pkg_with_scripts(&[("lint", "eslint -c ../../.config/eslint.config.js")]);
590 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
591 assert!(
592 scopes.is_empty(),
593 "paths with .. must not generate scopes; got {scopes:?}"
594 );
595 }
596
597 #[test]
598 fn script_scope_no_scripts_returns_empty() {
599 let dir = tempfile::tempdir().expect("tempdir");
600 let config = make_config(dir.path().to_path_buf());
601 let pkg = PackageJson::default();
602 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
603 assert!(scopes.is_empty());
604 }
605
606 #[test]
607 fn script_scope_no_hidden_paths_returns_empty() {
608 let dir = tempfile::tempdir().expect("tempdir");
609 let config = make_config(dir.path().to_path_buf());
610 let pkg = make_pkg_with_scripts(&[
611 ("build", "tsc -p tsconfig.json"),
612 ("lint", "eslint -c eslint.config.js"),
613 ]);
614 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
615 assert!(scopes.is_empty());
616 }
617
618 #[test]
619 fn script_scope_dedupes_within_package() {
620 let dir = tempfile::tempdir().expect("tempdir");
621 let config = make_config(dir.path().to_path_buf());
622 let pkg = make_pkg_with_scripts(&[
624 ("lint", "eslint -c .config/eslint.config.js"),
625 ("test", "vitest --config .config/vitest.config.ts"),
626 ]);
627 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
628 assert_eq!(scopes.len(), 1);
629 }
630
631 #[test]
632 fn script_scope_workspace_packages_have_own_scope_root() {
633 let dir = tempfile::tempdir().expect("tempdir");
634 let config = make_config(dir.path().to_path_buf());
635 let ws_root = dir.path().join("packages/app");
638 std::fs::create_dir_all(&ws_root).unwrap();
639 let ws_pkg_path = ws_root.join("package.json");
640 std::fs::write(
641 &ws_pkg_path,
642 r#"{"name":"app","scripts":{"lint":"eslint -c .config/eslint.config.js"}}"#,
643 )
644 .unwrap();
645 let ws = fallow_config::WorkspaceInfo {
646 root: ws_root.clone(),
647 name: "app".to_string(),
648 is_internal_dependency: false,
649 };
650 let scopes = collect_script_hidden_dir_scopes(&config, None, &[ws]);
651 assert_eq!(scopes.len(), 1);
652
653 std::fs::create_dir_all(ws_root.join(".config")).unwrap();
655 std::fs::write(
656 ws_root.join(".config/eslint.config.js"),
657 "export default {};",
658 )
659 .unwrap();
660 let other_root = dir.path().join("packages/other");
662 std::fs::create_dir_all(other_root.join(".config")).unwrap();
663 std::fs::write(
664 other_root.join(".config/eslint.config.js"),
665 "export default {};",
666 )
667 .unwrap();
668
669 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
670 let names: Vec<String> = files
671 .iter()
672 .map(|f| {
673 f.path
674 .strip_prefix(dir.path())
675 .unwrap_or(&f.path)
676 .to_string_lossy()
677 .replace('\\', "/")
678 })
679 .collect();
680 assert!(names.contains(&"packages/app/.config/eslint.config.js".to_string()));
681 assert!(
682 !names.contains(&"packages/other/.config/eslint.config.js".to_string()),
683 "unscoped workspace must not get .config traversed"
684 );
685 }
686}