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