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