1use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6
7use fallow_config::{
8 PackageJson, ResolvedConfig, WorkspaceDiagnostic, WorkspaceInfo, discover_workspaces,
9 find_undeclared_workspaces_with_ignores,
10};
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
12
13pub use crate::core_backend::{
16 ALLOWED_HIDDEN_DIRS, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS,
17};
18use rustc_hash::FxHashSet;
19
20use crate::{EngineError, EngineResult, plugins::PluginRegistry};
21
22const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
23
24const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
25 ".angular",
26 ".astro",
27 ".cache",
28 ".contentlayer",
29 ".docusaurus",
30 ".expo",
31 ".fallow",
32 ".git",
33 ".hg",
34 ".husky",
35 ".idea",
36 ".jj",
37 ".netlify",
38 ".next",
39 ".nuxt",
40 ".nx",
41 ".output",
42 ".parcel-cache",
43 ".pnpm",
44 ".pnpm-store",
45 ".react-router",
46 ".rollup.cache",
47 ".sst",
48 ".svelte-kit",
49 ".svn",
50 ".swc",
51 ".tanstack",
52 ".turbo",
53 ".velite",
54 ".vercel",
55 ".vinxi",
56 ".vscode",
57 ".wrangler",
58 ".wxt",
59 ".yalc",
60 ".yarn",
61];
62
63const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
64const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
65const SCRIPT_MULTIPLEXERS: &[&str] = &[
66 "concurrently",
67 "npm-run-all",
68 "npm-run-all2",
69 "run-s",
70 "run-p",
71 "run-s2",
72 "run-p2",
73];
74const BUN_RUNTIME_FLAGS: &[&str] = &["--bun", "--watch", "--hot", "--smol", "--no-clear-screen"];
75
76#[must_use]
83pub fn discover_workspace_packages(root: &Path) -> Vec<WorkspaceInfo> {
84 discover_workspaces(root)
85}
86
87pub fn discover_workspace_packages_with_diagnostics(
96 root: &Path,
97 ignore_patterns: &globset::GlobSet,
98) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>)> {
99 fallow_config::discover_workspaces_with_diagnostics(root, ignore_patterns)
100 .map_err(|err| EngineError::new(err.to_string()))
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum HiddenDirMatch {
109 AnyDepth,
111 ExactPath,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct HiddenDirScope {
118 root: PathBuf,
119 dirs: Vec<String>,
120 match_mode: HiddenDirMatch,
121}
122
123impl HiddenDirScope {
124 #[must_use]
125 const fn new(root: PathBuf, dirs: Vec<String>) -> Self {
126 Self {
127 root,
128 dirs,
129 match_mode: HiddenDirMatch::AnyDepth,
130 }
131 }
132
133 #[must_use]
134 const fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
135 Self {
136 root,
137 dirs,
138 match_mode: HiddenDirMatch::ExactPath,
139 }
140 }
141
142 #[must_use]
143 pub(crate) fn root(&self) -> &Path {
144 &self.root
145 }
146
147 #[must_use]
148 pub(crate) fn dirs(&self) -> &[String] {
149 &self.dirs
150 }
151
152 #[must_use]
153 pub(crate) const fn match_mode(&self) -> HiddenDirMatch {
154 self.match_mode
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct AnalysisDiscovery {
161 files: Vec<DiscoveredFile>,
162 workspaces: Vec<WorkspaceInfo>,
163 root_pkg: Option<PackageJson>,
164 config_candidates: Vec<PathBuf>,
165 source_diagnostics: Vec<WorkspaceDiagnostic>,
166 discover_ms: f64,
167 workspaces_ms: f64,
168}
169
170impl AnalysisDiscovery {
171 fn from_parts(
172 sources: crate::core_backend::DiscoveredSources,
173 workspaces: Vec<WorkspaceInfo>,
174 root_pkg: Option<PackageJson>,
175 discover_ms: f64,
176 workspaces_ms: f64,
177 ) -> Self {
178 Self {
179 files: sources.files,
180 workspaces,
181 root_pkg,
182 config_candidates: sources.config_candidates,
183 source_diagnostics: sources.diagnostics,
184 discover_ms,
185 workspaces_ms,
186 }
187 }
188
189 #[must_use]
191 pub(crate) fn files(&self) -> &[DiscoveredFile] {
192 &self.files
193 }
194
195 #[must_use]
197 pub(crate) fn workspaces(&self) -> &[WorkspaceInfo] {
198 &self.workspaces
199 }
200
201 pub(crate) fn root_pkg(&self) -> Option<&PackageJson> {
202 self.root_pkg.as_ref()
203 }
204
205 pub(crate) fn config_candidates(&self) -> &[PathBuf] {
206 &self.config_candidates
207 }
208
209 pub(crate) fn source_diagnostics(&self) -> &[WorkspaceDiagnostic] {
213 &self.source_diagnostics
214 }
215
216 pub(crate) fn discover_ms(&self) -> f64 {
217 self.discover_ms
218 }
219
220 pub(crate) fn workspaces_ms(&self) -> f64 {
221 self.workspaces_ms
222 }
223
224 #[must_use]
226 pub fn into_files(self) -> Vec<DiscoveredFile> {
227 self.files
228 }
229}
230
231#[must_use]
233pub(crate) fn prepare_analysis_discovery(config: &ResolvedConfig) -> AnalysisDiscovery {
234 let workspaces_start = Instant::now();
235 let workspaces = discover_workspaces(&config.root);
236 let workspaces_ms = workspaces_start.elapsed().as_secs_f64() * 1000.0;
237 if !workspaces.is_empty() {
238 tracing::info!(count = workspaces.len(), "workspaces discovered");
239 }
240 warn_undeclared_workspaces(
241 &config.root,
242 &workspaces,
243 &config.ignore_patterns,
244 config.quiet,
245 );
246
247 let root_pkg = fallow_config::load_dir_package_json(&config.root);
248 let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
249
250 let discover_start = Instant::now();
251 let sources = discover_files_config_candidates_and_diagnostics(config, &hidden_dir_scopes);
252 let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
253
254 AnalysisDiscovery::from_parts(sources, workspaces, root_pkg, discover_ms, workspaces_ms)
255}
256
257#[must_use]
264pub(crate) fn prepare_analysis_discovery_with_workspaces(
265 config: &ResolvedConfig,
266 workspaces: &[WorkspaceInfo],
267 workspaces_ms: f64,
268) -> AnalysisDiscovery {
269 if !workspaces.is_empty() {
270 tracing::info!(count = workspaces.len(), "workspaces discovered");
271 }
272
273 let root_pkg = fallow_config::load_dir_package_json(&config.root);
274 let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), workspaces);
275
276 let discover_start = Instant::now();
277 let sources = discover_files_config_candidates_and_diagnostics(config, &hidden_dir_scopes);
278 let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
279
280 AnalysisDiscovery::from_parts(
281 sources,
282 workspaces.to_vec(),
283 root_pkg,
284 discover_ms,
285 workspaces_ms,
286 )
287}
288
289fn format_undeclared_workspace_warning(
290 root: &Path,
291 undeclared: &[WorkspaceDiagnostic],
292) -> Option<String> {
293 if undeclared.is_empty() {
294 return None;
295 }
296
297 let preview = undeclared
298 .iter()
299 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
300 .map(|diagnostic| {
301 diagnostic
302 .path
303 .strip_prefix(root)
304 .unwrap_or(&diagnostic.path)
305 .display()
306 .to_string()
307 .replace('\\', "/")
308 })
309 .collect::<Vec<_>>();
310 let remaining = undeclared
311 .len()
312 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
313 let tail = if remaining > 0 {
314 format!(" (and {remaining} more)")
315 } else {
316 String::new()
317 };
318 let noun = if undeclared.len() == 1 {
319 "directory with package.json is"
320 } else {
321 "directories with package.json are"
322 };
323 let guidance = if undeclared.len() == 1 {
324 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
325 } else {
326 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
327 };
328
329 Some(format!(
330 "{} {} not declared as {}: {}{}. {}",
331 undeclared.len(),
332 noun,
333 if undeclared.len() == 1 {
334 "a workspace"
335 } else {
336 "workspaces"
337 },
338 preview.join(", "),
339 tail,
340 guidance
341 ))
342}
343
344fn warn_undeclared_workspaces(
345 root: &Path,
346 workspaces: &[WorkspaceInfo],
347 ignore_patterns: &globset::GlobSet,
348 quiet: bool,
349) {
350 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces, ignore_patterns);
351 if undeclared.is_empty() {
352 return;
353 }
354
355 let existing = fallow_config::workspace_diagnostics_for(root);
356 let already_flagged: FxHashSet<PathBuf> = existing
357 .iter()
358 .map(|diagnostic| {
359 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
360 })
361 .collect();
362 let undeclared: Vec<_> = undeclared
363 .into_iter()
364 .filter(|diagnostic| {
365 let canonical =
366 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
367 !already_flagged.contains(&canonical)
368 })
369 .collect();
370 if undeclared.is_empty() {
371 return;
372 }
373
374 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
375
376 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
377 tracing::warn!("{message}");
378 }
379}
380
381#[must_use]
383pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
384 ALLOWED_HIDDEN_DIRS
385 .iter()
386 .any(|&dir| OsStr::new(dir) == name)
387}
388
389#[must_use]
391pub fn collect_plugin_hidden_dir_scopes(
392 config: &ResolvedConfig,
393 root_pkg: Option<&PackageJson>,
394 workspaces: &[WorkspaceInfo],
395) -> Vec<HiddenDirScope> {
396 let registry = PluginRegistry::new(config.external_plugins.clone());
397 let mut scopes = Vec::new();
398
399 if let Some(pkg) = root_pkg {
400 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
401 }
402
403 for ws in workspaces {
404 if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
405 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
406 }
407 }
408
409 scopes
410}
411
412fn push_plugin_hidden_dir_scope(
413 scopes: &mut Vec<HiddenDirScope>,
414 registry: &PluginRegistry,
415 pkg: &PackageJson,
416 root: &Path,
417) {
418 let dirs = registry.discovery_hidden_dirs(pkg, root);
419 if !dirs.is_empty() {
420 scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
421 }
422}
423
424#[must_use]
426pub(crate) fn collect_hidden_dir_scopes(
427 config: &ResolvedConfig,
428 root_pkg: Option<&PackageJson>,
429 workspaces: &[WorkspaceInfo],
430) -> Vec<HiddenDirScope> {
431 let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
432 let registry = PluginRegistry::new(config.external_plugins.clone());
433 let mut scopes = Vec::new();
434
435 if let Some(pkg) = root_pkg {
436 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
437 push_script_hidden_dir_scope(&mut scopes, pkg, &config.root);
438 }
439
440 for ws in workspaces {
441 if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
442 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
443 push_script_hidden_dir_scope(&mut scopes, &pkg, &ws.root);
444 }
445 }
446
447 scopes
448}
449
450fn push_script_hidden_dir_scope(scopes: &mut Vec<HiddenDirScope>, pkg: &PackageJson, root: &Path) {
451 if let Some(scope) = build_script_scope(pkg, root) {
452 scopes.push(scope);
453 }
454}
455
456fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
457 let scripts = pkg.scripts.as_ref()?;
458 let mut seen = FxHashSet::default();
459 let mut dirs: Vec<String> = Vec::new();
460
461 for (script_name, script_value) in scripts {
462 for cmd in parse_script_value(script_value) {
463 for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
464 for hidden in extract_hidden_dir_paths(path) {
465 if hidden_dir_path_is_denied(&hidden) {
466 continue;
467 }
468 if seen.insert(hidden.clone()) {
469 tracing::debug!(
470 dir = %hidden,
471 script = %script_name,
472 package_root = %root.display(),
473 "inferred hidden_dir_scope from package.json#scripts"
474 );
475 dirs.push(hidden);
476 }
477 }
478 }
479 }
480 }
481
482 if dirs.is_empty() {
483 None
484 } else {
485 Some(HiddenDirScope::new_exact_paths(root.to_path_buf(), dirs))
486 }
487}
488
489fn hidden_dir_path_is_denied(path: &str) -> bool {
495 Path::new(path)
496 .file_name()
497 .and_then(|name| name.to_str())
498 .is_some_and(|name| SCRIPT_SCOPE_DENYLIST.contains(&name))
499}
500
501#[derive(Debug, PartialEq, Eq)]
502struct ScriptCommand {
503 config_args: Vec<String>,
504 file_args: Vec<String>,
505}
506
507fn parse_script_value(script: &str) -> Vec<ScriptCommand> {
508 let mut commands = Vec::new();
509
510 for segment in split_shell_operators(script) {
511 let segment = segment.trim();
512 if segment.is_empty() {
513 continue;
514 }
515 if let Some(cmd) = parse_command_segment(segment) {
516 commands.push(cmd);
517 }
518 }
519
520 commands
521}
522
523fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
524 let tokens: Vec<&str> = segment
525 .split_whitespace()
526 .map(strip_surrounding_quotes)
527 .collect();
528 if tokens.is_empty() {
529 return None;
530 }
531
532 let idx = skip_initial_wrappers(&tokens, 0)?;
533 let idx = advance_past_package_manager(&tokens, idx)?;
534 let binary = tokens[idx];
535
536 if SCRIPT_MULTIPLEXERS.contains(&binary) {
537 return Some(ScriptCommand {
538 config_args: Vec::new(),
539 file_args: Vec::new(),
540 });
541 }
542
543 let is_node_runner = NODE_RUNNERS.contains(&binary);
544 let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
545
546 Some(ScriptCommand {
547 config_args,
548 file_args,
549 })
550}
551
552fn split_shell_operators(script: &str) -> Vec<&str> {
553 let mut segments = Vec::new();
554 let mut start = 0;
555 let bytes = script.as_bytes();
556 let len = bytes.len();
557 let mut index = 0;
558 let mut in_single_quote = false;
559 let mut in_double_quote = false;
560
561 while index < len {
562 let byte = bytes[index];
563
564 if byte == b'\'' && !in_double_quote {
565 in_single_quote = !in_single_quote;
566 index += 1;
567 continue;
568 }
569 if byte == b'"' && !in_single_quote {
570 in_double_quote = !in_double_quote;
571 index += 1;
572 continue;
573 }
574
575 if in_single_quote || in_double_quote {
576 index += 1;
577 continue;
578 }
579
580 if let Some(op_len) = shell_operator_len(bytes, index) {
581 segments.push(&script[start..index]);
582 index += op_len;
583 start = index;
584 continue;
585 }
586
587 index += 1;
588 }
589
590 if start < len {
591 segments.push(&script[start..]);
592 }
593
594 segments
595}
596
597fn shell_operator_len(bytes: &[u8], index: usize) -> Option<usize> {
598 let byte = bytes[index];
599 let next = bytes.get(index + 1).copied();
600
601 if matches!((byte, next), (b'&', Some(b'&')) | (b'|', Some(b'|'))) {
602 return Some(2);
603 }
604
605 if byte == b';' {
606 return Some(1);
607 }
608 if byte == b'|' && next != Some(b'|') {
609 return Some(1);
610 }
611 if byte == b'&' && next != Some(b'&') {
612 return Some(1);
613 }
614
615 None
616}
617
618fn strip_surrounding_quotes(token: &str) -> &str {
619 if token.len() >= 2 {
620 let first = token.as_bytes()[0];
621 let last = token.as_bytes()[token.len() - 1];
622 if (first == b'\'' || first == b'"') && first == last {
623 return &token[1..token.len() - 1];
624 }
625 }
626 token
627}
628
629fn skip_initial_wrappers(tokens: &[&str], mut index: usize) -> Option<usize> {
630 while index < tokens.len() && is_env_assignment(tokens[index]) {
631 index += 1;
632 }
633 if index >= tokens.len() {
634 return None;
635 }
636
637 while index < tokens.len() && ENV_WRAPPERS.contains(&tokens[index]) {
638 index += 1;
639 while index < tokens.len() && is_env_assignment(tokens[index]) {
640 index += 1;
641 }
642 if index < tokens.len() && tokens[index] == "--" {
643 index += 1;
644 }
645 }
646 if index >= tokens.len() {
647 return None;
648 }
649
650 Some(index)
651}
652
653fn advance_past_package_manager(tokens: &[&str], mut index: usize) -> Option<usize> {
654 let token = tokens[index];
655 if matches!(token, "npx" | "pnpx" | "bunx") {
656 index += 1;
657 while index < tokens.len() && tokens[index].starts_with('-') {
658 let flag = tokens[index];
659 index += 1;
660 if matches!(flag, "--package" | "-p") && index < tokens.len() {
661 index += 1;
662 }
663 }
664 } else if token == "bun" {
665 index += 1;
666 let mut saw_runtime_flag = false;
667 while index < tokens.len() && BUN_RUNTIME_FLAGS.contains(&tokens[index]) {
668 index += 1;
669 saw_runtime_flag = true;
670 }
671 if index >= tokens.len() {
672 return None;
673 }
674 let subcmd = tokens[index];
675 if subcmd == "exec" || subcmd == "x" {
676 index += 1;
677 } else if matches!(subcmd, "run" | "run-script") || !saw_runtime_flag {
678 return None;
679 }
680 } else if matches!(token, "yarn" | "pnpm" | "npm") {
681 if index + 1 < tokens.len() {
682 let subcmd = tokens[index + 1];
683 if subcmd == "exec" || subcmd == "dlx" {
684 index += 2;
685 } else {
686 return None;
687 }
688 } else {
689 return None;
690 }
691 }
692 if index >= tokens.len() {
693 return None;
694 }
695
696 Some(index)
697}
698
699fn extract_args_for_binary(
700 tokens: &[&str],
701 mut index: usize,
702 is_node_runner: bool,
703) -> (Vec<String>, Vec<String>) {
704 let mut file_args = Vec::new();
705 let mut config_args = Vec::new();
706
707 while index < tokens.len() {
708 let token = tokens[index];
709
710 if is_node_runner
711 && matches!(
712 token,
713 "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
714 )
715 {
716 index += 2;
717 continue;
718 }
719
720 if let Some(config) = extract_config_arg(token, tokens.get(index + 1).copied()) {
721 config_args.push(config);
722 if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
723 index += 1;
724 } else {
725 index += 2;
726 }
727 continue;
728 }
729
730 if token.starts_with('-') {
731 index += 1;
732 continue;
733 }
734
735 if looks_like_file_path(token) {
736 file_args.push(token.to_string());
737 }
738 index += 1;
739 }
740
741 (file_args, config_args)
742}
743
744fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
745 if let Some(value) = token.strip_prefix("--config=")
746 && !value.is_empty()
747 {
748 return Some(value.to_string());
749 }
750 if let Some(value) = token.strip_prefix("-c=")
751 && !value.is_empty()
752 {
753 return Some(value.to_string());
754 }
755 if matches!(token, "--config" | "-c")
756 && let Some(next_token) = next
757 && !next_token.starts_with('-')
758 {
759 return Some(next_token.to_string());
760 }
761 None
762}
763
764fn is_env_assignment(token: &str) -> bool {
765 token.find('=').is_some_and(|eq_pos| {
766 let name = &token[..eq_pos];
767 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
768 })
769}
770
771fn looks_like_file_path(token: &str) -> bool {
772 if !could_be_file_path(token) {
773 return false;
774 }
775
776 const EXTENSIONS: &[&str] = &[
777 ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
778 ".toml",
779 ];
780 if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
781 return true;
782 }
783 token.starts_with("./")
784 || token.starts_with("../")
785 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
786}
787
788fn could_be_file_path(token: &str) -> bool {
789 if token.contains("${{") || (token.contains("}}") && !token.contains("{{")) {
790 return false;
791 }
792
793 if token.contains('\\') {
794 return false;
795 }
796
797 if let Some(open) = token.find('[') {
798 let after_open = &token[open + 1..];
799 let close_offset = after_open.find(']');
800 if !matches!(close_offset, Some(offset) if offset > 0) {
801 return false;
802 }
803 }
804
805 true
806}
807
808fn extract_hidden_dir_paths(path: &str) -> Vec<String> {
820 let path = Path::new(path);
821 if path.is_absolute() {
822 return Vec::new();
823 }
824
825 let mut hidden = Vec::new();
826 let components = path.components().collect::<Vec<_>>();
827 if components.iter().any(|component| {
828 matches!(
829 component,
830 std::path::Component::ParentDir | std::path::Component::RootDir
831 )
832 }) {
833 return Vec::new();
834 }
835
836 let mut prefix = PathBuf::new();
837 for (index, component) in components.iter().enumerate() {
838 let std::path::Component::Normal(value) = component else {
839 continue;
840 };
841 if index == components.len().saturating_sub(1) {
842 continue;
843 }
844 prefix.push(value);
845 let value = value.to_string_lossy();
846 if !value.starts_with('.') || value == "." || value == ".." {
847 continue;
848 }
849 hidden.push(prefix.to_string_lossy().into_owned());
850 }
851
852 hidden
853}
854
855#[must_use]
857pub fn discover_files_and_config_candidates(
858 config: &ResolvedConfig,
859 additional_hidden_dir_scopes: &[HiddenDirScope],
860) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
861 crate::core_backend::discover_files_and_config_candidates(config, additional_hidden_dir_scopes)
862}
863
864#[must_use]
866pub(crate) fn discover_files_config_candidates_and_diagnostics(
867 config: &ResolvedConfig,
868 additional_hidden_dir_scopes: &[HiddenDirScope],
869) -> crate::core_backend::DiscoveredSources {
870 crate::core_backend::discover_files_config_candidates_and_diagnostics(
871 config,
872 additional_hidden_dir_scopes,
873 )
874}
875
876#[must_use]
878pub(crate) fn discover_entry_points(
879 config: &ResolvedConfig,
880 files: &[DiscoveredFile],
881) -> Vec<EntryPoint> {
882 crate::core_backend::discover_entry_points(config, files)
883}
884
885#[must_use]
887pub(crate) fn discover_workspace_entry_points(
888 ws_root: &Path,
889 config: &ResolvedConfig,
890 all_files: &[DiscoveredFile],
891) -> Vec<EntryPoint> {
892 crate::core_backend::discover_workspace_entry_points(ws_root, config, all_files)
893}
894
895#[must_use]
897pub(crate) fn discover_plugin_entry_points(
898 plugin_result: &crate::plugins::AggregatedPluginResult,
899 config: &ResolvedConfig,
900 files: &[DiscoveredFile],
901) -> Vec<EntryPoint> {
902 crate::core_backend::discover_plugin_entry_points(plugin_result.backend(), config, files)
903}
904
905#[cfg(test)]
906mod tests {
907 use std::path::PathBuf;
908
909 use fallow_config::PackageJson;
910
911 use super::{
912 ALLOWED_HIDDEN_DIRS, HiddenDirScope, collect_hidden_dir_scopes,
913 collect_plugin_hidden_dir_scopes, extract_hidden_dir_paths, is_allowed_hidden_dir,
914 };
915
916 #[test]
917 fn hidden_dir_scope_exposes_root_and_dirs() {
918 let scope = HiddenDirScope::new(PathBuf::from("/repo/packages/app"), vec![".next".into()]);
919
920 assert_eq!(scope.root(), PathBuf::from("/repo/packages/app"));
921 assert_eq!(scope.dirs(), [".next"]);
922 }
923
924 #[test]
925 fn hidden_dir_allowlist_is_engine_owned() {
926 for dir in ALLOWED_HIDDEN_DIRS {
927 assert!(is_allowed_hidden_dir(std::ffi::OsStr::new(dir)));
928 }
929 assert!(!is_allowed_hidden_dir(std::ffi::OsStr::new(".git")));
930 }
931
932 #[test]
933 fn plugin_hidden_dir_scopes_are_engine_owned() {
934 let dir = tempfile::tempdir().expect("tempdir");
935 let config = fallow_config::FallowConfig::default().resolve(
936 dir.path().to_path_buf(),
937 fallow_config::OutputFormat::Human,
938 1,
939 true,
940 true,
941 None,
942 );
943 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
944 "devDependencies": {
945 "@react-router/dev": "^7.0.0"
946 }
947 }))
948 .expect("valid package fixture");
949
950 let scopes = collect_plugin_hidden_dir_scopes(&config, Some(&pkg), &[]);
951
952 assert_eq!(scopes.len(), 1);
953 assert_eq!(scopes[0].root(), dir.path());
954 assert_eq!(scopes[0].dirs(), [".client", ".server"]);
955 }
956
957 #[test]
958 fn script_hidden_dir_scopes_are_engine_owned() {
959 let dir = tempfile::tempdir().expect("tempdir");
960 let config = fallow_config::FallowConfig::default().resolve(
961 dir.path().to_path_buf(),
962 fallow_config::OutputFormat::Human,
963 1,
964 true,
965 true,
966 None,
967 );
968 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
969 "scripts": {
970 "lint": "eslint -c .config/eslint.config.js",
971 "build": "tsx ./.scripts/build.ts",
972 "cache": "tsx .nx/cache/build.ts",
973 "pnpm": "node node_modules/.pnpm/tool/bin.js"
974 }
975 }))
976 .expect("valid package fixture");
977
978 let scopes = collect_hidden_dir_scopes(&config, Some(&pkg), &[]);
979
980 assert_eq!(scopes.len(), 1);
981 assert_eq!(scopes[0].root(), dir.path());
982 let mut dirs = scopes[0].dirs().to_vec();
983 dirs.sort();
984 assert_eq!(dirs, [".config", ".scripts"]);
985 }
986
987 #[test]
988 fn hidden_dir_path_extraction_rejects_escape_paths() {
989 assert_eq!(
990 extract_hidden_dir_paths(".foo/.bar/x.js"),
991 vec![
992 ".foo".to_string(),
993 format!(".foo{}.bar", std::path::MAIN_SEPARATOR)
994 ]
995 );
996 assert!(extract_hidden_dir_paths("../../.config/eslint.config.js").is_empty());
997 assert!(extract_hidden_dir_paths(".env").is_empty());
998 }
999}