1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14#![cfg_attr(
15 test,
16 allow(
17 clippy::unwrap_used,
18 clippy::expect_used,
19 reason = "tests use unwrap and expect to keep fixture setup concise"
20 )
21)]
22
23pub mod analyze;
24pub mod cache;
25pub mod changed_files;
26pub mod churn;
27pub mod cross_reference;
28pub mod discover;
29pub mod duplicates;
30pub(crate) mod errors;
31mod external_style_usage;
32pub mod extract;
33pub mod git_env;
34mod package_assets;
35pub mod plugins;
36pub(crate) mod progress;
37pub mod results;
38pub(crate) mod scripts;
39pub(crate) mod spawn;
40pub mod suppress;
41pub mod trace;
42
43pub use fallow_graph::graph;
44pub use fallow_graph::project;
45pub use fallow_graph::resolve;
46
47use std::path::{Path, PathBuf};
48use std::time::Instant;
49
50use errors::FallowError;
51use fallow_config::{
52 EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
53 find_undeclared_workspaces_with_ignores,
54};
55use rayon::prelude::*;
56use results::AnalysisResults;
57use rustc_hash::FxHashSet;
58use trace::PipelineTimings;
59
60const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
61type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
62
63fn record_graph_package_usage(
64 graph: &mut graph::ModuleGraph,
65 package_name: &str,
66 file_id: discover::FileId,
67 is_type_only: bool,
68) {
69 graph
70 .package_usage
71 .entry(package_name.to_owned())
72 .or_default()
73 .push(file_id);
74 if is_type_only {
75 graph
76 .type_only_package_usage
77 .entry(package_name.to_owned())
78 .or_default()
79 .push(file_id);
80 }
81}
82
83fn workspace_package_name<'a>(
84 source: &str,
85 workspace_names: &'a FxHashSet<&str>,
86) -> Option<&'a str> {
87 if !resolve::is_bare_specifier(source) {
88 return None;
89 }
90 let package_name = resolve::extract_package_name(source);
91 workspace_names.get(package_name.as_str()).copied()
92}
93
94fn credit_workspace_package_usage(
95 graph: &mut graph::ModuleGraph,
96 resolved: &[resolve::ResolvedModule],
97 workspaces: &[fallow_config::WorkspaceInfo],
98) {
99 if workspaces.is_empty() {
100 return;
101 }
102
103 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
104 for module in resolved {
105 for import in module.all_resolved_imports() {
106 if matches!(import.target, resolve::ResolveResult::InternalModule(_))
107 && let Some(package_name) =
108 workspace_package_name(&import.info.source, &workspace_names)
109 {
110 record_graph_package_usage(
111 graph,
112 package_name,
113 module.file_id,
114 import.info.is_type_only,
115 );
116 }
117 }
118
119 for re_export in &module.re_exports {
120 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
121 && let Some(package_name) =
122 workspace_package_name(&re_export.info.source, &workspace_names)
123 {
124 record_graph_package_usage(
125 graph,
126 package_name,
127 module.file_id,
128 re_export.info.is_type_only,
129 );
130 }
131 }
132 }
133}
134
135fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
136 for module in modules {
137 for package_name in &module.package_path_references {
138 record_graph_package_usage(graph, package_name, module.file_id, false);
139 }
140 }
141}
142
143pub struct AnalysisOutput {
145 pub results: AnalysisResults,
146 pub timings: Option<PipelineTimings>,
147 pub graph: Option<graph::ModuleGraph>,
148 pub modules: Option<Vec<extract::ModuleInfo>>,
151 pub files: Option<Vec<discover::DiscoveredFile>>,
153 pub script_used_packages: rustc_hash::FxHashSet<String>,
158 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
167}
168
169fn update_cache(
171 store: &mut cache::CacheStore,
172 modules: &[extract::ModuleInfo],
173 files: &[discover::DiscoveredFile],
174) {
175 for module in modules {
176 if let Some(file) = files.get(module.file_id.0 as usize) {
177 let (mt, sz) = file_mtime_and_size(&file.path);
178 if let Some(cached) = store.get_by_path_only(&file.path)
179 && cached.content_hash == module.content_hash
180 {
181 if cached.mtime_secs != mt || cached.file_size != sz {
182 let preserved_last_access = cached.last_access_secs;
183 let mut refreshed = cache::module_to_cached(module, mt, sz);
184 refreshed.last_access_secs = preserved_last_access;
185 store.insert(&file.path, refreshed);
186 }
187 continue;
188 }
189 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
190 }
191 }
192 store.retain_paths(files);
193}
194
195#[must_use]
203pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
204 config
205 .cache_max_size_mb
206 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
207 (mb as usize).saturating_mul(1024 * 1024)
208 })
209}
210
211fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
213 std::fs::metadata(path).map_or((0, 0), |m| {
214 let mt = m
215 .modified()
216 .ok()
217 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
218 .map_or(0, |d| d.as_secs());
219 (mt, m.len())
220 })
221}
222
223fn format_undeclared_workspace_warning(
224 root: &Path,
225 undeclared: &[fallow_config::WorkspaceDiagnostic],
226) -> Option<String> {
227 if undeclared.is_empty() {
228 return None;
229 }
230
231 let preview = undeclared
232 .iter()
233 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
234 .map(|diag| {
235 diag.path
236 .strip_prefix(root)
237 .unwrap_or(&diag.path)
238 .display()
239 .to_string()
240 .replace('\\', "/")
241 })
242 .collect::<Vec<_>>();
243 let remaining = undeclared
244 .len()
245 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
246 let tail = if remaining > 0 {
247 format!(" (and {remaining} more)")
248 } else {
249 String::new()
250 };
251 let noun = if undeclared.len() == 1 {
252 "directory with package.json is"
253 } else {
254 "directories with package.json are"
255 };
256 let guidance = if undeclared.len() == 1 {
257 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
258 } else {
259 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
260 };
261
262 Some(format!(
263 "{} {} not declared as {}: {}{}. {}",
264 undeclared.len(),
265 noun,
266 if undeclared.len() == 1 {
267 "a workspace"
268 } else {
269 "workspaces"
270 },
271 preview.join(", "),
272 tail,
273 guidance
274 ))
275}
276
277fn warn_undeclared_workspaces(
278 root: &Path,
279 workspaces_vec: &[fallow_config::WorkspaceInfo],
280 ignore_patterns: &globset::GlobSet,
281 quiet: bool,
282) {
283 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
284 if undeclared.is_empty() {
285 return;
286 }
287
288 let existing = fallow_config::workspace_diagnostics_for(root);
289 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
290 .iter()
291 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
292 .collect();
293 let undeclared: Vec<_> = undeclared
294 .into_iter()
295 .filter(|diag| {
296 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
297 !already_flagged.contains(&canonical)
298 })
299 .collect();
300 if undeclared.is_empty() {
301 return;
302 }
303
304 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
305
306 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
307 tracing::warn!("{message}");
308 }
309}
310
311#[deprecated(
317 since = "2.76.0",
318 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
319)]
320pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
321 let output = analyze_full(config, false, false, false, false)?;
322 Ok(output.results)
323}
324
325#[deprecated(
331 since = "2.76.0",
332 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
333)]
334pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
335 let output = analyze_full(config, false, true, false, false)?;
336 Ok(output.results)
337}
338
339#[deprecated(
345 since = "2.76.0",
346 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: trace timings are not exposed in the programmatic surface today; use `fallow check --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
347)]
348pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
349 analyze_full(config, true, false, false, false)
350}
351
352#[deprecated(
361 since = "2.76.0",
362 note = "fallow_core is internal; the CLI fix command uses this via the workspace path dependency. External embedders should use fallow_cli::programmatic::detect_dead_code. See docs/fallow-core-migration.md and ADR-008."
363)]
364pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
365 analyze_full(config, false, false, false, false)
366}
367
368#[deprecated(
378 since = "2.76.0",
379 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
380)]
381pub fn analyze_retaining_modules(
382 config: &ResolvedConfig,
383 need_complexity: bool,
384 retain_graph: bool,
385) -> Result<AnalysisOutput, FallowError> {
386 analyze_full(config, retain_graph, false, need_complexity, true)
387}
388
389#[allow(
400 clippy::too_many_lines,
401 reason = "pipeline orchestration stays easier to audit in one place"
402)]
403#[deprecated(
404 since = "2.76.0",
405 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: pre-parsed module reuse is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
406)]
407pub fn analyze_with_parse_result(
408 config: &ResolvedConfig,
409 modules: &[extract::ModuleInfo],
410) -> Result<AnalysisOutput, FallowError> {
411 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
412 let pipeline_start = Instant::now();
413
414 let show_progress = !config.quiet
415 && std::io::IsTerminal::is_terminal(&std::io::stderr())
416 && matches!(
417 config.output,
418 fallow_config::OutputFormat::Human
419 | fallow_config::OutputFormat::Compact
420 | fallow_config::OutputFormat::Markdown
421 );
422 let progress = progress::AnalysisProgress::new(show_progress);
423
424 if !config.root.join("node_modules").is_dir() {
425 tracing::warn!(
426 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
427 );
428 }
429
430 let t = Instant::now();
431 let workspaces_vec = discover_workspaces(&config.root);
432 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
433 if !workspaces_vec.is_empty() {
434 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
435 }
436
437 warn_undeclared_workspaces(
438 &config.root,
439 &workspaces_vec,
440 &config.ignore_patterns,
441 config.quiet,
442 );
443 let root_pkg = load_root_package_json(config);
444 let discovery_hidden_dir_scopes =
445 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
446
447 let t = Instant::now();
448 progress.set_stage("discovering files...");
449 let discovered_files =
450 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
451 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
452
453 let project = project::ProjectState::new(discovered_files, workspaces_vec);
454 let files = project.files();
455 let workspaces = project.workspaces();
456 let workspace_pkgs = load_workspace_packages(workspaces);
457
458 let t = Instant::now();
459 progress.set_stage("detecting plugins...");
460 let mut plugin_result = run_plugins(
461 config,
462 files,
463 workspaces,
464 root_pkg.as_ref(),
465 &workspace_pkgs,
466 );
467 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
468
469 let t = Instant::now();
470 analyze_all_scripts(
471 config,
472 workspaces,
473 root_pkg.as_ref(),
474 &workspace_pkgs,
475 &mut plugin_result,
476 );
477 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
478
479 let t = Instant::now();
480 let entry_points = discover_all_entry_points(
481 config,
482 files,
483 workspaces,
484 root_pkg.as_ref(),
485 &workspace_pkgs,
486 &plugin_result,
487 );
488 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
489
490 let ep_summary = summarize_entry_points(&entry_points.all);
491
492 let t = Instant::now();
493 progress.set_stage("resolving imports...");
494 let mut resolved = resolve::resolve_all_imports(
495 modules,
496 files,
497 workspaces,
498 &plugin_result.active_plugins,
499 &plugin_result.path_aliases,
500 &plugin_result.auto_imports,
501 &plugin_result.scss_include_paths,
502 &plugin_result.static_dir_mappings,
503 &config.root,
504 &config.resolve.conditions,
505 );
506 external_style_usage::augment_external_style_package_usage(
507 &mut resolved,
508 config,
509 workspaces,
510 &plugin_result,
511 );
512 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
513
514 let t = Instant::now();
515 progress.set_stage("building module graph...");
516 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
517 &resolved,
518 &entry_points.all,
519 &entry_points.runtime,
520 &entry_points.test,
521 files,
522 );
523 credit_package_path_references(&mut graph, modules);
524 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
525 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
526
527 let t = Instant::now();
528 progress.set_stage("analyzing...");
529 #[expect(
530 deprecated,
531 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
532 )]
533 let mut result = analyze::find_dead_code_full(
534 &graph,
535 config,
536 &resolved,
537 Some(&plugin_result),
538 workspaces,
539 modules,
540 false,
541 );
542 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
543 progress.finish();
544
545 result.entry_point_summary = Some(ep_summary);
546
547 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
548
549 tracing::debug!(
550 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
551 │ discover files: {:>8.1}ms ({} files)\n\
552 │ workspaces: {:>8.1}ms\n\
553 │ plugins: {:>8.1}ms\n\
554 │ script analysis: {:>8.1}ms\n\
555 │ parse/extract: SKIPPED (reused {} modules)\n\
556 │ entry points: {:>8.1}ms ({} entries)\n\
557 │ resolve imports: {:>8.1}ms\n\
558 │ build graph: {:>8.1}ms\n\
559 │ analyze: {:>8.1}ms\n\
560 │ ────────────────────────────────────────────\n\
561 │ TOTAL: {:>8.1}ms\n\
562 └─────────────────────────────────────────────────",
563 discover_ms,
564 files.len(),
565 workspaces_ms,
566 plugins_ms,
567 scripts_ms,
568 modules.len(),
569 entry_points_ms,
570 entry_points.all.len(),
571 resolve_ms,
572 graph_ms,
573 analyze_ms,
574 total_ms,
575 );
576
577 let timings = Some(PipelineTimings {
578 discover_files_ms: discover_ms,
579 file_count: files.len(),
580 workspaces_ms,
581 workspace_count: workspaces.len(),
582 plugins_ms,
583 script_analysis_ms: scripts_ms,
584 parse_extract_ms: 0.0, parse_cpu_ms: 0.0, module_count: modules.len(),
587 cache_hits: 0,
588 cache_misses: 0,
589 cache_update_ms: 0.0,
590 entry_points_ms,
591 entry_point_count: entry_points.all.len(),
592 resolve_imports_ms: resolve_ms,
593 build_graph_ms: graph_ms,
594 analyze_ms,
595 duplication_ms: None,
596 total_ms,
597 });
598
599 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
600 .iter()
601 .filter_map(|module| {
602 files
603 .get(module.file_id.0 as usize)
604 .map(|file| (file.path.clone(), module.content_hash))
605 })
606 .collect();
607
608 Ok(AnalysisOutput {
609 results: result,
610 timings,
611 graph: Some(graph),
612 modules: None,
613 files: None,
614 script_used_packages: plugin_result.script_used_packages.clone(),
615 file_hashes,
616 })
617}
618
619#[expect(
620 clippy::unnecessary_wraps,
621 reason = "Result kept for future error handling"
622)]
623#[expect(
624 clippy::too_many_lines,
625 reason = "main pipeline function; sequential phases are held together for clarity"
626)]
627fn analyze_full(
628 config: &ResolvedConfig,
629 retain: bool,
630 collect_usages: bool,
631 need_complexity: bool,
632 retain_modules: bool,
633) -> Result<AnalysisOutput, FallowError> {
634 let _span = tracing::info_span!("fallow_analyze").entered();
635 let pipeline_start = Instant::now();
636
637 let show_progress = !config.quiet
638 && std::io::IsTerminal::is_terminal(&std::io::stderr())
639 && matches!(
640 config.output,
641 fallow_config::OutputFormat::Human
642 | fallow_config::OutputFormat::Compact
643 | fallow_config::OutputFormat::Markdown
644 );
645 let progress = progress::AnalysisProgress::new(show_progress);
646
647 if !config.root.join("node_modules").is_dir() {
648 tracing::warn!(
649 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
650 );
651 }
652
653 let t = Instant::now();
654 let workspaces_vec = discover_workspaces(&config.root);
655 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
656 if !workspaces_vec.is_empty() {
657 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
658 }
659
660 warn_undeclared_workspaces(
661 &config.root,
662 &workspaces_vec,
663 &config.ignore_patterns,
664 config.quiet,
665 );
666 let root_pkg = load_root_package_json(config);
667 let discovery_hidden_dir_scopes =
668 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
669
670 let t = Instant::now();
671 progress.set_stage("discovering files...");
672 let discovered_files =
673 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
674 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
675
676 let project = project::ProjectState::new(discovered_files, workspaces_vec);
677 let files = project.files();
678 let workspaces = project.workspaces();
679 let workspace_pkgs = load_workspace_packages(workspaces);
680
681 let t = Instant::now();
682 progress.set_stage("detecting plugins...");
683 let mut plugin_result = run_plugins(
684 config,
685 files,
686 workspaces,
687 root_pkg.as_ref(),
688 &workspace_pkgs,
689 );
690 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
691
692 let t = Instant::now();
693 analyze_all_scripts(
694 config,
695 workspaces,
696 root_pkg.as_ref(),
697 &workspace_pkgs,
698 &mut plugin_result,
699 );
700 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
701
702 let t = Instant::now();
703 progress.set_stage(&format!("parsing {} files...", files.len()));
704 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
705 let mut cache_store = if config.no_cache {
706 None
707 } else {
708 cache::CacheStore::load(
709 &config.cache_dir,
710 config.cache_config_hash,
711 cache_max_size_bytes,
712 )
713 };
714
715 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
716 let modules = parse_result.modules;
717 let cache_hits = parse_result.cache_hits;
718 let cache_misses = parse_result.cache_misses;
719 let parse_cpu_ms = parse_result.parse_cpu_ms;
720 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
721
722 let t = Instant::now();
723 if !config.no_cache {
724 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
725 update_cache(store, &modules, files);
726 if let Err(e) = store.save(
727 &config.cache_dir,
728 config.cache_config_hash,
729 cache_max_size_bytes,
730 ) {
731 tracing::warn!("Failed to save cache: {e}");
732 }
733 }
734 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
735
736 let t = Instant::now();
737 let entry_points = discover_all_entry_points(
738 config,
739 files,
740 workspaces,
741 root_pkg.as_ref(),
742 &workspace_pkgs,
743 &plugin_result,
744 );
745 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
746
747 let t = Instant::now();
748 progress.set_stage("resolving imports...");
749 let mut resolved = resolve::resolve_all_imports(
750 &modules,
751 files,
752 workspaces,
753 &plugin_result.active_plugins,
754 &plugin_result.path_aliases,
755 &plugin_result.auto_imports,
756 &plugin_result.scss_include_paths,
757 &plugin_result.static_dir_mappings,
758 &config.root,
759 &config.resolve.conditions,
760 );
761 external_style_usage::augment_external_style_package_usage(
762 &mut resolved,
763 config,
764 workspaces,
765 &plugin_result,
766 );
767 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
768
769 let t = Instant::now();
770 progress.set_stage("building module graph...");
771 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
772 &resolved,
773 &entry_points.all,
774 &entry_points.runtime,
775 &entry_points.test,
776 files,
777 );
778 credit_package_path_references(&mut graph, &modules);
779 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
780 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
781
782 let ep_summary = summarize_entry_points(&entry_points.all);
783
784 let t = Instant::now();
785 progress.set_stage("analyzing...");
786 #[expect(
787 deprecated,
788 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
789 )]
790 let mut result = analyze::find_dead_code_full(
791 &graph,
792 config,
793 &resolved,
794 Some(&plugin_result),
795 workspaces,
796 &modules,
797 collect_usages,
798 );
799 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
800 progress.finish();
801
802 result.entry_point_summary = Some(ep_summary);
803
804 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
805
806 let cache_summary = if cache_hits > 0 {
807 format!(" ({cache_hits} cached, {cache_misses} parsed)")
808 } else {
809 String::new()
810 };
811
812 tracing::debug!(
813 "\n┌─ Pipeline Profile ─────────────────────────────\n\
814 │ discover files: {:>8.1}ms ({} files)\n\
815 │ workspaces: {:>8.1}ms\n\
816 │ plugins: {:>8.1}ms\n\
817 │ script analysis: {:>8.1}ms\n\
818 │ parse/extract: {:>8.1}ms ({} modules{})\n\
819 │ cache update: {:>8.1}ms\n\
820 │ entry points: {:>8.1}ms ({} entries)\n\
821 │ resolve imports: {:>8.1}ms\n\
822 │ build graph: {:>8.1}ms\n\
823 │ analyze: {:>8.1}ms\n\
824 │ ────────────────────────────────────────────\n\
825 │ TOTAL: {:>8.1}ms\n\
826 └─────────────────────────────────────────────────",
827 discover_ms,
828 files.len(),
829 workspaces_ms,
830 plugins_ms,
831 scripts_ms,
832 parse_ms,
833 modules.len(),
834 cache_summary,
835 cache_ms,
836 entry_points_ms,
837 entry_points.all.len(),
838 resolve_ms,
839 graph_ms,
840 analyze_ms,
841 total_ms,
842 );
843
844 let timings = if retain {
845 Some(PipelineTimings {
846 discover_files_ms: discover_ms,
847 file_count: files.len(),
848 workspaces_ms,
849 workspace_count: workspaces.len(),
850 plugins_ms,
851 script_analysis_ms: scripts_ms,
852 parse_extract_ms: parse_ms,
853 parse_cpu_ms,
854 module_count: modules.len(),
855 cache_hits,
856 cache_misses,
857 cache_update_ms: cache_ms,
858 entry_points_ms,
859 entry_point_count: entry_points.all.len(),
860 resolve_imports_ms: resolve_ms,
861 build_graph_ms: graph_ms,
862 analyze_ms,
863 duplication_ms: None,
864 total_ms,
865 })
866 } else {
867 None
868 };
869
870 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
871 .iter()
872 .filter_map(|module| {
873 files
874 .get(module.file_id.0 as usize)
875 .map(|file| (file.path.clone(), module.content_hash))
876 })
877 .collect();
878
879 Ok(AnalysisOutput {
880 results: result,
881 timings,
882 graph: if retain { Some(graph) } else { None },
883 modules: if retain_modules { Some(modules) } else { None },
884 files: if retain_modules {
885 Some(files.to_vec())
886 } else {
887 None
888 },
889 script_used_packages: plugin_result.script_used_packages,
890 file_hashes,
891 })
892}
893
894fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
899 PackageJson::load(&config.root.join("package.json")).ok()
900}
901
902fn load_workspace_packages(
903 workspaces: &[fallow_config::WorkspaceInfo],
904) -> Vec<LoadedWorkspacePackage<'_>> {
905 workspaces
906 .iter()
907 .filter_map(|ws| {
908 PackageJson::load(&ws.root.join("package.json"))
909 .ok()
910 .map(|pkg| (ws, pkg))
911 })
912 .collect()
913}
914
915fn analyze_all_scripts(
916 config: &ResolvedConfig,
917 workspaces: &[fallow_config::WorkspaceInfo],
918 root_pkg: Option<&PackageJson>,
919 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
920 plugin_result: &mut plugins::AggregatedPluginResult,
921) {
922 let mut all_dep_names: Vec<String> = Vec::new();
923 if let Some(pkg) = root_pkg {
924 all_dep_names.extend(pkg.all_dependency_names());
925 }
926 for (_, ws_pkg) in workspace_pkgs {
927 all_dep_names.extend(ws_pkg.all_dependency_names());
928 }
929 all_dep_names.sort_unstable();
930 all_dep_names.dedup();
931 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
932 let mut all_script_names: FxHashSet<String> = FxHashSet::default();
933 if let Some(pkg) = root_pkg
934 && let Some(ref pkg_scripts) = pkg.scripts
935 {
936 all_script_names.extend(pkg_scripts.keys().cloned());
937 }
938 for (_, ws_pkg) in workspace_pkgs {
939 if let Some(ref ws_scripts) = ws_pkg.scripts {
940 all_script_names.extend(ws_scripts.keys().cloned());
941 }
942 }
943
944 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
945 if config.root.join("node_modules").is_dir() {
946 nm_roots.push(&config.root);
947 }
948 for ws in workspaces {
949 if ws.root.join("node_modules").is_dir() {
950 nm_roots.push(&ws.root);
951 }
952 }
953 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
954
955 if let Some(pkg) = root_pkg
956 && let Some(ref pkg_scripts) = pkg.scripts
957 {
958 let scripts_to_analyze = if config.production {
959 scripts::filter_production_scripts(pkg_scripts)
960 } else {
961 pkg_scripts.clone()
962 };
963 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
964 let script_analysis = scripts::analyze_scripts_with_dependency_context(
965 &scripts_to_analyze,
966 &config.root,
967 &bin_map,
968 &all_dep_set,
969 &script_names,
970 );
971 plugin_result.script_used_packages = script_analysis.used_packages;
972
973 for config_file in &script_analysis.config_files {
974 plugin_result
975 .discovered_always_used
976 .push((config_file.clone(), "scripts".to_string()));
977 }
978 for entry in &script_analysis.entry_files {
979 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
980 plugin_result
981 .entry_patterns
982 .push((plugins::PathRule::new(pat), "scripts".to_string()));
983 }
984 }
985 }
986 use rayon::prelude::*;
987 type WsScriptOut = (
988 Vec<String>,
989 Vec<(String, String)>,
990 Vec<(plugins::PathRule, String)>,
991 );
992 let ws_results: Vec<WsScriptOut> = workspace_pkgs
993 .par_iter()
994 .map(|(ws, ws_pkg)| {
995 let mut used_packages = Vec::new();
996 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
997 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
998 if let Some(ref ws_scripts) = ws_pkg.scripts {
999 let scripts_to_analyze = if config.production {
1000 scripts::filter_production_scripts(ws_scripts)
1001 } else {
1002 ws_scripts.clone()
1003 };
1004 let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1005 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1006 &scripts_to_analyze,
1007 &ws.root,
1008 &bin_map,
1009 &all_dep_set,
1010 &script_names,
1011 );
1012 used_packages.extend(ws_analysis.used_packages);
1013
1014 let ws_prefix = ws
1015 .root
1016 .strip_prefix(&config.root)
1017 .unwrap_or(&ws.root)
1018 .to_string_lossy();
1019 for config_file in &ws_analysis.config_files {
1020 discovered_always_used
1021 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1022 }
1023 for entry in &ws_analysis.entry_files {
1024 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1025 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1026 }
1027 }
1028 }
1029 (used_packages, discovered_always_used, entry_patterns)
1030 })
1031 .collect();
1032 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1033 plugin_result.script_used_packages.extend(used_packages);
1034 plugin_result
1035 .discovered_always_used
1036 .extend(discovered_always_used);
1037 plugin_result.entry_patterns.extend(entry_patterns);
1038 }
1039
1040 let ci_analysis =
1041 scripts::ci::analyze_ci_files(&config.root, &bin_map, &all_dep_set, &all_script_names);
1042 plugin_result
1043 .script_used_packages
1044 .extend(ci_analysis.used_packages);
1045 for entry in &ci_analysis.entry_files {
1046 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1047 plugin_result
1048 .entry_patterns
1049 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1050 }
1051 }
1052 plugin_result
1053 .entry_point_roles
1054 .entry("scripts".to_string())
1055 .or_insert(EntryPointRole::Support);
1056}
1057
1058fn discover_all_entry_points(
1060 config: &ResolvedConfig,
1061 files: &[discover::DiscoveredFile],
1062 workspaces: &[fallow_config::WorkspaceInfo],
1063 root_pkg: Option<&PackageJson>,
1064 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1065 plugin_result: &plugins::AggregatedPluginResult,
1066) -> discover::CategorizedEntryPoints {
1067 let mut entry_points = discover::CategorizedEntryPoints::default();
1068 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1069 config,
1070 files,
1071 root_pkg,
1072 workspaces.is_empty(),
1073 );
1074
1075 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1076 workspace_pkgs
1077 .iter()
1078 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1079 .collect();
1080
1081 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1082 .par_iter()
1083 .map(|ws| {
1084 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1085 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1086 })
1087 .collect();
1088 let mut skipped_entries = rustc_hash::FxHashMap::default();
1089 entry_points.extend_runtime(root_discovery.entries);
1090 for (path, count) in root_discovery.skipped_entries {
1091 *skipped_entries.entry(path).or_insert(0) += count;
1092 }
1093 let mut ws_entries = Vec::new();
1094 for workspace in workspace_discovery {
1095 ws_entries.extend(workspace.entries);
1096 for (path, count) in workspace.skipped_entries {
1097 *skipped_entries.entry(path).or_insert(0) += count;
1098 }
1099 }
1100 discover::warn_skipped_entry_summary(&skipped_entries);
1101 entry_points.extend_runtime(ws_entries);
1102
1103 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1104 entry_points.extend(plugin_entries);
1105
1106 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1107 entry_points.extend_runtime(infra_entries);
1108
1109 if !config.dynamically_loaded.is_empty() {
1110 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1111 entry_points.extend_runtime(dynamic_entries);
1112 }
1113
1114 entry_points.dedup()
1115}
1116
1117fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1119 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1120 for ep in entry_points {
1121 let category = match &ep.source {
1122 discover::EntryPointSource::PackageJsonMain
1123 | discover::EntryPointSource::PackageJsonModule
1124 | discover::EntryPointSource::PackageJsonExports
1125 | discover::EntryPointSource::PackageJsonBin
1126 | discover::EntryPointSource::PackageJsonScript => "package.json",
1127 discover::EntryPointSource::Plugin { .. } => "plugin",
1128 discover::EntryPointSource::TestFile => "test file",
1129 discover::EntryPointSource::DefaultIndex => "default index",
1130 discover::EntryPointSource::ManualEntry => "manual entry",
1131 discover::EntryPointSource::InfrastructureConfig => "config",
1132 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1133 };
1134 *counts.entry(category.to_string()).or_insert(0) += 1;
1135 }
1136 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1137 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1138 results::EntryPointSummary {
1139 total: entry_points.len(),
1140 by_source,
1141 }
1142}
1143
1144fn append_package_file_asset_patterns(
1145 result: &mut plugins::AggregatedPluginResult,
1146 prefix: &str,
1147 pkg: &PackageJson,
1148) {
1149 let prefix = prefix.trim_matches('/');
1150 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1151 let pattern = if prefix.is_empty() {
1152 pattern
1153 } else {
1154 format!("{prefix}/{pattern}")
1155 };
1156 result
1157 .discovered_always_used
1158 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1159 }
1160}
1161
1162fn append_workspace_package_file_asset_patterns(
1163 result: &mut plugins::AggregatedPluginResult,
1164 config: &ResolvedConfig,
1165 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1166) {
1167 for (ws, ws_pkg) in workspace_pkgs {
1168 let ws_prefix = ws
1169 .root
1170 .strip_prefix(&config.root)
1171 .unwrap_or(&ws.root)
1172 .to_string_lossy()
1173 .replace('\\', "/");
1174 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1175 }
1176}
1177
1178fn run_plugins(
1180 config: &ResolvedConfig,
1181 files: &[discover::DiscoveredFile],
1182 workspaces: &[fallow_config::WorkspaceInfo],
1183 root_pkg: Option<&PackageJson>,
1184 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1185) -> plugins::AggregatedPluginResult {
1186 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1187 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1188 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1189 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1190 .iter()
1191 .map(std::path::PathBuf::as_path)
1192 .collect();
1193
1194 let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1195 registry.run_with_search_roots(
1196 pkg,
1197 &config.root,
1198 &file_paths,
1199 &root_config_search_root_refs,
1200 config.production,
1201 )
1202 });
1203 if let Some(pkg) = root_pkg {
1204 append_package_file_asset_patterns(&mut result, "", pkg);
1205 }
1206
1207 if workspaces.is_empty() {
1208 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1209 return result;
1210 }
1211
1212 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1213
1214 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1215 result.active_plugins.iter().map(String::as_str).collect();
1216
1217 let precompiled_matchers = registry.precompile_config_matchers();
1218 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1219
1220 let ws_results: Vec<_> = workspace_pkgs
1221 .par_iter()
1222 .zip(workspace_relative_files.par_iter())
1223 .filter_map(|((ws, ws_pkg), relative_files)| {
1224 let ws_result = registry.run_workspace_fast(
1225 ws_pkg,
1226 &ws.root,
1227 &config.root,
1228 &precompiled_matchers,
1229 relative_files,
1230 &root_active_plugins,
1231 config.production,
1232 );
1233 if ws_result.active_plugins.is_empty() {
1234 return None;
1235 }
1236 let ws_prefix = ws
1237 .root
1238 .strip_prefix(&config.root)
1239 .unwrap_or(&ws.root)
1240 .to_string_lossy()
1241 .into_owned();
1242 Some((ws_result, ws_prefix))
1243 })
1244 .collect();
1245
1246 for (mut ws_result, ws_prefix) in ws_results {
1247 ws_result.apply_workspace_prefix(&ws_prefix);
1248 ws_result.config_patterns.clear();
1249 ws_result.script_used_packages.clear();
1250 result.merge_into(ws_result);
1251 }
1252
1253 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1254
1255 result
1256}
1257
1258fn gate_auto_import_entry_patterns(
1264 result: &mut plugins::AggregatedPluginResult,
1265 config: &ResolvedConfig,
1266 workspaces: &[fallow_config::WorkspaceInfo],
1267) {
1268 if !config.auto_imports {
1269 return;
1270 }
1271 if !result.active_plugins.iter().any(|name| name == "nuxt") {
1272 return;
1273 }
1274 let components_custom = plugins::nuxt::config_declares_components(&config.root)
1275 || workspaces
1276 .iter()
1277 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1278 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1279 || workspaces
1280 .iter()
1281 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1282 result.entry_patterns.retain(|(rule, plugin)| {
1283 if plugin != "nuxt" {
1284 return true;
1285 }
1286 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
1287 return false;
1288 }
1289 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
1290 return false;
1291 }
1292 true
1293 });
1294}
1295
1296fn bucket_files_by_workspace(
1297 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1298 file_paths: &[std::path::PathBuf],
1299) -> Vec<Vec<(std::path::PathBuf, String)>> {
1300 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1301
1302 for file_path in file_paths {
1303 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1304 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1305 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1306 break;
1307 }
1308 }
1309 }
1310
1311 buckets
1312}
1313
1314fn collect_config_search_roots(
1315 root: &Path,
1316 file_paths: &[std::path::PathBuf],
1317) -> Vec<std::path::PathBuf> {
1318 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1319 roots.insert(root.to_path_buf());
1320
1321 for file_path in file_paths {
1322 let mut current = file_path.parent();
1323 while let Some(dir) = current {
1324 if !dir.starts_with(root) {
1325 break;
1326 }
1327 roots.insert(dir.to_path_buf());
1328 if dir == root {
1329 break;
1330 }
1331 current = dir.parent();
1332 }
1333 }
1334
1335 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1336 roots_vec.sort();
1337 roots_vec
1338}
1339
1340#[deprecated(
1346 since = "2.76.0",
1347 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead (build a `DeadCodeOptions { analysis: AnalysisOptions { root, ..default() }, ..default() }`). See docs/fallow-core-migration.md and ADR-008."
1348)]
1349pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1350 let config = default_config(root);
1351 #[expect(
1352 deprecated,
1353 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1354 )]
1355 analyze_with_usages(&config)
1356}
1357
1358pub fn config_for_project(
1366 root: &Path,
1367 config_path: Option<&Path>,
1368) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1369 let user_config = if let Some(path) = config_path {
1370 Some((
1371 fallow_config::FallowConfig::load(path)
1372 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1373 path.to_path_buf(),
1374 ))
1375 } else {
1376 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1377 };
1378
1379 let config = match user_config {
1380 Some((mut config, path)) => {
1381 let dead_code_production = config
1382 .production
1383 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1384 config.production = dead_code_production.into();
1385 config
1386 .validate_resolved_boundaries(root)
1387 .map_err(|errors| {
1388 let joined = errors
1389 .iter()
1390 .map(ToString::to_string)
1391 .collect::<Vec<_>>()
1392 .join("\n - ");
1393 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
1394 })?;
1395 (
1396 config.resolve(
1397 root.to_path_buf(),
1398 fallow_config::OutputFormat::Human,
1399 num_cpus(),
1400 false,
1401 true, None, ),
1404 Some(path),
1405 )
1406 }
1407 None => (
1408 fallow_config::FallowConfig::default().resolve(
1409 root.to_path_buf(),
1410 fallow_config::OutputFormat::Human,
1411 num_cpus(),
1412 false,
1413 true,
1414 None,
1415 ),
1416 None,
1417 ),
1418 };
1419
1420 Ok(config)
1421}
1422
1423pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1434 config_for_project(root, None).map_or_else(
1435 |_| {
1436 fallow_config::FallowConfig::default().resolve(
1437 root.to_path_buf(),
1438 fallow_config::OutputFormat::Human,
1439 num_cpus(),
1440 false,
1441 true,
1442 None,
1443 )
1444 },
1445 |(config, _)| config,
1446 )
1447}
1448
1449fn num_cpus() -> usize {
1450 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::{
1456 bucket_files_by_workspace, collect_config_search_roots,
1457 format_undeclared_workspace_warning, warn_undeclared_workspaces,
1458 };
1459 use std::path::{Path, PathBuf};
1460
1461 use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1462
1463 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1464 WorkspaceDiagnostic::new(
1465 root,
1466 root.join(relative),
1467 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1468 )
1469 }
1470
1471 #[test]
1472 fn undeclared_workspace_warning_is_singular_for_one_path() {
1473 let root = Path::new("/repo");
1474 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1475 .expect("warning should be rendered");
1476
1477 assert_eq!(
1478 warning,
1479 "1 directory with package.json is not declared as a workspace: packages/api. Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
1480 );
1481 }
1482
1483 #[test]
1484 fn undeclared_workspace_warning_summarizes_many_paths() {
1485 let root = PathBuf::from("/repo");
1486 let diagnostics = [
1487 "examples/a",
1488 "examples/b",
1489 "examples/c",
1490 "examples/d",
1491 "examples/e",
1492 "examples/f",
1493 ]
1494 .into_iter()
1495 .map(|path| diag(&root, path))
1496 .collect::<Vec<_>>();
1497
1498 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1499 .expect("warning should be rendered");
1500
1501 assert_eq!(
1502 warning,
1503 "6 directories with package.json are not declared as workspaces: examples/a, examples/b, examples/c, examples/d, examples/e (and 1 more). Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
1504 );
1505 }
1506
1507 #[test]
1508 fn collect_config_search_roots_includes_file_ancestors_once() {
1509 let root = PathBuf::from("/repo");
1510 let search_roots = collect_config_search_roots(
1511 &root,
1512 &[
1513 root.join("apps/query/src/main.ts"),
1514 root.join("packages/shared/lib/index.ts"),
1515 ],
1516 );
1517
1518 assert_eq!(
1519 search_roots,
1520 vec![
1521 root.clone(),
1522 root.join("apps"),
1523 root.join("apps/query"),
1524 root.join("apps/query/src"),
1525 root.join("packages"),
1526 root.join("packages/shared"),
1527 root.join("packages/shared/lib"),
1528 ]
1529 );
1530 }
1531
1532 #[test]
1533 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1534 let root = PathBuf::from("/repo");
1535 let ui = fallow_config::WorkspaceInfo {
1536 root: root.join("apps/ui"),
1537 name: "ui".to_string(),
1538 is_internal_dependency: false,
1539 };
1540 let api = fallow_config::WorkspaceInfo {
1541 root: root.join("apps/api"),
1542 name: "api".to_string(),
1543 is_internal_dependency: false,
1544 };
1545 let workspace_pkgs = vec![
1546 (
1547 &ui,
1548 fallow_config::PackageJson {
1549 name: Some("ui".to_string()),
1550 ..Default::default()
1551 },
1552 ),
1553 (
1554 &api,
1555 fallow_config::PackageJson {
1556 name: Some("api".to_string()),
1557 ..Default::default()
1558 },
1559 ),
1560 ];
1561 let files = vec![
1562 root.join("apps/ui/vite.config.ts"),
1563 root.join("apps/ui/src/main.ts"),
1564 root.join("apps/api/src/server.ts"),
1565 root.join("tools/build.ts"),
1566 ];
1567
1568 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1569
1570 assert_eq!(
1571 buckets[0],
1572 vec![
1573 (
1574 root.join("apps/ui/vite.config.ts"),
1575 "vite.config.ts".to_string()
1576 ),
1577 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1578 ]
1579 );
1580 assert_eq!(
1581 buckets[1],
1582 vec![(
1583 root.join("apps/api/src/server.ts"),
1584 "src/server.ts".to_string()
1585 )]
1586 );
1587 }
1588
1589 #[test]
1590 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1591 let dir = tempfile::tempdir().expect("create temp dir");
1592 let pkg_good = dir.path().join("packages").join("good");
1593 let pkg_bad = dir.path().join("packages").join("bad");
1594 std::fs::create_dir_all(&pkg_good).unwrap();
1595 std::fs::create_dir_all(&pkg_bad).unwrap();
1596 std::fs::write(
1597 dir.path().join("package.json"),
1598 r#"{"workspaces": ["packages/*"]}"#,
1599 )
1600 .unwrap();
1601 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1602 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1603
1604 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1605 dir.path(),
1606 &globset::GlobSet::empty(),
1607 )
1608 .expect("root package.json is valid");
1609 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1610 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1611
1612 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1613
1614 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1615 let mut malformed = 0;
1616 let mut undeclared_for_bad = 0;
1617 for diag in &diagnostics {
1618 if matches!(
1619 diag.kind,
1620 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1621 ) && diag.path.ends_with("bad")
1622 {
1623 malformed += 1;
1624 }
1625 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1626 && diag.path.ends_with("bad")
1627 {
1628 undeclared_for_bad += 1;
1629 }
1630 }
1631 assert_eq!(
1632 malformed, 1,
1633 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1634 );
1635 assert_eq!(
1636 undeclared_for_bad, 0,
1637 "warn_undeclared_workspaces must NOT re-flag a path that already \
1638 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1639 );
1640 }
1641}