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};
12
13pub(crate) use entry_points::resolve_entry_path;
14pub use entry_points::{
15 CategorizedEntryPoints, compile_glob_set, discover_dynamically_loaded_entry_points,
16 discover_entry_points, discover_plugin_entry_point_sets, discover_plugin_entry_points,
17 discover_workspace_entry_points,
18};
19pub(crate) use entry_points::{
20 EntryPointDiscovery, discover_entry_points_with_warnings_from_pkg,
21 discover_workspace_entry_points_with_warnings_from_pkg, warn_skipped_entry_summary,
22};
23pub use infrastructure::discover_infrastructure_entry_points;
24pub use walk::{
25 HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS, discover_files,
26 discover_files_and_config_candidates, discover_files_with_additional_hidden_dirs,
27 is_allowed_hidden_dir,
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);
268 for component in &components[..upto] {
269 if let Component::Normal(name) = component {
270 let s = name.to_string_lossy();
271 if s.starts_with('.') && s.len() > 1 {
272 out.push(s.into_owned());
273 }
274 }
275 }
276 out
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn allowed_hidden_dirs_count() {
285 assert_eq!(
286 ALLOWED_HIDDEN_DIRS.len(),
287 5,
288 "update tests when adding new allowed hidden dirs"
289 );
290 }
291
292 #[test]
293 fn allowed_hidden_dirs_all_start_with_dot() {
294 for dir in ALLOWED_HIDDEN_DIRS {
295 assert!(
296 dir.starts_with('.'),
297 "allowed hidden dir '{dir}' must start with '.'"
298 );
299 }
300 }
301
302 #[test]
303 fn allowed_hidden_dirs_no_duplicates() {
304 let mut seen = rustc_hash::FxHashSet::default();
305 for dir in ALLOWED_HIDDEN_DIRS {
306 assert!(seen.insert(*dir), "duplicate allowed hidden dir: {dir}");
307 }
308 }
309
310 #[test]
311 fn allowed_hidden_dirs_no_trailing_slash() {
312 for dir in ALLOWED_HIDDEN_DIRS {
313 assert!(
314 !dir.ends_with('/'),
315 "allowed hidden dir '{dir}' should not have trailing slash"
316 );
317 }
318 }
319
320 #[test]
321 fn file_id_re_exported() {
322 let id = FileId(42);
323 assert_eq!(id.0, 42);
324 }
325
326 #[test]
327 fn source_extensions_re_exported() {
328 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
329 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
330 }
331
332 #[test]
333 fn compile_glob_set_re_exported() {
334 let result = compile_glob_set(&["**/*.ts".to_string()]);
335 assert!(result.is_some());
336 }
337
338 #[test]
339 fn script_scope_denylist_all_start_with_dot() {
340 for dir in SCRIPT_SCOPE_DENYLIST {
341 assert!(
342 dir.starts_with('.'),
343 "denylisted dir '{dir}' must start with '.'"
344 );
345 }
346 }
347
348 #[test]
349 fn script_scope_denylist_no_duplicates() {
350 let mut seen = rustc_hash::FxHashSet::default();
351 for dir in SCRIPT_SCOPE_DENYLIST {
352 assert!(seen.insert(*dir), "duplicate denylisted dir: {dir}");
353 }
354 }
355
356 #[test]
357 fn script_scope_denylist_does_not_overlap_allowlist() {
358 for dir in SCRIPT_SCOPE_DENYLIST {
359 assert!(
360 !ALLOWED_HIDDEN_DIRS.contains(dir),
361 "denylisted dir '{dir}' must not also appear in ALLOWED_HIDDEN_DIRS"
362 );
363 }
364 }
365
366 #[test]
367 fn extract_hidden_segments_single_segment() {
368 assert_eq!(
369 extract_hidden_segments(".config/eslint.config.js"),
370 vec![".config".to_string()]
371 );
372 }
373
374 #[test]
375 fn extract_hidden_segments_with_leading_dot_slash() {
376 assert_eq!(
377 extract_hidden_segments("./.config/eslint.config.js"),
378 vec![".config".to_string()]
379 );
380 }
381
382 #[test]
383 fn extract_hidden_segments_nested_hidden() {
384 assert_eq!(
385 extract_hidden_segments(".foo/.bar/x.js"),
386 vec![".foo".to_string(), ".bar".to_string()]
387 );
388 }
389
390 #[test]
391 fn extract_hidden_segments_hidden_inside_normal_parent() {
392 assert_eq!(
393 extract_hidden_segments("sub/.config/eslint.config.js"),
394 vec![".config".to_string()]
395 );
396 }
397
398 #[test]
399 fn extract_hidden_segments_no_hidden_returns_empty() {
400 assert!(extract_hidden_segments("src/index.ts").is_empty());
401 }
402
403 #[test]
404 fn extract_hidden_segments_skips_trailing_filename() {
405 assert!(extract_hidden_segments(".env").is_empty());
406 assert!(extract_hidden_segments("src/.eslintrc.js").is_empty());
407 }
408
409 #[test]
410 fn extract_hidden_segments_skips_paths_with_parent_dir() {
411 assert!(extract_hidden_segments("../.config/eslint.config.js").is_empty());
412 assert!(extract_hidden_segments(".config/../other/x.js").is_empty());
413 assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
414 }
415
416 #[test]
417 fn extract_hidden_segments_skips_absolute_paths() {
418 #[cfg(unix)]
419 {
420 assert!(extract_hidden_segments("/etc/.config/eslint.config.js").is_empty());
421 }
422 #[cfg(windows)]
423 {
424 assert!(extract_hidden_segments(r"C:\etc\.config\eslint.config.js").is_empty());
425 }
426 }
427
428 #[test]
429 fn extract_hidden_segments_ignores_bare_dot() {
430 assert!(extract_hidden_segments(".").is_empty());
431 assert!(extract_hidden_segments("./src/index.ts").is_empty());
432 }
433
434 #[expect(
435 clippy::disallowed_types,
436 reason = "PackageJson::scripts uses std HashMap for serde compatibility"
437 )]
438 fn make_pkg_with_scripts(entries: &[(&str, &str)]) -> PackageJson {
439 let mut pkg = PackageJson::default();
440 let mut scripts: std::collections::HashMap<String, String> =
441 std::collections::HashMap::new();
442 for (name, value) in entries {
443 scripts.insert((*name).to_string(), (*value).to_string());
444 }
445 pkg.scripts = Some(scripts);
446 pkg
447 }
448
449 fn make_config(root: std::path::PathBuf) -> ResolvedConfig {
450 fallow_config::FallowConfig::default().resolve(
451 root,
452 fallow_config::OutputFormat::Human,
453 1,
454 true,
455 true,
456 None,
457 )
458 }
459
460 #[test]
461 fn script_scope_extracts_dash_c_config_arg() {
462 let dir = tempfile::tempdir().expect("tempdir");
463 let config = make_config(dir.path().to_path_buf());
464 let pkg = make_pkg_with_scripts(&[("lint", "eslint -c .config/eslint.config.js")]);
465 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
466
467 assert_eq!(scopes.len(), 1, "one scope for the root package");
468 let target_dir = dir.path().join(".config");
469 std::fs::create_dir_all(&target_dir).unwrap();
470 std::fs::write(target_dir.join("eslint.config.js"), "export default {};").unwrap();
471 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
472 let names: Vec<String> = files
473 .iter()
474 .map(|f| {
475 f.path
476 .strip_prefix(dir.path())
477 .unwrap_or(&f.path)
478 .to_string_lossy()
479 .replace('\\', "/")
480 })
481 .collect();
482 assert!(
483 names.contains(&".config/eslint.config.js".to_string()),
484 "expected .config/eslint.config.js to be discovered; got {names:?}"
485 );
486 }
487
488 #[test]
489 fn script_scope_extracts_long_config_arg_with_equals() {
490 let dir = tempfile::tempdir().expect("tempdir");
491 let config = make_config(dir.path().to_path_buf());
492 let pkg = make_pkg_with_scripts(&[("test", "vitest --config=.config/vitest.config.ts")]);
493 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
494 assert_eq!(scopes.len(), 1);
495 }
496
497 #[test]
498 fn script_scope_extracts_positional_file_arg() {
499 let dir = tempfile::tempdir().expect("tempdir");
500 let config = make_config(dir.path().to_path_buf());
501 let pkg = make_pkg_with_scripts(&[("build", "tsx ./.scripts/build.ts")]);
502 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
503 assert_eq!(scopes.len(), 1);
504 }
505
506 #[test]
507 fn script_scope_denies_known_bad_dirs() {
508 let dir = tempfile::tempdir().expect("tempdir");
509 let config = make_config(dir.path().to_path_buf());
510 let pkg = make_pkg_with_scripts(&[
511 ("cache", "tsx .nx/scripts/cache.ts"),
512 ("vscode", "node .vscode/build.js"),
513 ("yarn-state", "node .yarn/releases/yarn-4.0.0.cjs"),
514 ]);
515 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
516 assert!(
517 scopes.is_empty(),
518 "denylisted dirs must not produce scopes; got {scopes:?}"
519 );
520 }
521
522 #[test]
523 fn script_scope_mixes_denied_and_allowed_dirs() {
524 let dir = tempfile::tempdir().expect("tempdir");
525 let config = make_config(dir.path().to_path_buf());
526 let pkg = make_pkg_with_scripts(&[(
527 "lint",
528 "nx run-many --target=lint && eslint -c .config/eslint.config.js",
529 )]);
530 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
531 assert_eq!(scopes.len(), 1, "one scope for the .config reference");
532
533 std::fs::create_dir_all(dir.path().join(".config")).unwrap();
534 std::fs::write(
535 dir.path().join(".config/eslint.config.js"),
536 "export default {};",
537 )
538 .unwrap();
539 std::fs::create_dir_all(dir.path().join(".nx/cache")).unwrap();
540 std::fs::write(dir.path().join(".nx/cache/build.js"), "// cache").unwrap();
541
542 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
543 let names: Vec<String> = files
544 .iter()
545 .map(|f| {
546 f.path
547 .strip_prefix(dir.path())
548 .unwrap_or(&f.path)
549 .to_string_lossy()
550 .replace('\\', "/")
551 })
552 .collect();
553 assert!(names.contains(&".config/eslint.config.js".to_string()));
554 assert!(
555 !names.contains(&".nx/cache/build.js".to_string()),
556 "denylisted .nx must stay hidden"
557 );
558 }
559
560 #[test]
561 fn script_scope_skips_parent_dir_paths() {
562 let dir = tempfile::tempdir().expect("tempdir");
563 let config = make_config(dir.path().to_path_buf());
564 let pkg = make_pkg_with_scripts(&[("lint", "eslint -c ../../.config/eslint.config.js")]);
565 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
566 assert!(
567 scopes.is_empty(),
568 "paths with .. must not generate scopes; got {scopes:?}"
569 );
570 }
571
572 #[test]
573 fn script_scope_no_scripts_returns_empty() {
574 let dir = tempfile::tempdir().expect("tempdir");
575 let config = make_config(dir.path().to_path_buf());
576 let pkg = PackageJson::default();
577 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
578 assert!(scopes.is_empty());
579 }
580
581 #[test]
582 fn script_scope_no_hidden_paths_returns_empty() {
583 let dir = tempfile::tempdir().expect("tempdir");
584 let config = make_config(dir.path().to_path_buf());
585 let pkg = make_pkg_with_scripts(&[
586 ("build", "tsc -p tsconfig.json"),
587 ("lint", "eslint -c eslint.config.js"),
588 ]);
589 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
590 assert!(scopes.is_empty());
591 }
592
593 #[test]
594 fn script_scope_dedupes_within_package() {
595 let dir = tempfile::tempdir().expect("tempdir");
596 let config = make_config(dir.path().to_path_buf());
597 let pkg = make_pkg_with_scripts(&[
598 ("lint", "eslint -c .config/eslint.config.js"),
599 ("test", "vitest --config .config/vitest.config.ts"),
600 ]);
601 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
602 assert_eq!(scopes.len(), 1);
603 }
604
605 #[test]
606 fn script_scope_workspace_packages_have_own_scope_root() {
607 let dir = tempfile::tempdir().expect("tempdir");
608 let config = make_config(dir.path().to_path_buf());
609 let ws_root = dir.path().join("packages/app");
610 std::fs::create_dir_all(&ws_root).unwrap();
611 let ws_pkg_path = ws_root.join("package.json");
612 std::fs::write(
613 &ws_pkg_path,
614 r#"{"name":"app","scripts":{"lint":"eslint -c .config/eslint.config.js"}}"#,
615 )
616 .unwrap();
617 let ws = fallow_config::WorkspaceInfo {
618 root: ws_root.clone(),
619 name: "app".to_string(),
620 is_internal_dependency: false,
621 };
622 let scopes = collect_script_hidden_dir_scopes(&config, None, &[ws]);
623 assert_eq!(scopes.len(), 1);
624
625 std::fs::create_dir_all(ws_root.join(".config")).unwrap();
626 std::fs::write(
627 ws_root.join(".config/eslint.config.js"),
628 "export default {};",
629 )
630 .unwrap();
631 let other_root = dir.path().join("packages/other");
632 std::fs::create_dir_all(other_root.join(".config")).unwrap();
633 std::fs::write(
634 other_root.join(".config/eslint.config.js"),
635 "export default {};",
636 )
637 .unwrap();
638
639 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
640 let names: Vec<String> = files
641 .iter()
642 .map(|f| {
643 f.path
644 .strip_prefix(dir.path())
645 .unwrap_or(&f.path)
646 .to_string_lossy()
647 .replace('\\', "/")
648 })
649 .collect();
650 assert!(names.contains(&"packages/app/.config/eslint.config.js".to_string()));
651 assert!(
652 !names.contains(&"packages/other/.config/eslint.config.js".to_string()),
653 "unscoped workspace must not get .config traversed"
654 );
655 }
656}