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
922 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
923 if config.root.join("node_modules").is_dir() {
924 nm_roots.push(&config.root);
925 }
926 for ws in workspaces {
927 if ws.root.join("node_modules").is_dir() {
928 nm_roots.push(&ws.root);
929 }
930 }
931 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
932
933 if let Some(pkg) = root_pkg
934 && let Some(ref pkg_scripts) = pkg.scripts
935 {
936 let scripts_to_analyze = if config.production {
937 scripts::filter_production_scripts(pkg_scripts)
938 } else {
939 pkg_scripts.clone()
940 };
941 let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root, &bin_map);
942 plugin_result.script_used_packages = script_analysis.used_packages;
943
944 for config_file in &script_analysis.config_files {
945 plugin_result
946 .discovered_always_used
947 .push((config_file.clone(), "scripts".to_string()));
948 }
949 for entry in &script_analysis.entry_files {
950 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
951 plugin_result
952 .entry_patterns
953 .push((plugins::PathRule::new(pat), "scripts".to_string()));
954 }
955 }
956 }
957 use rayon::prelude::*;
958 type WsScriptOut = (
959 Vec<String>,
960 Vec<(String, String)>,
961 Vec<(plugins::PathRule, String)>,
962 );
963 let ws_results: Vec<WsScriptOut> = workspace_pkgs
964 .par_iter()
965 .map(|(ws, ws_pkg)| {
966 let mut used_packages = Vec::new();
967 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
968 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
969 if let Some(ref ws_scripts) = ws_pkg.scripts {
970 let scripts_to_analyze = if config.production {
971 scripts::filter_production_scripts(ws_scripts)
972 } else {
973 ws_scripts.clone()
974 };
975 let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root, &bin_map);
976 used_packages.extend(ws_analysis.used_packages);
977
978 let ws_prefix = ws
979 .root
980 .strip_prefix(&config.root)
981 .unwrap_or(&ws.root)
982 .to_string_lossy();
983 for config_file in &ws_analysis.config_files {
984 discovered_always_used
985 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
986 }
987 for entry in &ws_analysis.entry_files {
988 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
989 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
990 }
991 }
992 }
993 (used_packages, discovered_always_used, entry_patterns)
994 })
995 .collect();
996 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
997 plugin_result.script_used_packages.extend(used_packages);
998 plugin_result
999 .discovered_always_used
1000 .extend(discovered_always_used);
1001 plugin_result.entry_patterns.extend(entry_patterns);
1002 }
1003
1004 let ci_analysis = scripts::ci::analyze_ci_files(&config.root, &bin_map);
1005 plugin_result
1006 .script_used_packages
1007 .extend(ci_analysis.used_packages);
1008 for entry in &ci_analysis.entry_files {
1009 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1010 plugin_result
1011 .entry_patterns
1012 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1013 }
1014 }
1015 plugin_result
1016 .entry_point_roles
1017 .entry("scripts".to_string())
1018 .or_insert(EntryPointRole::Support);
1019}
1020
1021fn discover_all_entry_points(
1023 config: &ResolvedConfig,
1024 files: &[discover::DiscoveredFile],
1025 workspaces: &[fallow_config::WorkspaceInfo],
1026 root_pkg: Option<&PackageJson>,
1027 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1028 plugin_result: &plugins::AggregatedPluginResult,
1029) -> discover::CategorizedEntryPoints {
1030 let mut entry_points = discover::CategorizedEntryPoints::default();
1031 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1032 config,
1033 files,
1034 root_pkg,
1035 workspaces.is_empty(),
1036 );
1037
1038 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1039 workspace_pkgs
1040 .iter()
1041 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1042 .collect();
1043
1044 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1045 .par_iter()
1046 .map(|ws| {
1047 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1048 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1049 })
1050 .collect();
1051 let mut skipped_entries = rustc_hash::FxHashMap::default();
1052 entry_points.extend_runtime(root_discovery.entries);
1053 for (path, count) in root_discovery.skipped_entries {
1054 *skipped_entries.entry(path).or_insert(0) += count;
1055 }
1056 let mut ws_entries = Vec::new();
1057 for workspace in workspace_discovery {
1058 ws_entries.extend(workspace.entries);
1059 for (path, count) in workspace.skipped_entries {
1060 *skipped_entries.entry(path).or_insert(0) += count;
1061 }
1062 }
1063 discover::warn_skipped_entry_summary(&skipped_entries);
1064 entry_points.extend_runtime(ws_entries);
1065
1066 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1067 entry_points.extend(plugin_entries);
1068
1069 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1070 entry_points.extend_runtime(infra_entries);
1071
1072 if !config.dynamically_loaded.is_empty() {
1073 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1074 entry_points.extend_runtime(dynamic_entries);
1075 }
1076
1077 entry_points.dedup()
1078}
1079
1080fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1082 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1083 for ep in entry_points {
1084 let category = match &ep.source {
1085 discover::EntryPointSource::PackageJsonMain
1086 | discover::EntryPointSource::PackageJsonModule
1087 | discover::EntryPointSource::PackageJsonExports
1088 | discover::EntryPointSource::PackageJsonBin
1089 | discover::EntryPointSource::PackageJsonScript => "package.json",
1090 discover::EntryPointSource::Plugin { .. } => "plugin",
1091 discover::EntryPointSource::TestFile => "test file",
1092 discover::EntryPointSource::DefaultIndex => "default index",
1093 discover::EntryPointSource::ManualEntry => "manual entry",
1094 discover::EntryPointSource::InfrastructureConfig => "config",
1095 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1096 };
1097 *counts.entry(category.to_string()).or_insert(0) += 1;
1098 }
1099 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1100 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1101 results::EntryPointSummary {
1102 total: entry_points.len(),
1103 by_source,
1104 }
1105}
1106
1107fn append_package_file_asset_patterns(
1108 result: &mut plugins::AggregatedPluginResult,
1109 prefix: &str,
1110 pkg: &PackageJson,
1111) {
1112 let prefix = prefix.trim_matches('/');
1113 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1114 let pattern = if prefix.is_empty() {
1115 pattern
1116 } else {
1117 format!("{prefix}/{pattern}")
1118 };
1119 result
1120 .discovered_always_used
1121 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1122 }
1123}
1124
1125fn append_workspace_package_file_asset_patterns(
1126 result: &mut plugins::AggregatedPluginResult,
1127 config: &ResolvedConfig,
1128 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1129) {
1130 for (ws, ws_pkg) in workspace_pkgs {
1131 let ws_prefix = ws
1132 .root
1133 .strip_prefix(&config.root)
1134 .unwrap_or(&ws.root)
1135 .to_string_lossy()
1136 .replace('\\', "/");
1137 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1138 }
1139}
1140
1141fn run_plugins(
1143 config: &ResolvedConfig,
1144 files: &[discover::DiscoveredFile],
1145 workspaces: &[fallow_config::WorkspaceInfo],
1146 root_pkg: Option<&PackageJson>,
1147 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1148) -> plugins::AggregatedPluginResult {
1149 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1150 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1151 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1152 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1153 .iter()
1154 .map(std::path::PathBuf::as_path)
1155 .collect();
1156
1157 let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1158 registry.run_with_search_roots(
1159 pkg,
1160 &config.root,
1161 &file_paths,
1162 &root_config_search_root_refs,
1163 config.production,
1164 )
1165 });
1166 if let Some(pkg) = root_pkg {
1167 append_package_file_asset_patterns(&mut result, "", pkg);
1168 }
1169
1170 if workspaces.is_empty() {
1171 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1172 return result;
1173 }
1174
1175 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1176
1177 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1178 result.active_plugins.iter().map(String::as_str).collect();
1179
1180 let precompiled_matchers = registry.precompile_config_matchers();
1181 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1182
1183 let ws_results: Vec<_> = workspace_pkgs
1184 .par_iter()
1185 .zip(workspace_relative_files.par_iter())
1186 .filter_map(|((ws, ws_pkg), relative_files)| {
1187 let ws_result = registry.run_workspace_fast(
1188 ws_pkg,
1189 &ws.root,
1190 &config.root,
1191 &precompiled_matchers,
1192 relative_files,
1193 &root_active_plugins,
1194 config.production,
1195 );
1196 if ws_result.active_plugins.is_empty() {
1197 return None;
1198 }
1199 let ws_prefix = ws
1200 .root
1201 .strip_prefix(&config.root)
1202 .unwrap_or(&ws.root)
1203 .to_string_lossy()
1204 .into_owned();
1205 Some((ws_result, ws_prefix))
1206 })
1207 .collect();
1208
1209 for (mut ws_result, ws_prefix) in ws_results {
1210 ws_result.apply_workspace_prefix(&ws_prefix);
1211 ws_result.config_patterns.clear();
1212 ws_result.script_used_packages.clear();
1213 result.merge_into(ws_result);
1214 }
1215
1216 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1217
1218 result
1219}
1220
1221fn gate_auto_import_entry_patterns(
1227 result: &mut plugins::AggregatedPluginResult,
1228 config: &ResolvedConfig,
1229 workspaces: &[fallow_config::WorkspaceInfo],
1230) {
1231 if !config.auto_imports {
1232 return;
1233 }
1234 if !result.active_plugins.iter().any(|name| name == "nuxt") {
1235 return;
1236 }
1237 let components_custom = plugins::nuxt::config_declares_components(&config.root)
1238 || workspaces
1239 .iter()
1240 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1241 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1242 || workspaces
1243 .iter()
1244 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1245 result.entry_patterns.retain(|(rule, plugin)| {
1246 if plugin != "nuxt" {
1247 return true;
1248 }
1249 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
1250 return false;
1251 }
1252 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
1253 return false;
1254 }
1255 true
1256 });
1257}
1258
1259fn bucket_files_by_workspace(
1260 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1261 file_paths: &[std::path::PathBuf],
1262) -> Vec<Vec<(std::path::PathBuf, String)>> {
1263 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1264
1265 for file_path in file_paths {
1266 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1267 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1268 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1269 break;
1270 }
1271 }
1272 }
1273
1274 buckets
1275}
1276
1277fn collect_config_search_roots(
1278 root: &Path,
1279 file_paths: &[std::path::PathBuf],
1280) -> Vec<std::path::PathBuf> {
1281 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1282 roots.insert(root.to_path_buf());
1283
1284 for file_path in file_paths {
1285 let mut current = file_path.parent();
1286 while let Some(dir) = current {
1287 if !dir.starts_with(root) {
1288 break;
1289 }
1290 roots.insert(dir.to_path_buf());
1291 if dir == root {
1292 break;
1293 }
1294 current = dir.parent();
1295 }
1296 }
1297
1298 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1299 roots_vec.sort();
1300 roots_vec
1301}
1302
1303#[deprecated(
1309 since = "2.76.0",
1310 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."
1311)]
1312pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1313 let config = default_config(root);
1314 #[expect(
1315 deprecated,
1316 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1317 )]
1318 analyze_with_usages(&config)
1319}
1320
1321pub fn config_for_project(
1329 root: &Path,
1330 config_path: Option<&Path>,
1331) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1332 let user_config = if let Some(path) = config_path {
1333 Some((
1334 fallow_config::FallowConfig::load(path)
1335 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1336 path.to_path_buf(),
1337 ))
1338 } else {
1339 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1340 };
1341
1342 let config = match user_config {
1343 Some((mut config, path)) => {
1344 let dead_code_production = config
1345 .production
1346 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1347 config.production = dead_code_production.into();
1348 config
1349 .validate_resolved_boundaries(root)
1350 .map_err(|errors| {
1351 let joined = errors
1352 .iter()
1353 .map(ToString::to_string)
1354 .collect::<Vec<_>>()
1355 .join("\n - ");
1356 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
1357 })?;
1358 (
1359 config.resolve(
1360 root.to_path_buf(),
1361 fallow_config::OutputFormat::Human,
1362 num_cpus(),
1363 false,
1364 true, None, ),
1367 Some(path),
1368 )
1369 }
1370 None => (
1371 fallow_config::FallowConfig::default().resolve(
1372 root.to_path_buf(),
1373 fallow_config::OutputFormat::Human,
1374 num_cpus(),
1375 false,
1376 true,
1377 None,
1378 ),
1379 None,
1380 ),
1381 };
1382
1383 Ok(config)
1384}
1385
1386pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1397 config_for_project(root, None).map_or_else(
1398 |_| {
1399 fallow_config::FallowConfig::default().resolve(
1400 root.to_path_buf(),
1401 fallow_config::OutputFormat::Human,
1402 num_cpus(),
1403 false,
1404 true,
1405 None,
1406 )
1407 },
1408 |(config, _)| config,
1409 )
1410}
1411
1412fn num_cpus() -> usize {
1413 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::{
1419 bucket_files_by_workspace, collect_config_search_roots,
1420 format_undeclared_workspace_warning, warn_undeclared_workspaces,
1421 };
1422 use std::path::{Path, PathBuf};
1423
1424 use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1425
1426 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1427 WorkspaceDiagnostic::new(
1428 root,
1429 root.join(relative),
1430 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1431 )
1432 }
1433
1434 #[test]
1435 fn undeclared_workspace_warning_is_singular_for_one_path() {
1436 let root = Path::new("/repo");
1437 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1438 .expect("warning should be rendered");
1439
1440 assert_eq!(
1441 warning,
1442 "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."
1443 );
1444 }
1445
1446 #[test]
1447 fn undeclared_workspace_warning_summarizes_many_paths() {
1448 let root = PathBuf::from("/repo");
1449 let diagnostics = [
1450 "examples/a",
1451 "examples/b",
1452 "examples/c",
1453 "examples/d",
1454 "examples/e",
1455 "examples/f",
1456 ]
1457 .into_iter()
1458 .map(|path| diag(&root, path))
1459 .collect::<Vec<_>>();
1460
1461 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1462 .expect("warning should be rendered");
1463
1464 assert_eq!(
1465 warning,
1466 "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."
1467 );
1468 }
1469
1470 #[test]
1471 fn collect_config_search_roots_includes_file_ancestors_once() {
1472 let root = PathBuf::from("/repo");
1473 let search_roots = collect_config_search_roots(
1474 &root,
1475 &[
1476 root.join("apps/query/src/main.ts"),
1477 root.join("packages/shared/lib/index.ts"),
1478 ],
1479 );
1480
1481 assert_eq!(
1482 search_roots,
1483 vec![
1484 root.clone(),
1485 root.join("apps"),
1486 root.join("apps/query"),
1487 root.join("apps/query/src"),
1488 root.join("packages"),
1489 root.join("packages/shared"),
1490 root.join("packages/shared/lib"),
1491 ]
1492 );
1493 }
1494
1495 #[test]
1496 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1497 let root = PathBuf::from("/repo");
1498 let ui = fallow_config::WorkspaceInfo {
1499 root: root.join("apps/ui"),
1500 name: "ui".to_string(),
1501 is_internal_dependency: false,
1502 };
1503 let api = fallow_config::WorkspaceInfo {
1504 root: root.join("apps/api"),
1505 name: "api".to_string(),
1506 is_internal_dependency: false,
1507 };
1508 let workspace_pkgs = vec![
1509 (
1510 &ui,
1511 fallow_config::PackageJson {
1512 name: Some("ui".to_string()),
1513 ..Default::default()
1514 },
1515 ),
1516 (
1517 &api,
1518 fallow_config::PackageJson {
1519 name: Some("api".to_string()),
1520 ..Default::default()
1521 },
1522 ),
1523 ];
1524 let files = vec![
1525 root.join("apps/ui/vite.config.ts"),
1526 root.join("apps/ui/src/main.ts"),
1527 root.join("apps/api/src/server.ts"),
1528 root.join("tools/build.ts"),
1529 ];
1530
1531 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1532
1533 assert_eq!(
1534 buckets[0],
1535 vec![
1536 (
1537 root.join("apps/ui/vite.config.ts"),
1538 "vite.config.ts".to_string()
1539 ),
1540 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1541 ]
1542 );
1543 assert_eq!(
1544 buckets[1],
1545 vec![(
1546 root.join("apps/api/src/server.ts"),
1547 "src/server.ts".to_string()
1548 )]
1549 );
1550 }
1551
1552 #[test]
1553 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1554 let dir = tempfile::tempdir().expect("create temp dir");
1555 let pkg_good = dir.path().join("packages").join("good");
1556 let pkg_bad = dir.path().join("packages").join("bad");
1557 std::fs::create_dir_all(&pkg_good).unwrap();
1558 std::fs::create_dir_all(&pkg_bad).unwrap();
1559 std::fs::write(
1560 dir.path().join("package.json"),
1561 r#"{"workspaces": ["packages/*"]}"#,
1562 )
1563 .unwrap();
1564 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1565 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1566
1567 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1568 dir.path(),
1569 &globset::GlobSet::empty(),
1570 )
1571 .expect("root package.json is valid");
1572 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1573 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1574
1575 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1576
1577 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1578 let mut malformed = 0;
1579 let mut undeclared_for_bad = 0;
1580 for diag in &diagnostics {
1581 if matches!(
1582 diag.kind,
1583 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1584 ) && diag.path.ends_with("bad")
1585 {
1586 malformed += 1;
1587 }
1588 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1589 && diag.path.ends_with("bad")
1590 {
1591 undeclared_for_bad += 1;
1592 }
1593 }
1594 assert_eq!(
1595 malformed, 1,
1596 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1597 );
1598 assert_eq!(
1599 undeclared_for_bad, 0,
1600 "warn_undeclared_workspaces must NOT re-flag a path that already \
1601 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1602 );
1603 }
1604}