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(
350 since = "2.90.0",
351 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code and `compute_complexity` instead. NOTE: this combined LSP-only typed surface is not exposed externally. See docs/fallow-core-migration.md and ADR-008."
352)]
353pub fn analyze_with_usages_and_complexity(
354 config: &ResolvedConfig,
355) -> Result<AnalysisOutput, FallowError> {
356 analyze_full(config, false, true, true, true)
357}
358
359#[deprecated(
365 since = "2.76.0",
366 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 dead-code --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
367)]
368pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
369 analyze_full(config, true, false, false, false)
370}
371
372#[deprecated(
381 since = "2.76.0",
382 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."
383)]
384pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
385 analyze_full(config, false, false, false, false)
386}
387
388#[deprecated(
398 since = "2.76.0",
399 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."
400)]
401pub fn analyze_retaining_modules(
402 config: &ResolvedConfig,
403 need_complexity: bool,
404 retain_graph: bool,
405) -> Result<AnalysisOutput, FallowError> {
406 analyze_full(config, retain_graph, false, need_complexity, true)
407}
408
409#[allow(
420 clippy::too_many_lines,
421 reason = "pipeline orchestration stays easier to audit in one place"
422)]
423#[deprecated(
424 since = "2.76.0",
425 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."
426)]
427pub fn analyze_with_parse_result(
428 config: &ResolvedConfig,
429 modules: &[extract::ModuleInfo],
430) -> Result<AnalysisOutput, FallowError> {
431 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
432 let pipeline_start = Instant::now();
433
434 let show_progress = !config.quiet
435 && std::io::IsTerminal::is_terminal(&std::io::stderr())
436 && matches!(
437 config.output,
438 fallow_config::OutputFormat::Human
439 | fallow_config::OutputFormat::Compact
440 | fallow_config::OutputFormat::Markdown
441 );
442 let progress = progress::AnalysisProgress::new(show_progress);
443
444 if !config.root.join("node_modules").is_dir() {
445 tracing::warn!(
446 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
447 );
448 }
449
450 let t = Instant::now();
451 let workspaces_vec = discover_workspaces(&config.root);
452 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
453 if !workspaces_vec.is_empty() {
454 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
455 }
456
457 warn_undeclared_workspaces(
458 &config.root,
459 &workspaces_vec,
460 &config.ignore_patterns,
461 config.quiet,
462 );
463 let root_pkg = load_root_package_json(config);
464 let discovery_hidden_dir_scopes =
465 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
466
467 let t = Instant::now();
468 progress.set_stage("discovering files...");
469 let discovered_files =
470 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
471 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
472
473 let project = project::ProjectState::new(discovered_files, workspaces_vec);
474 let files = project.files();
475 let workspaces = project.workspaces();
476 let workspace_pkgs = load_workspace_packages(workspaces);
477
478 let t = Instant::now();
479 progress.set_stage("detecting plugins...");
480 let mut plugin_result = run_plugins(
481 config,
482 files,
483 workspaces,
484 root_pkg.as_ref(),
485 &workspace_pkgs,
486 );
487 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
488
489 let t = Instant::now();
490 analyze_all_scripts(
491 config,
492 workspaces,
493 root_pkg.as_ref(),
494 &workspace_pkgs,
495 &mut plugin_result,
496 );
497 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
498
499 let t = Instant::now();
500 let entry_points = discover_all_entry_points(
501 config,
502 files,
503 workspaces,
504 root_pkg.as_ref(),
505 &workspace_pkgs,
506 &plugin_result,
507 );
508 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
509
510 let ep_summary = summarize_entry_points(&entry_points.all);
511
512 let t = Instant::now();
513 progress.set_stage("resolving imports...");
514 let mut resolved = resolve::resolve_all_imports(
515 modules,
516 files,
517 workspaces,
518 &plugin_result.active_plugins,
519 &plugin_result.path_aliases,
520 &plugin_result.auto_imports,
521 &plugin_result.scss_include_paths,
522 &plugin_result.static_dir_mappings,
523 &config.root,
524 &config.resolve.conditions,
525 );
526 external_style_usage::augment_external_style_package_usage(
527 &mut resolved,
528 config,
529 workspaces,
530 &plugin_result,
531 );
532 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
533
534 let t = Instant::now();
535 progress.set_stage("building module graph...");
536 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
537 &resolved,
538 &entry_points.all,
539 &entry_points.runtime,
540 &entry_points.test,
541 files,
542 );
543 credit_package_path_references(&mut graph, modules);
544 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
545 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
546
547 let t = Instant::now();
548 progress.set_stage("analyzing...");
549 #[expect(
550 deprecated,
551 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
552 )]
553 let mut result = analyze::find_dead_code_full(
554 &graph,
555 config,
556 &resolved,
557 Some(&plugin_result),
558 workspaces,
559 modules,
560 false,
561 );
562 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
563 progress.finish();
564
565 result.entry_point_summary = Some(ep_summary);
566
567 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
568
569 tracing::debug!(
570 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
571 │ discover files: {:>8.1}ms ({} files)\n\
572 │ workspaces: {:>8.1}ms\n\
573 │ plugins: {:>8.1}ms\n\
574 │ script analysis: {:>8.1}ms\n\
575 │ parse/extract: SKIPPED (reused {} modules)\n\
576 │ entry points: {:>8.1}ms ({} entries)\n\
577 │ resolve imports: {:>8.1}ms\n\
578 │ build graph: {:>8.1}ms\n\
579 │ analyze: {:>8.1}ms\n\
580 │ ────────────────────────────────────────────\n\
581 │ TOTAL: {:>8.1}ms\n\
582 └─────────────────────────────────────────────────",
583 discover_ms,
584 files.len(),
585 workspaces_ms,
586 plugins_ms,
587 scripts_ms,
588 modules.len(),
589 entry_points_ms,
590 entry_points.all.len(),
591 resolve_ms,
592 graph_ms,
593 analyze_ms,
594 total_ms,
595 );
596
597 let timings = Some(PipelineTimings {
598 discover_files_ms: discover_ms,
599 file_count: files.len(),
600 workspaces_ms,
601 workspace_count: workspaces.len(),
602 plugins_ms,
603 script_analysis_ms: scripts_ms,
604 parse_extract_ms: 0.0, parse_cpu_ms: 0.0, module_count: modules.len(),
607 cache_hits: 0,
608 cache_misses: 0,
609 cache_update_ms: 0.0,
610 entry_points_ms,
611 entry_point_count: entry_points.all.len(),
612 resolve_imports_ms: resolve_ms,
613 build_graph_ms: graph_ms,
614 analyze_ms,
615 duplication_ms: None,
616 total_ms,
617 });
618
619 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
620 .iter()
621 .filter_map(|module| {
622 files
623 .get(module.file_id.0 as usize)
624 .map(|file| (file.path.clone(), module.content_hash))
625 })
626 .collect();
627
628 Ok(AnalysisOutput {
629 results: result,
630 timings,
631 graph: Some(graph),
632 modules: None,
633 files: None,
634 script_used_packages: plugin_result.script_used_packages.clone(),
635 file_hashes,
636 })
637}
638
639#[expect(
640 clippy::unnecessary_wraps,
641 reason = "Result kept for future error handling"
642)]
643#[expect(
644 clippy::too_many_lines,
645 reason = "main pipeline function; sequential phases are held together for clarity"
646)]
647fn analyze_full(
648 config: &ResolvedConfig,
649 retain: bool,
650 collect_usages: bool,
651 need_complexity: bool,
652 retain_modules: bool,
653) -> Result<AnalysisOutput, FallowError> {
654 let _span = tracing::info_span!("fallow_analyze").entered();
655 let pipeline_start = Instant::now();
656
657 let show_progress = !config.quiet
658 && std::io::IsTerminal::is_terminal(&std::io::stderr())
659 && matches!(
660 config.output,
661 fallow_config::OutputFormat::Human
662 | fallow_config::OutputFormat::Compact
663 | fallow_config::OutputFormat::Markdown
664 );
665 let progress = progress::AnalysisProgress::new(show_progress);
666
667 if !config.root.join("node_modules").is_dir() {
668 tracing::warn!(
669 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
670 );
671 }
672
673 let t = Instant::now();
674 let workspaces_vec = discover_workspaces(&config.root);
675 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
676 if !workspaces_vec.is_empty() {
677 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
678 }
679
680 warn_undeclared_workspaces(
681 &config.root,
682 &workspaces_vec,
683 &config.ignore_patterns,
684 config.quiet,
685 );
686 let root_pkg = load_root_package_json(config);
687 let discovery_hidden_dir_scopes =
688 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
689
690 let t = Instant::now();
691 progress.set_stage("discovering files...");
692 let discovered_files =
693 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
694 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
695
696 let project = project::ProjectState::new(discovered_files, workspaces_vec);
697 let files = project.files();
698 let workspaces = project.workspaces();
699 let workspace_pkgs = load_workspace_packages(workspaces);
700
701 let t = Instant::now();
702 progress.set_stage("detecting plugins...");
703 let mut plugin_result = run_plugins(
704 config,
705 files,
706 workspaces,
707 root_pkg.as_ref(),
708 &workspace_pkgs,
709 );
710 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
711
712 let t = Instant::now();
713 analyze_all_scripts(
714 config,
715 workspaces,
716 root_pkg.as_ref(),
717 &workspace_pkgs,
718 &mut plugin_result,
719 );
720 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
721
722 let t = Instant::now();
723 progress.set_stage(&format!("parsing {} files...", files.len()));
724 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
725 let mut cache_store = if config.no_cache {
726 None
727 } else {
728 cache::CacheStore::load(
729 &config.cache_dir,
730 config.cache_config_hash,
731 cache_max_size_bytes,
732 )
733 };
734
735 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
736 let modules = parse_result.modules;
737 let cache_hits = parse_result.cache_hits;
738 let cache_misses = parse_result.cache_misses;
739 let parse_cpu_ms = parse_result.parse_cpu_ms;
740 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
741
742 let t = Instant::now();
743 if !config.no_cache {
744 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
745 update_cache(store, &modules, files);
746 if let Err(e) = store.save(
747 &config.cache_dir,
748 config.cache_config_hash,
749 cache_max_size_bytes,
750 ) {
751 tracing::warn!("Failed to save cache: {e}");
752 }
753 }
754 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
755
756 let t = Instant::now();
757 let entry_points = discover_all_entry_points(
758 config,
759 files,
760 workspaces,
761 root_pkg.as_ref(),
762 &workspace_pkgs,
763 &plugin_result,
764 );
765 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
766
767 let t = Instant::now();
768 progress.set_stage("resolving imports...");
769 let mut resolved = resolve::resolve_all_imports(
770 &modules,
771 files,
772 workspaces,
773 &plugin_result.active_plugins,
774 &plugin_result.path_aliases,
775 &plugin_result.auto_imports,
776 &plugin_result.scss_include_paths,
777 &plugin_result.static_dir_mappings,
778 &config.root,
779 &config.resolve.conditions,
780 );
781 external_style_usage::augment_external_style_package_usage(
782 &mut resolved,
783 config,
784 workspaces,
785 &plugin_result,
786 );
787 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
788
789 let t = Instant::now();
790 progress.set_stage("building module graph...");
791 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
792 &resolved,
793 &entry_points.all,
794 &entry_points.runtime,
795 &entry_points.test,
796 files,
797 );
798 credit_package_path_references(&mut graph, &modules);
799 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
800 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
801
802 let ep_summary = summarize_entry_points(&entry_points.all);
803
804 let t = Instant::now();
805 progress.set_stage("analyzing...");
806 #[expect(
807 deprecated,
808 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
809 )]
810 let mut result = analyze::find_dead_code_full(
811 &graph,
812 config,
813 &resolved,
814 Some(&plugin_result),
815 workspaces,
816 &modules,
817 collect_usages,
818 );
819 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
820 progress.finish();
821
822 result.entry_point_summary = Some(ep_summary);
823
824 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
825
826 let cache_summary = if cache_hits > 0 {
827 format!(" ({cache_hits} cached, {cache_misses} parsed)")
828 } else {
829 String::new()
830 };
831
832 tracing::debug!(
833 "\n┌─ Pipeline Profile ─────────────────────────────\n\
834 │ discover files: {:>8.1}ms ({} files)\n\
835 │ workspaces: {:>8.1}ms\n\
836 │ plugins: {:>8.1}ms\n\
837 │ script analysis: {:>8.1}ms\n\
838 │ parse/extract: {:>8.1}ms ({} modules{})\n\
839 │ cache update: {:>8.1}ms\n\
840 │ entry points: {:>8.1}ms ({} entries)\n\
841 │ resolve imports: {:>8.1}ms\n\
842 │ build graph: {:>8.1}ms\n\
843 │ analyze: {:>8.1}ms\n\
844 │ ────────────────────────────────────────────\n\
845 │ TOTAL: {:>8.1}ms\n\
846 └─────────────────────────────────────────────────",
847 discover_ms,
848 files.len(),
849 workspaces_ms,
850 plugins_ms,
851 scripts_ms,
852 parse_ms,
853 modules.len(),
854 cache_summary,
855 cache_ms,
856 entry_points_ms,
857 entry_points.all.len(),
858 resolve_ms,
859 graph_ms,
860 analyze_ms,
861 total_ms,
862 );
863
864 let timings = if retain {
865 Some(PipelineTimings {
866 discover_files_ms: discover_ms,
867 file_count: files.len(),
868 workspaces_ms,
869 workspace_count: workspaces.len(),
870 plugins_ms,
871 script_analysis_ms: scripts_ms,
872 parse_extract_ms: parse_ms,
873 parse_cpu_ms,
874 module_count: modules.len(),
875 cache_hits,
876 cache_misses,
877 cache_update_ms: cache_ms,
878 entry_points_ms,
879 entry_point_count: entry_points.all.len(),
880 resolve_imports_ms: resolve_ms,
881 build_graph_ms: graph_ms,
882 analyze_ms,
883 duplication_ms: None,
884 total_ms,
885 })
886 } else {
887 None
888 };
889
890 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
891 .iter()
892 .filter_map(|module| {
893 files
894 .get(module.file_id.0 as usize)
895 .map(|file| (file.path.clone(), module.content_hash))
896 })
897 .collect();
898
899 Ok(AnalysisOutput {
900 results: result,
901 timings,
902 graph: if retain { Some(graph) } else { None },
903 modules: if retain_modules { Some(modules) } else { None },
904 files: if retain_modules {
905 Some(files.to_vec())
906 } else {
907 None
908 },
909 script_used_packages: plugin_result.script_used_packages,
910 file_hashes,
911 })
912}
913
914fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
919 PackageJson::load(&config.root.join("package.json")).ok()
920}
921
922fn load_workspace_packages(
923 workspaces: &[fallow_config::WorkspaceInfo],
924) -> Vec<LoadedWorkspacePackage<'_>> {
925 workspaces
926 .iter()
927 .filter_map(|ws| {
928 PackageJson::load(&ws.root.join("package.json"))
929 .ok()
930 .map(|pkg| (ws, pkg))
931 })
932 .collect()
933}
934
935fn analyze_all_scripts(
936 config: &ResolvedConfig,
937 workspaces: &[fallow_config::WorkspaceInfo],
938 root_pkg: Option<&PackageJson>,
939 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
940 plugin_result: &mut plugins::AggregatedPluginResult,
941) {
942 let mut all_dep_names: Vec<String> = Vec::new();
943 if let Some(pkg) = root_pkg {
944 all_dep_names.extend(pkg.all_dependency_names());
945 }
946 for (_, ws_pkg) in workspace_pkgs {
947 all_dep_names.extend(ws_pkg.all_dependency_names());
948 }
949 all_dep_names.sort_unstable();
950 all_dep_names.dedup();
951 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
952 let mut all_script_names: FxHashSet<String> = FxHashSet::default();
953 if let Some(pkg) = root_pkg
954 && let Some(ref pkg_scripts) = pkg.scripts
955 {
956 all_script_names.extend(pkg_scripts.keys().cloned());
957 }
958 for (_, ws_pkg) in workspace_pkgs {
959 if let Some(ref ws_scripts) = ws_pkg.scripts {
960 all_script_names.extend(ws_scripts.keys().cloned());
961 }
962 }
963
964 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
965 if config.root.join("node_modules").is_dir() {
966 nm_roots.push(&config.root);
967 }
968 for ws in workspaces {
969 if ws.root.join("node_modules").is_dir() {
970 nm_roots.push(&ws.root);
971 }
972 }
973 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
974
975 if let Some(pkg) = root_pkg
976 && let Some(ref pkg_scripts) = pkg.scripts
977 {
978 let scripts_to_analyze = if config.production {
979 scripts::filter_production_scripts(pkg_scripts)
980 } else {
981 pkg_scripts.clone()
982 };
983 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
984 let script_analysis = scripts::analyze_scripts_with_dependency_context(
985 &scripts_to_analyze,
986 &config.root,
987 &bin_map,
988 &all_dep_set,
989 &script_names,
990 );
991 plugin_result.script_used_packages = script_analysis.used_packages;
992
993 for config_file in &script_analysis.config_files {
994 plugin_result
995 .discovered_always_used
996 .push((config_file.clone(), "scripts".to_string()));
997 }
998 for entry in &script_analysis.entry_files {
999 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1000 plugin_result
1001 .entry_patterns
1002 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1003 }
1004 }
1005 }
1006 use rayon::prelude::*;
1007 type WsScriptOut = (
1008 Vec<String>,
1009 Vec<(String, String)>,
1010 Vec<(plugins::PathRule, String)>,
1011 );
1012 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1013 .par_iter()
1014 .map(|(ws, ws_pkg)| {
1015 let mut used_packages = Vec::new();
1016 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1017 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1018 if let Some(ref ws_scripts) = ws_pkg.scripts {
1019 let scripts_to_analyze = if config.production {
1020 scripts::filter_production_scripts(ws_scripts)
1021 } else {
1022 ws_scripts.clone()
1023 };
1024 let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1025 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1026 &scripts_to_analyze,
1027 &ws.root,
1028 &bin_map,
1029 &all_dep_set,
1030 &script_names,
1031 );
1032 used_packages.extend(ws_analysis.used_packages);
1033
1034 let ws_prefix = ws
1035 .root
1036 .strip_prefix(&config.root)
1037 .unwrap_or(&ws.root)
1038 .to_string_lossy();
1039 for config_file in &ws_analysis.config_files {
1040 discovered_always_used
1041 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1042 }
1043 for entry in &ws_analysis.entry_files {
1044 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1045 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1046 }
1047 }
1048 }
1049 (used_packages, discovered_always_used, entry_patterns)
1050 })
1051 .collect();
1052 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1053 plugin_result.script_used_packages.extend(used_packages);
1054 plugin_result
1055 .discovered_always_used
1056 .extend(discovered_always_used);
1057 plugin_result.entry_patterns.extend(entry_patterns);
1058 }
1059
1060 let ci_analysis =
1061 scripts::ci::analyze_ci_files(&config.root, &bin_map, &all_dep_set, &all_script_names);
1062 plugin_result
1063 .script_used_packages
1064 .extend(ci_analysis.used_packages);
1065 for entry in &ci_analysis.entry_files {
1066 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1067 plugin_result
1068 .entry_patterns
1069 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1070 }
1071 }
1072 plugin_result
1073 .entry_point_roles
1074 .entry("scripts".to_string())
1075 .or_insert(EntryPointRole::Support);
1076}
1077
1078fn discover_all_entry_points(
1080 config: &ResolvedConfig,
1081 files: &[discover::DiscoveredFile],
1082 workspaces: &[fallow_config::WorkspaceInfo],
1083 root_pkg: Option<&PackageJson>,
1084 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1085 plugin_result: &plugins::AggregatedPluginResult,
1086) -> discover::CategorizedEntryPoints {
1087 let mut entry_points = discover::CategorizedEntryPoints::default();
1088 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1089 config,
1090 files,
1091 root_pkg,
1092 workspaces.is_empty(),
1093 );
1094
1095 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1096 workspace_pkgs
1097 .iter()
1098 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1099 .collect();
1100
1101 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1102 .par_iter()
1103 .map(|ws| {
1104 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1105 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1106 })
1107 .collect();
1108 let mut skipped_entries = rustc_hash::FxHashMap::default();
1109 entry_points.extend_runtime(root_discovery.entries);
1110 for (path, count) in root_discovery.skipped_entries {
1111 *skipped_entries.entry(path).or_insert(0) += count;
1112 }
1113 let mut ws_entries = Vec::new();
1114 for workspace in workspace_discovery {
1115 ws_entries.extend(workspace.entries);
1116 for (path, count) in workspace.skipped_entries {
1117 *skipped_entries.entry(path).or_insert(0) += count;
1118 }
1119 }
1120 discover::warn_skipped_entry_summary(&skipped_entries);
1121 entry_points.extend_runtime(ws_entries);
1122
1123 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1124 entry_points.extend(plugin_entries);
1125
1126 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1127 entry_points.extend_runtime(infra_entries);
1128
1129 if !config.dynamically_loaded.is_empty() {
1130 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1131 entry_points.extend_runtime(dynamic_entries);
1132 }
1133
1134 entry_points.dedup()
1135}
1136
1137fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1139 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1140 for ep in entry_points {
1141 let category = match &ep.source {
1142 discover::EntryPointSource::PackageJsonMain
1143 | discover::EntryPointSource::PackageJsonModule
1144 | discover::EntryPointSource::PackageJsonExports
1145 | discover::EntryPointSource::PackageJsonBin
1146 | discover::EntryPointSource::PackageJsonScript => "package.json",
1147 discover::EntryPointSource::Plugin { .. } => "plugin",
1148 discover::EntryPointSource::TestFile => "test file",
1149 discover::EntryPointSource::DefaultIndex => "default index",
1150 discover::EntryPointSource::ManualEntry => "manual entry",
1151 discover::EntryPointSource::InfrastructureConfig => "config",
1152 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1153 };
1154 *counts.entry(category.to_string()).or_insert(0) += 1;
1155 }
1156 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1157 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1158 results::EntryPointSummary {
1159 total: entry_points.len(),
1160 by_source,
1161 }
1162}
1163
1164fn append_package_file_asset_patterns(
1165 result: &mut plugins::AggregatedPluginResult,
1166 prefix: &str,
1167 pkg: &PackageJson,
1168) {
1169 let prefix = prefix.trim_matches('/');
1170 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1171 let pattern = if prefix.is_empty() {
1172 pattern
1173 } else {
1174 format!("{prefix}/{pattern}")
1175 };
1176 result
1177 .discovered_always_used
1178 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1179 }
1180}
1181
1182fn append_workspace_package_file_asset_patterns(
1183 result: &mut plugins::AggregatedPluginResult,
1184 config: &ResolvedConfig,
1185 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1186) {
1187 for (ws, ws_pkg) in workspace_pkgs {
1188 let ws_prefix = ws
1189 .root
1190 .strip_prefix(&config.root)
1191 .unwrap_or(&ws.root)
1192 .to_string_lossy()
1193 .replace('\\', "/");
1194 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1195 }
1196}
1197
1198fn run_plugins(
1200 config: &ResolvedConfig,
1201 files: &[discover::DiscoveredFile],
1202 workspaces: &[fallow_config::WorkspaceInfo],
1203 root_pkg: Option<&PackageJson>,
1204 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1205) -> plugins::AggregatedPluginResult {
1206 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1207 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1208 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1209 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1210 .iter()
1211 .map(std::path::PathBuf::as_path)
1212 .collect();
1213
1214 let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1215 registry.run_with_search_roots(
1216 pkg,
1217 &config.root,
1218 &file_paths,
1219 &root_config_search_root_refs,
1220 config.production,
1221 )
1222 });
1223 if let Some(pkg) = root_pkg {
1224 append_package_file_asset_patterns(&mut result, "", pkg);
1225 }
1226
1227 if workspaces.is_empty() {
1228 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1229 return result;
1230 }
1231
1232 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1233
1234 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1235 result.active_plugins.iter().map(String::as_str).collect();
1236
1237 let precompiled_matchers = registry.precompile_config_matchers();
1238 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1239
1240 let ws_results: Vec<_> = workspace_pkgs
1241 .par_iter()
1242 .zip(workspace_relative_files.par_iter())
1243 .filter_map(|((ws, ws_pkg), relative_files)| {
1244 let ws_result = registry.run_workspace_fast(
1245 ws_pkg,
1246 &ws.root,
1247 &config.root,
1248 &precompiled_matchers,
1249 relative_files,
1250 &root_active_plugins,
1251 config.production,
1252 );
1253 if ws_result.active_plugins.is_empty() {
1254 return None;
1255 }
1256 let ws_prefix = ws
1257 .root
1258 .strip_prefix(&config.root)
1259 .unwrap_or(&ws.root)
1260 .to_string_lossy()
1261 .into_owned();
1262 Some((ws_result, ws_prefix))
1263 })
1264 .collect();
1265
1266 for (mut ws_result, ws_prefix) in ws_results {
1267 ws_result.apply_workspace_prefix(&ws_prefix);
1268 ws_result.config_patterns.clear();
1269 ws_result.script_used_packages.clear();
1270 result.merge_into(ws_result);
1271 }
1272
1273 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1274
1275 result
1276}
1277
1278fn gate_auto_import_entry_patterns(
1284 result: &mut plugins::AggregatedPluginResult,
1285 config: &ResolvedConfig,
1286 workspaces: &[fallow_config::WorkspaceInfo],
1287) {
1288 if !config.auto_imports {
1289 return;
1290 }
1291 if !result.active_plugins.iter().any(|name| name == "nuxt") {
1292 return;
1293 }
1294 let components_custom = plugins::nuxt::config_declares_components(&config.root)
1295 || workspaces
1296 .iter()
1297 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1298 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1299 || workspaces
1300 .iter()
1301 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1302 result.entry_patterns.retain(|(rule, plugin)| {
1303 if plugin != "nuxt" {
1304 return true;
1305 }
1306 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
1307 return false;
1308 }
1309 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
1310 return false;
1311 }
1312 true
1313 });
1314}
1315
1316fn bucket_files_by_workspace(
1317 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1318 file_paths: &[std::path::PathBuf],
1319) -> Vec<Vec<(std::path::PathBuf, String)>> {
1320 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1321
1322 for file_path in file_paths {
1323 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1324 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1325 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1326 break;
1327 }
1328 }
1329 }
1330
1331 buckets
1332}
1333
1334fn collect_config_search_roots(
1335 root: &Path,
1336 file_paths: &[std::path::PathBuf],
1337) -> Vec<std::path::PathBuf> {
1338 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1339 roots.insert(root.to_path_buf());
1340
1341 for file_path in file_paths {
1342 let mut current = file_path.parent();
1343 while let Some(dir) = current {
1344 if !dir.starts_with(root) {
1345 break;
1346 }
1347 roots.insert(dir.to_path_buf());
1348 if dir == root {
1349 break;
1350 }
1351 current = dir.parent();
1352 }
1353 }
1354
1355 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1356 roots_vec.sort();
1357 roots_vec
1358}
1359
1360#[deprecated(
1366 since = "2.76.0",
1367 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."
1368)]
1369pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1370 let config = default_config(root);
1371 #[expect(
1372 deprecated,
1373 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1374 )]
1375 analyze_with_usages(&config)
1376}
1377
1378pub fn config_for_project(
1386 root: &Path,
1387 config_path: Option<&Path>,
1388) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1389 let user_config = if let Some(path) = config_path {
1390 Some((
1391 fallow_config::FallowConfig::load(path)
1392 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1393 path.to_path_buf(),
1394 ))
1395 } else {
1396 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1397 };
1398
1399 let config = match user_config {
1400 Some((mut config, path)) => {
1401 let dead_code_production = config
1402 .production
1403 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1404 config.production = dead_code_production.into();
1405 config
1406 .validate_resolved_boundaries(root)
1407 .map_err(|errors| {
1408 let joined = errors
1409 .iter()
1410 .map(ToString::to_string)
1411 .collect::<Vec<_>>()
1412 .join("\n - ");
1413 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
1414 })?;
1415 (
1416 config.resolve(
1417 root.to_path_buf(),
1418 fallow_config::OutputFormat::Human,
1419 num_cpus(),
1420 false,
1421 true, None, ),
1424 Some(path),
1425 )
1426 }
1427 None => (
1428 fallow_config::FallowConfig::default().resolve(
1429 root.to_path_buf(),
1430 fallow_config::OutputFormat::Human,
1431 num_cpus(),
1432 false,
1433 true,
1434 None,
1435 ),
1436 None,
1437 ),
1438 };
1439
1440 Ok(config)
1441}
1442
1443pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1454 config_for_project(root, None).map_or_else(
1455 |_| {
1456 fallow_config::FallowConfig::default().resolve(
1457 root.to_path_buf(),
1458 fallow_config::OutputFormat::Human,
1459 num_cpus(),
1460 false,
1461 true,
1462 None,
1463 )
1464 },
1465 |(config, _)| config,
1466 )
1467}
1468
1469fn num_cpus() -> usize {
1470 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475 use super::{
1476 bucket_files_by_workspace, collect_config_search_roots,
1477 format_undeclared_workspace_warning, warn_undeclared_workspaces,
1478 };
1479 use std::path::{Path, PathBuf};
1480
1481 use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1482
1483 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1484 WorkspaceDiagnostic::new(
1485 root,
1486 root.join(relative),
1487 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1488 )
1489 }
1490
1491 #[test]
1492 fn undeclared_workspace_warning_is_singular_for_one_path() {
1493 let root = Path::new("/repo");
1494 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1495 .expect("warning should be rendered");
1496
1497 assert_eq!(
1498 warning,
1499 "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."
1500 );
1501 }
1502
1503 #[test]
1504 fn undeclared_workspace_warning_summarizes_many_paths() {
1505 let root = PathBuf::from("/repo");
1506 let diagnostics = [
1507 "examples/a",
1508 "examples/b",
1509 "examples/c",
1510 "examples/d",
1511 "examples/e",
1512 "examples/f",
1513 ]
1514 .into_iter()
1515 .map(|path| diag(&root, path))
1516 .collect::<Vec<_>>();
1517
1518 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1519 .expect("warning should be rendered");
1520
1521 assert_eq!(
1522 warning,
1523 "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."
1524 );
1525 }
1526
1527 #[test]
1528 fn collect_config_search_roots_includes_file_ancestors_once() {
1529 let root = PathBuf::from("/repo");
1530 let search_roots = collect_config_search_roots(
1531 &root,
1532 &[
1533 root.join("apps/query/src/main.ts"),
1534 root.join("packages/shared/lib/index.ts"),
1535 ],
1536 );
1537
1538 assert_eq!(
1539 search_roots,
1540 vec![
1541 root.clone(),
1542 root.join("apps"),
1543 root.join("apps/query"),
1544 root.join("apps/query/src"),
1545 root.join("packages"),
1546 root.join("packages/shared"),
1547 root.join("packages/shared/lib"),
1548 ]
1549 );
1550 }
1551
1552 #[test]
1553 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1554 let root = PathBuf::from("/repo");
1555 let ui = fallow_config::WorkspaceInfo {
1556 root: root.join("apps/ui"),
1557 name: "ui".to_string(),
1558 is_internal_dependency: false,
1559 };
1560 let api = fallow_config::WorkspaceInfo {
1561 root: root.join("apps/api"),
1562 name: "api".to_string(),
1563 is_internal_dependency: false,
1564 };
1565 let workspace_pkgs = vec![
1566 (
1567 &ui,
1568 fallow_config::PackageJson {
1569 name: Some("ui".to_string()),
1570 ..Default::default()
1571 },
1572 ),
1573 (
1574 &api,
1575 fallow_config::PackageJson {
1576 name: Some("api".to_string()),
1577 ..Default::default()
1578 },
1579 ),
1580 ];
1581 let files = vec![
1582 root.join("apps/ui/vite.config.ts"),
1583 root.join("apps/ui/src/main.ts"),
1584 root.join("apps/api/src/server.ts"),
1585 root.join("tools/build.ts"),
1586 ];
1587
1588 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1589
1590 assert_eq!(
1591 buckets[0],
1592 vec![
1593 (
1594 root.join("apps/ui/vite.config.ts"),
1595 "vite.config.ts".to_string()
1596 ),
1597 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1598 ]
1599 );
1600 assert_eq!(
1601 buckets[1],
1602 vec![(
1603 root.join("apps/api/src/server.ts"),
1604 "src/server.ts".to_string()
1605 )]
1606 );
1607 }
1608
1609 #[test]
1610 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1611 let dir = tempfile::tempdir().expect("create temp dir");
1612 let pkg_good = dir.path().join("packages").join("good");
1613 let pkg_bad = dir.path().join("packages").join("bad");
1614 std::fs::create_dir_all(&pkg_good).unwrap();
1615 std::fs::create_dir_all(&pkg_bad).unwrap();
1616 std::fs::write(
1617 dir.path().join("package.json"),
1618 r#"{"workspaces": ["packages/*"]}"#,
1619 )
1620 .unwrap();
1621 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1622 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1623
1624 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1625 dir.path(),
1626 &globset::GlobSet::empty(),
1627 )
1628 .expect("root package.json is valid");
1629 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1630 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1631
1632 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1633
1634 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1635 let mut malformed = 0;
1636 let mut undeclared_for_bad = 0;
1637 for diag in &diagnostics {
1638 if matches!(
1639 diag.kind,
1640 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1641 ) && diag.path.ends_with("bad")
1642 {
1643 malformed += 1;
1644 }
1645 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1646 && diag.path.ends_with("bad")
1647 {
1648 undeclared_for_bad += 1;
1649 }
1650 }
1651 assert_eq!(
1652 malformed, 1,
1653 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1654 );
1655 assert_eq!(
1656 undeclared_for_bad, 0,
1657 "warn_undeclared_workspaces must NOT re-flag a path that already \
1658 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1659 );
1660 }
1661}