1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
13#![cfg_attr(
14 test,
15 allow(
16 clippy::unwrap_used,
17 clippy::expect_used,
18 reason = "tests use unwrap and expect to keep fixture setup concise"
19 )
20)]
21
22pub mod analyze;
23pub mod cache;
24pub mod discover;
25pub(crate) mod errors;
26mod external_style_usage;
27pub mod extract;
28pub mod git_env;
29mod package_assets;
30pub mod plugins;
31pub(crate) mod progress;
32pub mod results;
33pub(crate) mod scripts;
34#[doc(hidden)]
37pub use scripts::parse_script;
38pub mod suppress;
39
40pub use fallow_graph::cache as graph_cache;
41pub use fallow_graph::graph;
42pub use fallow_graph::project;
43pub use fallow_graph::resolve;
44
45use std::path::{Path, PathBuf};
46use std::time::Instant;
47
48use errors::FallowError;
49use fallow_config::{
50 EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces_with_diagnostics,
51 find_undeclared_workspaces_with_ignores,
52};
53use fallow_types::trace::PipelineTimings;
54use rayon::prelude::*;
55use results::AnalysisResults;
56use rustc_hash::FxHashSet;
57
58const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
59type LoadedWorkspacePackage = (fallow_config::WorkspaceInfo, PackageJson);
60
61fn record_graph_package_usage(
62 graph: &mut graph::ModuleGraph,
63 package_name: &str,
64 file_id: discover::FileId,
65 is_type_only: bool,
66) {
67 graph
68 .package_usage
69 .entry(package_name.to_owned())
70 .or_default()
71 .push(file_id);
72 if is_type_only {
73 graph
74 .type_only_package_usage
75 .entry(package_name.to_owned())
76 .or_default()
77 .push(file_id);
78 }
79}
80
81fn workspace_package_name<'a>(
82 source: &str,
83 workspace_names: &FxHashSet<&'a str>,
84) -> Option<&'a str> {
85 if !resolve::is_bare_specifier(source) {
86 return None;
87 }
88 let package_name = resolve::extract_package_name(source);
89 workspace_names.get(package_name.as_str()).copied()
90}
91
92fn credit_workspace_package_usage(
93 graph: &mut graph::ModuleGraph,
94 resolved: &[resolve::ResolvedModule],
95 workspaces: &[fallow_config::WorkspaceInfo],
96) {
97 if workspaces.is_empty() {
98 return;
99 }
100
101 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
102 for module in resolved {
103 for import in module.all_resolved_imports() {
104 if matches!(
105 import.target,
106 resolve::ResolveResult::InternalModule(_)
107 | resolve::ResolveResult::CommonJsInternalModule(_)
108 ) && let Some(package_name) =
109 workspace_package_name(&import.info.source, &workspace_names)
110 {
111 record_graph_package_usage(
112 graph,
113 package_name,
114 module.file_id,
115 import.info.is_type_only,
116 );
117 }
118 }
119
120 for re_export in &module.re_exports {
121 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
122 && let Some(package_name) =
123 workspace_package_name(&re_export.info.source, &workspace_names)
124 {
125 record_graph_package_usage(
126 graph,
127 package_name,
128 module.file_id,
129 re_export.info.is_type_only,
130 );
131 }
132 }
133 }
134}
135
136fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
137 for module in modules {
138 for package_name in &module.package_path_references {
139 record_graph_package_usage(graph, package_name, module.file_id, false);
140 }
141 }
142}
143
144#[doc(hidden)]
146pub struct AnalysisOutput {
147 pub results: AnalysisResults,
148 pub timings: Option<PipelineTimings>,
149 pub graph: Option<graph::ModuleGraph>,
150 pub modules: Option<Vec<extract::ModuleInfo>>,
154 pub files: Option<Vec<discover::DiscoveredFile>>,
156 pub script_used_packages: rustc_hash::FxHashSet<String>,
161 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
170}
171
172#[derive(Debug, Clone, Copy)]
175#[doc(hidden)]
176pub struct AnalysisParseMetrics {
177 parse_ms: f64,
178 cache_ms: f64,
179 cache_hits: usize,
180 cache_misses: usize,
181 parse_cpu_ms: f64,
182}
183
184fn update_cache(
186 store: &mut cache::CacheStore,
187 modules: &[extract::ModuleInfo],
188 files: &[discover::DiscoveredFile],
189) -> bool {
190 let mut dirty = false;
191 for module in modules {
192 if let Some(file) = files.get(module.file_id.0 as usize) {
193 let fingerprint = file_fingerprint(&file.path);
194 if let Some(cached) = store.get_by_path_only(&file.path)
195 && cached.content_hash == module.content_hash
196 {
197 if cached.source_fingerprint() != fingerprint {
198 let preserved_last_access = cached.last_access_secs;
199 let mut refreshed = cache::module_to_cached(module, fingerprint);
200 refreshed.last_access_secs = preserved_last_access;
201 store.insert(&file.path, refreshed);
202 dirty = true;
203 }
204 continue;
205 }
206 store.insert(&file.path, cache::module_to_cached(module, fingerprint));
207 dirty = true;
208 }
209 }
210 let removed_stale_paths = store.retain_paths(files);
211 dirty || removed_stale_paths
212}
213
214#[must_use]
222fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
223 config
224 .cache_max_size_mb
225 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
226 (mb as usize).saturating_mul(1024 * 1024)
227 })
228}
229
230fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
232 std::fs::metadata(path).map_or(
233 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
234 |metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
235 )
236}
237
238fn format_undeclared_workspace_warning(
239 root: &Path,
240 undeclared: &[fallow_config::WorkspaceDiagnostic],
241) -> Option<String> {
242 if undeclared.is_empty() {
243 return None;
244 }
245
246 let preview = undeclared
247 .iter()
248 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
249 .map(|diag| {
250 diag.path
251 .strip_prefix(root)
252 .unwrap_or(&diag.path)
253 .display()
254 .to_string()
255 .replace('\\', "/")
256 })
257 .collect::<Vec<_>>();
258 let remaining = undeclared
259 .len()
260 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
261 let tail = if remaining > 0 {
262 format!(" (and {remaining} more)")
263 } else {
264 String::new()
265 };
266 let noun = if undeclared.len() == 1 {
267 "directory with package.json is"
268 } else {
269 "directories with package.json are"
270 };
271 let guidance = if undeclared.len() == 1 {
272 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
273 } else {
274 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
275 };
276
277 Some(format!(
278 "{} {} not declared as {}: {}{}. {}",
279 undeclared.len(),
280 noun,
281 if undeclared.len() == 1 {
282 "a workspace"
283 } else {
284 "workspaces"
285 },
286 preview.join(", "),
287 tail,
288 guidance
289 ))
290}
291
292fn warn_undeclared_workspaces(
293 root: &Path,
294 workspaces_vec: &[fallow_config::WorkspaceInfo],
295 ignore_patterns: &globset::GlobSet,
296 quiet: bool,
297) {
298 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
299 if undeclared.is_empty() {
300 return;
301 }
302
303 let existing = fallow_config::workspace_diagnostics_for(root);
304 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
305 .iter()
306 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
307 .collect();
308 let undeclared: Vec<_> = undeclared
309 .into_iter()
310 .filter(|diag| {
311 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
312 !already_flagged.contains(&canonical)
313 })
314 .collect();
315 if undeclared.is_empty() {
316 return;
317 }
318
319 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
320
321 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
322 tracing::warn!("{message}");
323 }
324}
325
326#[doc(hidden)]
332#[deprecated(
333 since = "2.76.0",
334 note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
335)]
336pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
337 let output = analyze_full(config, false, false, false, false)?;
338 Ok(output.results)
339}
340
341#[doc(hidden)]
347#[deprecated(
348 since = "2.76.0",
349 note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md."
350)]
351pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
352 let output = analyze_full(config, false, true, false, false)?;
353 Ok(output.results)
354}
355
356#[doc(hidden)]
362#[deprecated(
363 since = "2.76.0",
364 note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. 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."
365)]
366pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
367 analyze_full(config, true, false, false, false)
368}
369
370#[doc(hidden)]
380#[deprecated(
381 since = "2.76.0",
382 note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md."
383)]
384pub fn analyze_retaining_modules(
385 config: &ResolvedConfig,
386 need_complexity: bool,
387 retain_graph: bool,
388) -> Result<AnalysisOutput, FallowError> {
389 analyze_full(config, retain_graph, false, need_complexity, true)
390}
391
392fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
393 let show_progress = !config.quiet
394 && std::io::IsTerminal::is_terminal(&std::io::stderr())
395 && matches!(
396 config.output,
397 fallow_config::OutputFormat::Human
398 | fallow_config::OutputFormat::Compact
399 | fallow_config::OutputFormat::Markdown
400 );
401 progress::AnalysisProgress::new(show_progress)
402}
403
404fn warn_missing_node_modules(config: &ResolvedConfig) {
405 if config.root.join("node_modules").is_dir() {
406 return;
407 }
408 if fallow_config::is_deno_without_node_modules(&config.root) {
409 return;
410 }
411
412 tracing::warn!(
413 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
414 );
415}
416
417fn discover_analysis_workspaces(
418 config: &ResolvedConfig,
419) -> Result<(Vec<fallow_config::WorkspaceInfo>, f64), FallowError> {
420 let t = Instant::now();
421 let (workspaces, diagnostics) =
422 discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
423 .map_err(|error| FallowError::config(error.to_string()))?;
424 fallow_config::stash_workspace_diagnostics(&config.root, diagnostics);
425 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
426 if !workspaces.is_empty() {
427 tracing::info!(count = workspaces.len(), "workspaces discovered");
428 }
429
430 warn_undeclared_workspaces(
431 &config.root,
432 &workspaces,
433 &config.ignore_patterns,
434 config.quiet,
435 );
436
437 Ok((workspaces, workspaces_ms))
438}
439
440struct AnalysisSetup {
444 progress: progress::AnalysisProgress,
445 project: project::ProjectState,
446 root_pkg: Option<PackageJson>,
447 config_candidates: Vec<std::path::PathBuf>,
452 discover_ms: f64,
453 workspaces_ms: f64,
454}
455
456#[derive(Debug, Clone)]
462#[doc(hidden)]
463pub struct AnalysisDiscovery {
464 files: Vec<discover::DiscoveredFile>,
465 workspaces: Vec<fallow_config::WorkspaceInfo>,
466 root_pkg: Option<PackageJson>,
467 config_candidates: Vec<std::path::PathBuf>,
468 discover_ms: f64,
469 workspaces_ms: f64,
470}
471
472impl AnalysisDiscovery {
473 #[must_use]
475 pub fn from_parts(
476 files: Vec<discover::DiscoveredFile>,
477 workspaces: Vec<fallow_config::WorkspaceInfo>,
478 root_pkg: Option<PackageJson>,
479 config_candidates: Vec<std::path::PathBuf>,
480 discover_ms: f64,
481 workspaces_ms: f64,
482 ) -> Self {
483 Self {
484 files,
485 workspaces,
486 root_pkg,
487 config_candidates,
488 discover_ms,
489 workspaces_ms,
490 }
491 }
492
493 #[must_use]
495 fn files(&self) -> &[discover::DiscoveredFile] {
496 &self.files
497 }
498
499 #[must_use]
501 pub fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
502 &self.workspaces
503 }
504
505 #[must_use]
507 pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
508 self.files
509 }
510}
511
512pub(crate) struct AnalysisSession<'a> {
517 config: &'a ResolvedConfig,
518 pipeline_start: Instant,
519 progress: progress::AnalysisProgress,
520 project: project::ProjectState,
521 root_pkg: Option<PackageJson>,
522 config_candidates: Vec<std::path::PathBuf>,
523 discover_ms: f64,
524 workspaces_ms: f64,
525}
526
527impl<'a> AnalysisSession<'a> {
528 fn new(config: &'a ResolvedConfig) -> Result<Self, FallowError> {
529 let pipeline_start = Instant::now();
530 let AnalysisSetup {
531 progress,
532 project,
533 root_pkg,
534 config_candidates,
535 discover_ms,
536 workspaces_ms,
537 } = run_analysis_setup(config)?;
538
539 Ok(Self {
540 config,
541 pipeline_start,
542 progress,
543 project,
544 root_pkg,
545 config_candidates,
546 discover_ms,
547 workspaces_ms,
548 })
549 }
550
551 fn files(&self) -> &[discover::DiscoveredFile] {
552 self.project.files()
553 }
554
555 fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
556 self.project.workspaces()
557 }
558
559 fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage> {
560 load_workspace_packages(self.workspaces())
561 }
562
563 fn run_plugins_and_scripts(
564 &self,
565 workspace_pkgs: &[LoadedWorkspacePackage],
566 ) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
567 run_plugins_and_scripts(&PluginScriptInput {
568 config: self.config,
569 progress: &self.progress,
570 files: self.files(),
571 workspaces: self.workspaces(),
572 root_pkg: self.root_pkg.as_ref(),
573 workspace_pkgs,
574 config_candidates: &self.config_candidates,
575 })
576 }
577
578 fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
579 PreludeTimings {
580 discover_ms: self.discover_ms,
581 workspaces_ms: self.workspaces_ms,
582 plugins_ms,
583 scripts_ms,
584 }
585 }
586
587 fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
588 let t = Instant::now();
589 self.progress
590 .set_stage(&format!("parsing {} files...", self.files().len()));
591 parse_analysis_modules(self.config, self.files(), need_complexity, t)
592 }
593
594 fn run_owned_core(
595 &self,
596 workspace_pkgs: &[LoadedWorkspacePackage],
597 plugin_result: &plugins::AggregatedPluginResult,
598 mut modules: Vec<extract::ModuleInfo>,
599 collect_usages: bool,
600 ) -> OwnedAnalysisCore {
601 let shared = AnalysisCoreSharedInput {
602 config: self.config,
603 progress: &self.progress,
604 files: self.files(),
605 workspaces: self.workspaces(),
606 root_pkg: self.root_pkg.as_ref(),
607 workspace_pkgs,
608 plugin_result,
609 };
610
611 let entry_points = discover_analysis_entry_points(&shared);
612 let (resolved, graph) =
613 if let Some(hit) = try_load_analysis_graph_cache(&shared, &entry_points, &modules) {
614 (
615 TimedResolvedModules {
616 project: hit.project,
617 elapsed_ms: 0.0,
618 },
619 TimedGraph {
620 graph: hit.graph,
621 elapsed_ms: hit.elapsed_ms,
622 },
623 )
624 } else {
625 let resolved = resolve_analysis_imports_timed(&shared, &modules);
626 let graph =
627 build_analysis_graph_timed(&shared, &resolved.project, &entry_points, &modules);
628 (resolved, graph)
629 };
630 release_resolution_payloads(&mut modules);
631 let analysis = analyze_dead_code_timed(
632 &shared,
633 &graph.graph,
634 &resolved.project.modules,
635 &modules,
636 collect_usages,
637 entry_points.summary,
638 );
639
640 OwnedAnalysisCore {
641 result: analysis.result,
642 graph: graph.graph,
643 modules,
644 entry_point_count: entry_points.count,
645 entry_points_ms: entry_points.elapsed_ms,
646 resolve_ms: resolved.elapsed_ms,
647 graph_ms: graph.elapsed_ms,
648 analyze_ms: analysis.elapsed_ms,
649 }
650 }
651
652 fn run_full(
653 self,
654 retain: bool,
655 collect_usages: bool,
656 need_complexity: bool,
657 retain_modules: bool,
658 ) -> Result<AnalysisOutput, FallowError> {
659 let workspace_pkgs = self.load_workspace_packages();
660 let (plugin_result, plugins_ms, scripts_ms) =
661 self.run_plugins_and_scripts(&workspace_pkgs)?;
662
663 let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
664 let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
665 self.progress.finish();
666
667 let profile = full_analysis_pipeline_profile(
668 &self.prelude_timings(plugins_ms, scripts_ms),
669 self.pipeline_start,
670 self.files(),
671 self.workspaces(),
672 &core,
673 &metrics,
674 );
675 trace_pipeline_profile(&profile);
676
677 Ok(assemble_full_output(
678 core,
679 plugin_result,
680 &profile,
681 self.files(),
682 retain,
683 retain_modules,
684 ))
685 }
686}
687
688fn run_analysis_setup(config: &ResolvedConfig) -> Result<AnalysisSetup, FallowError> {
691 let progress = new_analysis_progress(config);
692 warn_missing_node_modules(config);
693
694 let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config)?;
695 let root_pkg = load_root_package_json(config);
696 let discovery_hidden_dir_scopes =
697 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
698
699 let t = Instant::now();
700 progress.set_stage("discovering files...");
701 let (discovered_files, config_candidates) =
702 discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
703 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
704
705 let project = project::ProjectState::new(discovered_files, workspaces_vec);
706
707 Ok(AnalysisSetup {
708 progress,
709 project,
710 root_pkg,
711 config_candidates,
712 discover_ms,
713 workspaces_ms,
714 })
715}
716
717struct PluginScriptInput<'a> {
719 config: &'a ResolvedConfig,
720 progress: &'a progress::AnalysisProgress,
721 files: &'a [discover::DiscoveredFile],
722 workspaces: &'a [fallow_config::WorkspaceInfo],
723 root_pkg: Option<&'a PackageJson>,
724 workspace_pkgs: &'a [LoadedWorkspacePackage],
725 config_candidates: &'a [std::path::PathBuf],
726}
727
728fn run_plugins_and_scripts(
731 input: &PluginScriptInput<'_>,
732) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
733 let t = Instant::now();
734 input.progress.set_stage("detecting plugins...");
735 let mut plugin_result = run_plugins(
736 input.config,
737 input.files,
738 input.workspaces,
739 input.root_pkg,
740 input.workspace_pkgs,
741 input.config_candidates,
742 )?;
743 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
744
745 let t = Instant::now();
746 analyze_all_scripts(
747 input.config,
748 input.workspaces,
749 input.root_pkg,
750 input.workspace_pkgs,
751 &mut plugin_result,
752 );
753 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
754
755 Ok((plugin_result, plugins_ms, scripts_ms))
756}
757
758#[derive(Debug, Clone, Copy)]
760#[doc(hidden)]
761pub struct DeadCodePreludeTimings {
762 pub discover_ms: f64,
763 pub workspaces_ms: f64,
764 pub plugins_ms: f64,
765 pub scripts_ms: f64,
766}
767
768#[doc(hidden)]
773pub struct DeadCodeBackendPrelude<'a> {
774 config: &'a ResolvedConfig,
775 pipeline_start: Instant,
776 progress: progress::AnalysisProgress,
777 discovery: AnalysisDiscovery,
778 workspace_pkgs: Vec<LoadedWorkspacePackage>,
779 plugin_result: plugins::AggregatedPluginResult,
780 plugins_ms: f64,
781 scripts_ms: f64,
782}
783
784impl DeadCodeBackendPrelude<'_> {
785 #[must_use]
786 pub fn timings(&self) -> DeadCodePreludeTimings {
787 DeadCodePreludeTimings {
788 discover_ms: self.discovery.discover_ms,
789 workspaces_ms: self.discovery.workspaces_ms,
790 plugins_ms: self.plugins_ms,
791 scripts_ms: self.scripts_ms,
792 }
793 }
794
795 #[must_use]
796 pub fn elapsed_ms(&self) -> f64 {
797 self.pipeline_start.elapsed().as_secs_f64() * 1000.0
798 }
799
800 #[must_use]
801 pub fn script_used_packages(&self) -> FxHashSet<String> {
802 self.plugin_result.script_used_packages.clone()
803 }
804
805 pub fn finish(&self) {
806 self.progress.finish();
807 }
808}
809
810#[doc(hidden)]
812pub struct DeadCodeEntryPoints {
813 inner: TimedEntryPoints,
814}
815
816impl DeadCodeEntryPoints {
817 #[must_use]
818 pub fn count(&self) -> usize {
819 self.inner.count
820 }
821
822 #[must_use]
823 pub fn elapsed_ms(&self) -> f64 {
824 self.inner.elapsed_ms
825 }
826}
827
828#[doc(hidden)]
830pub struct DeadCodeResolvedModules {
831 pub project: resolve::ResolvedProject,
832 pub elapsed_ms: f64,
833}
834
835#[doc(hidden)]
837pub struct DeadCodeGraphRun {
838 pub graph: graph::ModuleGraph,
839 pub elapsed_ms: f64,
840}
841
842#[doc(hidden)]
844pub struct DeadCodeDetectorRun {
845 pub results: AnalysisResults,
846 pub elapsed_ms: f64,
847}
848
849pub fn prepare_dead_code_backend_prelude(
855 config: &ResolvedConfig,
856 discovery: AnalysisDiscovery,
857) -> Result<DeadCodeBackendPrelude<'_>, FallowError> {
858 let progress = new_analysis_progress(config);
859 let pipeline_start = Instant::now();
860 let workspace_pkgs = load_workspace_packages(&discovery.workspaces);
861 let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
862 config,
863 progress: &progress,
864 files: discovery.files(),
865 workspaces: &discovery.workspaces,
866 root_pkg: discovery.root_pkg.as_ref(),
867 workspace_pkgs: &workspace_pkgs,
868 config_candidates: &discovery.config_candidates,
869 })?;
870
871 Ok(DeadCodeBackendPrelude {
872 config,
873 pipeline_start,
874 progress,
875 discovery,
876 workspace_pkgs,
877 plugin_result,
878 plugins_ms,
879 scripts_ms,
880 })
881}
882
883#[must_use]
885pub fn discover_dead_code_entry_points(
886 prelude: &DeadCodeBackendPrelude<'_>,
887) -> DeadCodeEntryPoints {
888 let shared = prelude.shared_input();
889 DeadCodeEntryPoints {
890 inner: discover_analysis_entry_points(&shared),
891 }
892}
893
894#[must_use]
896pub fn try_load_dead_code_graph_cache(
897 prelude: &DeadCodeBackendPrelude<'_>,
898 entry_points: &DeadCodeEntryPoints,
899 modules: &[extract::ModuleInfo],
900) -> Option<(DeadCodeResolvedModules, DeadCodeGraphRun)> {
901 let shared = prelude.shared_input();
902 try_load_analysis_graph_cache(&shared, &entry_points.inner, modules).map(|hit| {
903 (
904 DeadCodeResolvedModules {
905 project: hit.project,
906 elapsed_ms: 0.0,
907 },
908 DeadCodeGraphRun {
909 graph: hit.graph,
910 elapsed_ms: hit.elapsed_ms,
911 },
912 )
913 })
914}
915
916#[must_use]
918pub fn resolve_dead_code_imports(
919 prelude: &DeadCodeBackendPrelude<'_>,
920 modules: &[extract::ModuleInfo],
921) -> DeadCodeResolvedModules {
922 let shared = prelude.shared_input();
923 let resolved = resolve_analysis_imports_timed(&shared, modules);
924 DeadCodeResolvedModules {
925 project: resolved.project,
926 elapsed_ms: resolved.elapsed_ms,
927 }
928}
929
930#[must_use]
932pub fn build_dead_code_graph(
933 prelude: &DeadCodeBackendPrelude<'_>,
934 project: &resolve::ResolvedProject,
935 entry_points: &DeadCodeEntryPoints,
936 modules: &[extract::ModuleInfo],
937) -> DeadCodeGraphRun {
938 let shared = prelude.shared_input();
939 let graph = build_analysis_graph_timed(&shared, project, &entry_points.inner, modules);
940 DeadCodeGraphRun {
941 graph: graph.graph,
942 elapsed_ms: graph.elapsed_ms,
943 }
944}
945
946#[must_use]
948pub fn run_dead_code_detectors(
949 prelude: &DeadCodeBackendPrelude<'_>,
950 graph: &graph::ModuleGraph,
951 resolved: &[resolve::ResolvedModule],
952 modules: &[extract::ModuleInfo],
953 collect_usages: bool,
954 entry_points: &DeadCodeEntryPoints,
955) -> DeadCodeDetectorRun {
956 let shared = prelude.shared_input();
957 let analysis = analyze_dead_code_timed(
958 &shared,
959 graph,
960 resolved,
961 modules,
962 collect_usages,
963 entry_points.inner.summary.clone(),
964 );
965 DeadCodeDetectorRun {
966 results: analysis.result,
967 elapsed_ms: analysis.elapsed_ms,
968 }
969}
970
971impl<'a> DeadCodeBackendPrelude<'a> {
972 fn shared_input(&'a self) -> AnalysisCoreSharedInput<'a> {
973 AnalysisCoreSharedInput {
974 config: self.config,
975 progress: &self.progress,
976 files: self.discovery.files(),
977 workspaces: &self.discovery.workspaces,
978 root_pkg: self.discovery.root_pkg.as_ref(),
979 workspace_pkgs: &self.workspace_pkgs,
980 plugin_result: &self.plugin_result,
981 }
982 }
983}
984
985struct PreludeMetrics {
988 discover_ms: f64,
989 workspaces_ms: f64,
990 plugins_ms: f64,
991 scripts_ms: f64,
992 total_ms: f64,
993 file_count: usize,
994 workspace_count: usize,
995 module_count: usize,
996}
997
998#[expect(
1000 clippy::struct_field_names,
1001 reason = "timings are all milliseconds; the _ms suffix is the unit"
1002)]
1003struct PreludeTimings {
1004 discover_ms: f64,
1005 workspaces_ms: f64,
1006 plugins_ms: f64,
1007 scripts_ms: f64,
1008}
1009
1010fn prelude_metrics(
1013 timings: &PreludeTimings,
1014 pipeline_start: Instant,
1015 files: &[discover::DiscoveredFile],
1016 workspaces: &[fallow_config::WorkspaceInfo],
1017 module_count: usize,
1018) -> PreludeMetrics {
1019 PreludeMetrics {
1020 discover_ms: timings.discover_ms,
1021 workspaces_ms: timings.workspaces_ms,
1022 plugins_ms: timings.plugins_ms,
1023 scripts_ms: timings.scripts_ms,
1024 total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
1025 file_count: files.len(),
1026 workspace_count: workspaces.len(),
1027 module_count,
1028 }
1029}
1030
1031struct AnalysisCoreSharedInput<'a> {
1032 config: &'a ResolvedConfig,
1033 progress: &'a progress::AnalysisProgress,
1034 files: &'a [discover::DiscoveredFile],
1035 workspaces: &'a [fallow_config::WorkspaceInfo],
1036 root_pkg: Option<&'a PackageJson>,
1037 workspace_pkgs: &'a [LoadedWorkspacePackage],
1038 plugin_result: &'a plugins::AggregatedPluginResult,
1039}
1040
1041struct TimedEntryPoints {
1042 entry_points: discover::CategorizedEntryPoints,
1043 summary: results::EntryPointSummary,
1044 count: usize,
1045 elapsed_ms: f64,
1046}
1047
1048struct TimedResolvedModules {
1049 project: resolve::ResolvedProject,
1050 elapsed_ms: f64,
1051}
1052
1053struct TimedGraph {
1054 graph: graph::ModuleGraph,
1055 elapsed_ms: f64,
1056}
1057
1058struct GraphCacheHit {
1059 graph: graph::ModuleGraph,
1060 project: resolve::ResolvedProject,
1061 elapsed_ms: f64,
1062}
1063
1064#[derive(Clone, Copy)]
1065struct DiscoverAllEntryPointsInput<'a> {
1066 config: &'a ResolvedConfig,
1067 files: &'a [discover::DiscoveredFile],
1068 workspaces: &'a [fallow_config::WorkspaceInfo],
1069 root_pkg: Option<&'a PackageJson>,
1070 workspace_pkgs: &'a [LoadedWorkspacePackage],
1071 plugin_result: &'a plugins::AggregatedPluginResult,
1072}
1073
1074struct TimedAnalysis {
1075 result: AnalysisResults,
1076 elapsed_ms: f64,
1077}
1078
1079fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1080 let t = Instant::now();
1081 let entry_points = discover_all_entry_points(DiscoverAllEntryPointsInput {
1082 config: input.config,
1083 files: input.files,
1084 workspaces: input.workspaces,
1085 root_pkg: input.root_pkg,
1086 workspace_pkgs: input.workspace_pkgs,
1087 plugin_result: input.plugin_result,
1088 });
1089 let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1090 let summary = summarize_entry_points(&entry_points.all);
1091 let count = entry_points.all.len();
1092
1093 TimedEntryPoints {
1094 entry_points,
1095 summary,
1096 count,
1097 elapsed_ms,
1098 }
1099}
1100
1101fn try_load_analysis_graph_cache(
1102 input: &AnalysisCoreSharedInput<'_>,
1103 entry_points: &TimedEntryPoints,
1104 modules: &[extract::ModuleInfo],
1105) -> Option<GraphCacheHit> {
1106 if input.config.no_cache {
1107 return None;
1108 }
1109
1110 let t = Instant::now();
1111 input.progress.set_stage("loading module graph cache...");
1112 let current = build_graph_cache_manifest(
1113 input.config,
1114 input.plugin_result,
1115 &entry_points.entry_points,
1116 input.files,
1117 );
1118 let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir)?;
1119 if store.manifest.matches_inputs(¤t) {
1120 let project = graph_cache::restore_resolved_project(
1121 &input.config.root,
1122 modules,
1123 input.files,
1124 &store.resolved_project,
1125 )?;
1126 tracing::debug!("Graph cache hit: skipping import resolution and graph build");
1127
1128 return Some(GraphCacheHit {
1129 graph: store.graph,
1130 project,
1131 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1132 });
1133 }
1134
1135 if !store.manifest.matches_resolution_inputs(¤t) {
1136 return None;
1137 }
1138
1139 let project = graph_cache::restore_resolved_project(
1140 &input.config.root,
1141 modules,
1142 input.files,
1143 &store.resolved_project,
1144 )?;
1145 tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
1146 let graph = build_analysis_graph_timed(input, &project, entry_points, modules);
1147
1148 Some(GraphCacheHit {
1149 graph: graph.graph,
1150 project,
1151 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1152 })
1153}
1154
1155fn resolve_analysis_imports_timed(
1156 input: &AnalysisCoreSharedInput<'_>,
1157 modules: &[extract::ModuleInfo],
1158) -> TimedResolvedModules {
1159 let t = Instant::now();
1160 input.progress.set_stage("resolving imports...");
1161 let project = resolve_analysis_imports(
1162 modules,
1163 input.files,
1164 input.workspaces,
1165 input.plugin_result,
1166 input.config,
1167 );
1168 TimedResolvedModules {
1169 project,
1170 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1171 }
1172}
1173
1174fn build_analysis_graph_timed(
1175 input: &AnalysisCoreSharedInput<'_>,
1176 project: &resolve::ResolvedProject,
1177 entry_points: &TimedEntryPoints,
1178 modules: &[extract::ModuleInfo],
1179) -> TimedGraph {
1180 let t = Instant::now();
1181 input.progress.set_stage("building module graph...");
1182 let graph = build_analysis_graph(&BuildAnalysisGraphInput {
1183 config: input.config,
1184 plugin_result: input.plugin_result,
1185 project,
1186 entry_points: &entry_points.entry_points,
1187 files: input.files,
1188 modules,
1189 workspaces: input.workspaces,
1190 });
1191 TimedGraph {
1192 graph,
1193 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1194 }
1195}
1196
1197fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1198 for module in modules {
1199 module.release_resolution_payload();
1200 }
1201}
1202
1203fn analyze_dead_code_timed(
1204 input: &AnalysisCoreSharedInput<'_>,
1205 graph: &graph::ModuleGraph,
1206 resolved: &[resolve::ResolvedModule],
1207 modules: &[extract::ModuleInfo],
1208 collect_usages: bool,
1209 entry_point_summary: results::EntryPointSummary,
1210) -> TimedAnalysis {
1211 let t = Instant::now();
1212 input.progress.set_stage("analyzing...");
1213 #[expect(
1214 deprecated,
1215 reason = "Core-internal policy keeps workspace path-dependency calls while warning external fallow-core consumers"
1216 )]
1217 let mut result = analyze::find_dead_code_full(
1218 graph,
1219 input.config,
1220 resolved,
1221 Some(input.plugin_result),
1222 input.workspaces,
1223 modules,
1224 collect_usages,
1225 );
1226 result.entry_point_summary = Some(entry_point_summary);
1227 TimedAnalysis {
1228 result,
1229 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1230 }
1231}
1232
1233fn analyze_full(
1234 config: &ResolvedConfig,
1235 retain: bool,
1236 collect_usages: bool,
1237 need_complexity: bool,
1238 retain_modules: bool,
1239) -> Result<AnalysisOutput, FallowError> {
1240 let _span = tracing::info_span!("fallow_analyze").entered();
1241 AnalysisSession::new(config)?.run_full(retain, collect_usages, need_complexity, retain_modules)
1242}
1243
1244fn full_analysis_pipeline_profile(
1245 timings: &PreludeTimings,
1246 pipeline_start: Instant,
1247 files: &[discover::DiscoveredFile],
1248 workspaces: &[fallow_config::WorkspaceInfo],
1249 core: &OwnedAnalysisCore,
1250 metrics: &ParseMetrics,
1251) -> PipelineProfile {
1252 let prelude = prelude_metrics(
1253 timings,
1254 pipeline_start,
1255 files,
1256 workspaces,
1257 core.modules.len(),
1258 );
1259 full_pipeline_profile(&prelude, core, metrics)
1260}
1261
1262fn assemble_full_output(
1265 core: OwnedAnalysisCore,
1266 plugin_result: plugins::AggregatedPluginResult,
1267 profile: &PipelineProfile,
1268 files: &[discover::DiscoveredFile],
1269 retain: bool,
1270 retain_modules: bool,
1271) -> AnalysisOutput {
1272 let file_hashes = collect_file_hashes(&core.modules, files);
1273 AnalysisOutput {
1274 results: core.result,
1275 timings: retained_pipeline_timings(retain, profile),
1276 graph: if retain { Some(core.graph) } else { None },
1277 modules: if retain_modules {
1278 Some(core.modules)
1279 } else {
1280 None
1281 },
1282 files: if retain_modules {
1283 Some(files.to_vec())
1284 } else {
1285 None
1286 },
1287 script_used_packages: plugin_result.script_used_packages,
1288 file_hashes,
1289 }
1290}
1291
1292struct OwnedAnalysisCore {
1295 result: AnalysisResults,
1296 graph: graph::ModuleGraph,
1297 modules: Vec<extract::ModuleInfo>,
1298 entry_point_count: usize,
1299 entry_points_ms: f64,
1300 resolve_ms: f64,
1301 graph_ms: f64,
1302 analyze_ms: f64,
1303}
1304
1305fn full_pipeline_profile(
1307 prelude: &PreludeMetrics,
1308 core: &OwnedAnalysisCore,
1309 parse: &ParseMetrics,
1310) -> PipelineProfile {
1311 PipelineProfile {
1312 discover_ms: prelude.discover_ms,
1313 workspaces_ms: prelude.workspaces_ms,
1314 plugins_ms: prelude.plugins_ms,
1315 scripts_ms: prelude.scripts_ms,
1316 parse_ms: parse.parse_ms,
1317 cache_ms: parse.cache_ms,
1318 entry_points_ms: core.entry_points_ms,
1319 resolve_ms: core.resolve_ms,
1320 graph_ms: core.graph_ms,
1321 analyze_ms: core.analyze_ms,
1322 total_ms: prelude.total_ms,
1323 file_count: prelude.file_count,
1324 workspace_count: prelude.workspace_count,
1325 module_count: prelude.module_count,
1326 entry_point_count: core.entry_point_count,
1327 cache_hits: parse.cache_hits,
1328 cache_misses: parse.cache_misses,
1329 parse_cpu_ms: parse.parse_cpu_ms,
1330 }
1331}
1332
1333#[derive(Clone, Copy)]
1334struct PipelineProfile {
1335 discover_ms: f64,
1336 workspaces_ms: f64,
1337 plugins_ms: f64,
1338 scripts_ms: f64,
1339 parse_ms: f64,
1340 cache_ms: f64,
1341 entry_points_ms: f64,
1342 resolve_ms: f64,
1343 graph_ms: f64,
1344 analyze_ms: f64,
1345 total_ms: f64,
1346 file_count: usize,
1347 workspace_count: usize,
1348 module_count: usize,
1349 entry_point_count: usize,
1350 cache_hits: usize,
1351 cache_misses: usize,
1352 parse_cpu_ms: f64,
1353}
1354
1355struct AnalysisParseOutput {
1356 modules: Vec<extract::ModuleInfo>,
1357 metrics: ParseMetrics,
1358}
1359
1360struct ParseMetrics {
1362 parse_ms: f64,
1363 cache_ms: f64,
1364 cache_hits: usize,
1365 cache_misses: usize,
1366 parse_cpu_ms: f64,
1367}
1368
1369impl From<AnalysisParseMetrics> for ParseMetrics {
1370 fn from(metrics: AnalysisParseMetrics) -> Self {
1371 Self {
1372 parse_ms: metrics.parse_ms,
1373 cache_ms: metrics.cache_ms,
1374 cache_hits: metrics.cache_hits,
1375 cache_misses: metrics.cache_misses,
1376 parse_cpu_ms: metrics.parse_cpu_ms,
1377 }
1378 }
1379}
1380
1381fn parse_analysis_modules(
1382 config: &ResolvedConfig,
1383 files: &[discover::DiscoveredFile],
1384 need_complexity: bool,
1385 start: Instant,
1386) -> AnalysisParseOutput {
1387 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1388 let mut cache_store = if config.no_cache {
1389 None
1390 } else {
1391 cache::CacheStore::load(
1392 &config.cache_dir,
1393 config.cache_config_hash,
1394 cache_max_size_bytes,
1395 )
1396 };
1397
1398 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1399 let _ = fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
1400 let modules = parse_result.modules;
1401 let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1402 let cache_ms = update_parse_cache_if_enabled(
1403 config,
1404 &mut cache_store,
1405 &modules,
1406 files,
1407 cache_max_size_bytes,
1408 );
1409
1410 AnalysisParseOutput {
1411 modules,
1412 metrics: ParseMetrics {
1413 parse_ms,
1414 cache_ms,
1415 cache_hits: parse_result.cache_hits,
1416 cache_misses: parse_result.cache_misses,
1417 parse_cpu_ms: parse_result.parse_cpu_ms,
1418 },
1419 }
1420}
1421
1422fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1423 retain.then_some(PipelineTimings {
1424 discover_files_ms: profile.discover_ms,
1425 file_count: profile.file_count,
1426 workspaces_ms: profile.workspaces_ms,
1427 workspace_count: profile.workspace_count,
1428 plugins_ms: profile.plugins_ms,
1429 script_analysis_ms: profile.scripts_ms,
1430 parse_extract_ms: profile.parse_ms,
1431 parse_cpu_ms: profile.parse_cpu_ms,
1432 module_count: profile.module_count,
1433 cache_hits: profile.cache_hits,
1434 cache_misses: profile.cache_misses,
1435 cache_update_ms: profile.cache_ms,
1436 entry_points_ms: profile.entry_points_ms,
1437 entry_point_count: profile.entry_point_count,
1438 resolve_imports_ms: profile.resolve_ms,
1439 build_graph_ms: profile.graph_ms,
1440 analyze_ms: profile.analyze_ms,
1441 duplication_ms: None,
1442 total_ms: profile.total_ms,
1443 })
1444}
1445
1446fn update_parse_cache_if_enabled(
1447 config: &ResolvedConfig,
1448 cache_store: &mut Option<cache::CacheStore>,
1449 modules: &[extract::ModuleInfo],
1450 files: &[discover::DiscoveredFile],
1451 cache_max_size_bytes: usize,
1452) -> f64 {
1453 let t = Instant::now();
1454 if !config.no_cache {
1455 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1456 if update_cache(store, modules, files)
1457 && let Err(error) = store.save(
1458 &config.cache_dir,
1459 config.cache_config_hash,
1460 cache_max_size_bytes,
1461 )
1462 {
1463 tracing::warn!("Failed to save cache: {error}");
1464 }
1465 }
1466 t.elapsed().as_secs_f64() * 1000.0
1467}
1468
1469fn resolve_analysis_imports(
1470 modules: &[extract::ModuleInfo],
1471 files: &[discover::DiscoveredFile],
1472 workspaces: &[fallow_config::WorkspaceInfo],
1473 plugin_result: &plugins::AggregatedPluginResult,
1474 config: &ResolvedConfig,
1475) -> resolve::ResolvedProject {
1476 let mut project = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1477 modules,
1478 files,
1479 workspaces,
1480 active_plugins: &plugin_result.active_plugins,
1481 path_aliases: &plugin_result.path_aliases,
1482 auto_imports: &plugin_result.auto_imports,
1483 scss_include_paths: &plugin_result.scss_include_paths,
1484 static_dir_mappings: &plugin_result.static_dir_mappings,
1485 root: &config.root,
1486 extra_conditions: &config.resolve.conditions,
1487 });
1488 external_style_usage::augment_external_style_package_usage(
1489 &mut project.modules,
1490 config,
1491 workspaces,
1492 plugin_result,
1493 );
1494 project
1495}
1496
1497struct BuildAnalysisGraphInput<'a> {
1498 config: &'a ResolvedConfig,
1499 plugin_result: &'a plugins::AggregatedPluginResult,
1500 project: &'a resolve::ResolvedProject,
1501 entry_points: &'a discover::CategorizedEntryPoints,
1502 files: &'a [discover::DiscoveredFile],
1503 modules: &'a [extract::ModuleInfo],
1504 workspaces: &'a [fallow_config::WorkspaceInfo],
1505}
1506
1507fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1515 let caching_enabled = !input.config.no_cache;
1516 let current_manifest = caching_enabled.then(|| {
1517 build_graph_cache_manifest(
1518 input.config,
1519 input.plugin_result,
1520 input.entry_points,
1521 input.files,
1522 )
1523 });
1524
1525 let mut graph = graph::ModuleGraph::build_with_reachability_roots_and_replacements(
1526 &input.project.modules,
1527 &input.project.replaced_module_targets,
1528 &input.entry_points.all,
1529 &input.entry_points.runtime,
1530 &input.entry_points.test,
1531 input.files,
1532 );
1533 credit_package_path_references(&mut graph, input.modules);
1534 credit_workspace_package_usage(&mut graph, &input.project.modules, input.workspaces);
1535
1536 if let Some(manifest) = current_manifest {
1537 let Some(resolved_project) =
1538 graph_cache::cache_resolved_project(&input.config.root, input.files, input.project)
1539 else {
1540 return graph;
1541 };
1542 let store = graph_cache::GraphCacheStore {
1543 version: graph_cache::GRAPH_CACHE_VERSION,
1544 manifest,
1545 graph,
1546 resolved_project,
1547 };
1548 store.save(&input.config.cache_dir);
1549 return store.graph;
1554 }
1555
1556 graph
1557}
1558
1559fn build_graph_cache_manifest(
1562 config: &ResolvedConfig,
1563 plugin_result: &plugins::AggregatedPluginResult,
1564 entry_points: &discover::CategorizedEntryPoints,
1565 files: &[discover::DiscoveredFile],
1566) -> graph_cache::GraphCacheManifest {
1567 let mode = graph_cache::GraphCacheMode::new(
1568 resolver_options_hash(config),
1569 entry_points_hash(entry_points),
1570 plugin_config_hash(plugin_result),
1571 );
1572 graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |path| {
1573 std::fs::metadata(path).map_or(
1574 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
1575 |metadata| {
1576 fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
1577 },
1578 )
1579 })
1580}
1581
1582fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1590 use std::hash::{Hash, Hasher};
1591 let mut hasher = rustc_hash::FxHasher::default();
1592 config.root.hash(&mut hasher);
1593 config.cache_config_hash.hash(&mut hasher);
1594 config.resolve.conditions.hash(&mut hasher);
1595 hasher.finish()
1596}
1597
1598fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
1601 use std::hash::{Hash, Hasher};
1602 let mut hasher = rustc_hash::FxHasher::default();
1603 for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1604 let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
1605 paths.sort_unstable();
1606 paths.len().hash(&mut hasher);
1607 for path in paths {
1608 path.hash(&mut hasher);
1609 }
1610 }
1611 hasher.finish()
1612}
1613
1614fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
1616 use std::hash::{Hash, Hasher};
1617 let mut hasher = rustc_hash::FxHasher::default();
1618
1619 hash_active_plugins(plugin_result, &mut hasher);
1620 hash_path_aliases(plugin_result, &mut hasher);
1621
1622 let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
1623 plugin_result
1624 .auto_imports
1625 .iter()
1626 .map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
1627 .collect();
1628 auto_imports.sort_unstable_by(|a, b| {
1629 a.0.cmp(b.0)
1630 .then_with(|| a.1.cmp(b.1))
1631 .then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
1632 });
1633 auto_imports.len().hash(&mut hasher);
1634 for (name, source, kind) in auto_imports {
1635 name.hash(&mut hasher);
1636 source.hash(&mut hasher);
1637 auto_import_kind_rank(kind).hash(&mut hasher);
1638 }
1639
1640 let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
1641 .scss_include_paths
1642 .iter()
1643 .map(std::path::PathBuf::as_path)
1644 .collect();
1645 scss_include_paths.sort_unstable();
1646 scss_include_paths.len().hash(&mut hasher);
1647 for path in scss_include_paths {
1648 path.hash(&mut hasher);
1649 }
1650
1651 let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
1652 .static_dir_mappings
1653 .iter()
1654 .map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
1655 .collect();
1656 static_dir_mappings.sort_unstable();
1657 static_dir_mappings.len().hash(&mut hasher);
1658 for (from_dir, mount) in static_dir_mappings {
1659 from_dir.hash(&mut hasher);
1660 mount.hash(&mut hasher);
1661 }
1662
1663 hasher.finish()
1664}
1665
1666fn hash_active_plugins(
1667 plugin_result: &plugins::AggregatedPluginResult,
1668 hasher: &mut rustc_hash::FxHasher,
1669) {
1670 use std::hash::Hash;
1671 let mut active: Vec<&str> = plugin_result
1672 .active_plugins
1673 .iter()
1674 .map(String::as_str)
1675 .collect();
1676 active.sort_unstable();
1677 active.len().hash(hasher);
1678 for name in active {
1679 name.hash(hasher);
1680 }
1681}
1682
1683fn hash_path_aliases(
1684 plugin_result: &plugins::AggregatedPluginResult,
1685 hasher: &mut rustc_hash::FxHasher,
1686) {
1687 use std::hash::Hash;
1688 let mut aliases: Vec<(&str, &str)> = plugin_result
1689 .path_aliases
1690 .iter()
1691 .map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
1692 .collect();
1693 aliases.sort_unstable();
1694 aliases.len().hash(hasher);
1695 for (prefix, replacement) in aliases {
1696 prefix.hash(hasher);
1697 replacement.hash(hasher);
1698 }
1699}
1700
1701fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1702 match kind {
1703 fallow_config::AutoImportKind::Named => 0,
1704 fallow_config::AutoImportKind::Default => 1,
1705 fallow_config::AutoImportKind::DefaultComponent => 2,
1706 }
1707}
1708
1709fn collect_file_hashes(
1710 modules: &[extract::ModuleInfo],
1711 files: &[discover::DiscoveredFile],
1712) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1713 modules
1714 .iter()
1715 .filter_map(|module| {
1716 files
1717 .get(module.file_id.0 as usize)
1718 .map(|file| (file.path.clone(), module.content_hash))
1719 })
1720 .collect()
1721}
1722
1723fn trace_pipeline_profile(profile: &PipelineProfile) {
1724 let PipelineProfile {
1725 discover_ms,
1726 workspaces_ms,
1727 plugins_ms,
1728 scripts_ms,
1729 parse_ms,
1730 cache_ms,
1731 entry_points_ms,
1732 resolve_ms,
1733 graph_ms,
1734 analyze_ms,
1735 total_ms,
1736 file_count,
1737 module_count,
1738 entry_point_count,
1739 cache_hits,
1740 cache_misses,
1741 ..
1742 } = *profile;
1743 let cache_summary = if cache_hits > 0 {
1744 format!(" ({cache_hits} cached, {cache_misses} parsed)")
1745 } else {
1746 String::new()
1747 };
1748
1749 tracing::debug!(
1750 "\n┌─ Pipeline Profile ─────────────────────────────\n\
1751 │ discover files: {:>8.1}ms ({} files)\n\
1752 │ workspaces: {:>8.1}ms\n\
1753 │ plugins: {:>8.1}ms\n\
1754 │ script analysis: {:>8.1}ms\n\
1755 │ parse/extract: {:>8.1}ms ({} modules{})\n\
1756 │ cache update: {:>8.1}ms\n\
1757 │ entry points: {:>8.1}ms ({} entries)\n\
1758 │ resolve imports: {:>8.1}ms\n\
1759 │ build graph: {:>8.1}ms\n\
1760 │ analyze: {:>8.1}ms\n\
1761 │ ────────────────────────────────────────────\n\
1762 │ TOTAL: {:>8.1}ms\n\
1763 └─────────────────────────────────────────────────",
1764 discover_ms,
1765 file_count,
1766 workspaces_ms,
1767 plugins_ms,
1768 scripts_ms,
1769 parse_ms,
1770 module_count,
1771 cache_summary,
1772 cache_ms,
1773 entry_points_ms,
1774 entry_point_count,
1775 resolve_ms,
1776 graph_ms,
1777 analyze_ms,
1778 total_ms,
1779 );
1780}
1781
1782fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1787 fallow_config::load_dir_package_json(&config.root)
1788}
1789
1790fn load_workspace_packages(
1791 workspaces: &[fallow_config::WorkspaceInfo],
1792) -> Vec<LoadedWorkspacePackage> {
1793 workspaces
1794 .iter()
1795 .filter_map(|ws| {
1796 fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
1797 })
1798 .collect()
1799}
1800
1801fn analyze_all_scripts(
1802 config: &ResolvedConfig,
1803 workspaces: &[fallow_config::WorkspaceInfo],
1804 root_pkg: Option<&PackageJson>,
1805 workspace_pkgs: &[LoadedWorkspacePackage],
1806 plugin_result: &mut plugins::AggregatedPluginResult,
1807) {
1808 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1809 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1810 let all_scripts = collect_all_scripts(root_pkg, workspace_pkgs);
1811
1812 let nm_roots = collect_node_modules_roots(config, workspaces);
1813 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1814
1815 analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1816 analyze_workspace_scripts(
1817 config,
1818 workspace_pkgs,
1819 &bin_map,
1820 &all_dep_set,
1821 plugin_result,
1822 );
1823 analyze_ci_scripts(config, &bin_map, &all_dep_set, &all_scripts, plugin_result);
1824
1825 plugin_result
1826 .entry_point_roles
1827 .entry("scripts".to_string())
1828 .or_insert(EntryPointRole::Support);
1829}
1830
1831fn collect_all_dependency_names(
1833 root_pkg: Option<&PackageJson>,
1834 workspace_pkgs: &[LoadedWorkspacePackage],
1835) -> Vec<String> {
1836 let mut all_dep_names: Vec<String> = Vec::new();
1837 if let Some(pkg) = root_pkg {
1838 all_dep_names.extend(pkg.all_dependency_names());
1839 }
1840 for (_, ws_pkg) in workspace_pkgs {
1841 all_dep_names.extend(ws_pkg.all_dependency_names());
1842 }
1843 all_dep_names.sort_unstable();
1844 all_dep_names.dedup();
1845 all_dep_names
1846}
1847
1848fn collect_all_scripts(
1850 root_pkg: Option<&PackageJson>,
1851 workspace_pkgs: &[LoadedWorkspacePackage],
1852) -> scripts::ScriptCatalog {
1853 let mut catalog = scripts::ScriptCatalog::default();
1854 if let Some(pkg) = root_pkg
1855 && let Some(ref pkg_scripts) = pkg.scripts
1856 {
1857 catalog.merge_scripts(pkg_scripts);
1858 }
1859 for (_, ws_pkg) in workspace_pkgs {
1860 if let Some(ref ws_scripts) = ws_pkg.scripts {
1861 catalog.merge_workspace_scripts(ws_scripts);
1862 }
1863 }
1864 catalog
1865}
1866
1867fn collect_node_modules_roots<'a>(
1869 config: &'a ResolvedConfig,
1870 workspaces: &'a [fallow_config::WorkspaceInfo],
1871) -> Vec<&'a std::path::Path> {
1872 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1873 if config.root.join("node_modules").is_dir() {
1874 nm_roots.push(&config.root);
1875 }
1876 for ws in workspaces {
1877 if ws.root.join("node_modules").is_dir() {
1878 nm_roots.push(&ws.root);
1879 }
1880 }
1881 nm_roots
1882}
1883
1884fn analyze_root_scripts(
1886 config: &ResolvedConfig,
1887 root_pkg: Option<&PackageJson>,
1888 bin_map: &rustc_hash::FxHashMap<String, String>,
1889 all_dep_set: &FxHashSet<String>,
1890 plugin_result: &mut plugins::AggregatedPluginResult,
1891) {
1892 let Some(pkg) = root_pkg else {
1893 return;
1894 };
1895 let Some(ref pkg_scripts) = pkg.scripts else {
1896 return;
1897 };
1898 let scripts_to_analyze = if config.production {
1899 scripts::filter_production_scripts(pkg_scripts)
1900 } else {
1901 pkg_scripts.clone()
1902 };
1903 let catalog =
1904 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1905 let script_analysis = scripts::analyze_scripts_with_dependency_context(
1906 &scripts_to_analyze,
1907 &config.root,
1908 bin_map,
1909 all_dep_set,
1910 &catalog,
1911 );
1912 plugin_result.script_used_packages = script_analysis.used_packages;
1913
1914 for config_file in &script_analysis.config_files {
1915 plugin_result
1916 .discovered_always_used
1917 .push((config_file.clone(), "scripts".to_string()));
1918 }
1919 for entry in &script_analysis.entry_files {
1920 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1921 plugin_result
1922 .entry_patterns
1923 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1924 }
1925 }
1926}
1927
1928type WsScriptOut = (
1930 Vec<String>,
1931 Vec<(String, String)>,
1932 Vec<(plugins::PathRule, String)>,
1933);
1934
1935fn analyze_workspace_scripts(
1936 config: &ResolvedConfig,
1937 workspace_pkgs: &[LoadedWorkspacePackage],
1938 bin_map: &rustc_hash::FxHashMap<String, String>,
1939 all_dep_set: &FxHashSet<String>,
1940 plugin_result: &mut plugins::AggregatedPluginResult,
1941) {
1942 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1943 .par_iter()
1944 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1945 .collect();
1946 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1947 plugin_result.script_used_packages.extend(used_packages);
1948 plugin_result
1949 .discovered_always_used
1950 .extend(discovered_always_used);
1951 plugin_result.entry_patterns.extend(entry_patterns);
1952 }
1953}
1954
1955fn analyze_one_workspace_scripts(
1958 config: &ResolvedConfig,
1959 ws: &fallow_config::WorkspaceInfo,
1960 ws_pkg: &PackageJson,
1961 bin_map: &rustc_hash::FxHashMap<String, String>,
1962 all_dep_set: &FxHashSet<String>,
1963) -> WsScriptOut {
1964 let mut used_packages = Vec::new();
1965 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1966 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1967 let Some(ref ws_scripts) = ws_pkg.scripts else {
1968 return (used_packages, discovered_always_used, entry_patterns);
1969 };
1970 let scripts_to_analyze = if config.production {
1971 scripts::filter_production_scripts(ws_scripts)
1972 } else {
1973 ws_scripts.clone()
1974 };
1975 let catalog = scripts::ScriptCatalog::from_scripts_with_bodies(ws_scripts, &scripts_to_analyze);
1976 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1977 &scripts_to_analyze,
1978 &ws.root,
1979 bin_map,
1980 all_dep_set,
1981 &catalog,
1982 );
1983 used_packages.extend(ws_analysis.used_packages);
1984
1985 let ws_prefix = ws
1986 .root
1987 .strip_prefix(&config.root)
1988 .unwrap_or(&ws.root)
1989 .to_string_lossy();
1990 for config_file in &ws_analysis.config_files {
1991 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1992 }
1993 for entry in &ws_analysis.entry_files {
1994 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1995 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1996 }
1997 }
1998 (used_packages, discovered_always_used, entry_patterns)
1999}
2000
2001fn analyze_ci_scripts(
2003 config: &ResolvedConfig,
2004 bin_map: &rustc_hash::FxHashMap<String, String>,
2005 all_dep_set: &FxHashSet<String>,
2006 all_scripts: &scripts::ScriptCatalog,
2007 plugin_result: &mut plugins::AggregatedPluginResult,
2008) {
2009 let ci_analysis =
2010 scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_scripts);
2011 plugin_result
2012 .script_used_packages
2013 .extend(ci_analysis.used_packages);
2014 for entry in &ci_analysis.entry_files {
2015 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2016 plugin_result
2017 .entry_patterns
2018 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2019 }
2020 }
2021}
2022
2023fn discover_all_entry_points(
2025 input: DiscoverAllEntryPointsInput<'_>,
2026) -> discover::CategorizedEntryPoints {
2027 let mut entry_points = discover::CategorizedEntryPoints::default();
2028 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
2029 input.config,
2030 input.files,
2031 input.root_pkg,
2032 input.workspaces.is_empty(),
2033 );
2034
2035 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> = input
2036 .workspace_pkgs
2037 .iter()
2038 .map(|(ws, pkg)| (ws.root.clone(), pkg))
2039 .collect();
2040 let workspace_script_seeds = discover::workspace_runtime_script_seeds(
2041 &input.config.root,
2042 input.root_pkg,
2043 input.workspace_pkgs,
2044 );
2045
2046 let workspace_discovery: Vec<discover::EntryPointDiscovery> = input
2047 .workspaces
2048 .par_iter()
2049 .map(|ws| {
2050 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2051 let seeds = workspace_script_seeds
2052 .get(&ws.name)
2053 .cloned()
2054 .unwrap_or_default();
2055 discover::discover_workspace_entry_points_with_runtime_scripts(
2056 &ws.root,
2057 input.files,
2058 pkg,
2059 &seeds,
2060 )
2061 })
2062 .collect();
2063 let mut skipped_entries = rustc_hash::FxHashMap::default();
2064 entry_points.extend_runtime(root_discovery.entries);
2065 entry_points.extend_support(root_discovery.support_entries);
2066 for (path, count) in root_discovery.skipped_entries {
2067 *skipped_entries.entry(path).or_insert(0) += count;
2068 }
2069 let mut ws_entries = Vec::new();
2070 let mut ws_support_entries = Vec::new();
2071 for workspace in workspace_discovery {
2072 ws_entries.extend(workspace.entries);
2073 ws_support_entries.extend(workspace.support_entries);
2074 for (path, count) in workspace.skipped_entries {
2075 *skipped_entries.entry(path).or_insert(0) += count;
2076 }
2077 }
2078 discover::warn_skipped_entry_summary(&skipped_entries);
2079 entry_points.extend_runtime(ws_entries);
2080 entry_points.extend_support(ws_support_entries);
2081
2082 let plugin_entries =
2083 discover::discover_plugin_entry_point_sets(input.plugin_result, input.config, input.files);
2084 entry_points.extend(plugin_entries);
2085
2086 let infra_entries = discover::discover_infrastructure_entry_points(&input.config.root);
2087 entry_points.extend_runtime(infra_entries);
2088
2089 if !input.config.dynamically_loaded.is_empty() {
2090 let dynamic_entries =
2091 discover::discover_dynamically_loaded_entry_points(input.config, input.files);
2092 entry_points.extend_runtime(dynamic_entries);
2093 }
2094
2095 entry_points.dedup()
2096}
2097
2098fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2100 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2101 for ep in entry_points {
2102 let category = match &ep.source {
2103 discover::EntryPointSource::PackageJsonMain
2104 | discover::EntryPointSource::PackageJsonModule
2105 | discover::EntryPointSource::PackageJsonExports
2106 | discover::EntryPointSource::PackageJsonBin
2107 | discover::EntryPointSource::PackageJsonScript => "package.json",
2108 discover::EntryPointSource::Plugin { .. } => "plugin",
2109 discover::EntryPointSource::TestFile => "test file",
2110 discover::EntryPointSource::DefaultIndex => "default index",
2111 discover::EntryPointSource::ManualEntry => "manual entry",
2112 discover::EntryPointSource::InfrastructureConfig => "config",
2113 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2114 };
2115 *counts.entry(category.to_string()).or_insert(0) += 1;
2116 }
2117 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2118 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2119 results::EntryPointSummary {
2120 total: entry_points.len(),
2121 by_source,
2122 }
2123}
2124
2125fn append_package_file_asset_patterns(
2126 result: &mut plugins::AggregatedPluginResult,
2127 prefix: &str,
2128 pkg: &PackageJson,
2129) {
2130 let prefix = prefix.trim_matches('/');
2131 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2132 let pattern = if prefix.is_empty() {
2133 pattern
2134 } else {
2135 format!("{prefix}/{pattern}")
2136 };
2137 result
2138 .discovered_always_used
2139 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2140 }
2141}
2142
2143fn append_workspace_package_file_asset_patterns(
2144 result: &mut plugins::AggregatedPluginResult,
2145 config: &ResolvedConfig,
2146 workspace_pkgs: &[LoadedWorkspacePackage],
2147) {
2148 for (ws, ws_pkg) in workspace_pkgs {
2149 let ws_prefix = ws
2150 .root
2151 .strip_prefix(&config.root)
2152 .unwrap_or(&ws.root)
2153 .to_string_lossy()
2154 .replace('\\', "/");
2155 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2156 }
2157}
2158
2159fn run_plugins(
2161 config: &ResolvedConfig,
2162 files: &[discover::DiscoveredFile],
2163 workspaces: &[fallow_config::WorkspaceInfo],
2164 root_pkg: Option<&PackageJson>,
2165 workspace_pkgs: &[LoadedWorkspacePackage],
2166 config_candidates: &[std::path::PathBuf],
2167) -> Result<plugins::AggregatedPluginResult, FallowError> {
2168 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2169 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2170
2171 let candidate_index = (!config.production).then(|| {
2176 plugins::registry::ConfigCandidateIndex::build(
2177 file_paths
2178 .iter()
2179 .map(std::path::PathBuf::as_path)
2180 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2181 )
2182 });
2183
2184 let mut result = run_root_plugins(
2185 ®istry,
2186 config,
2187 root_pkg,
2188 &file_paths,
2189 candidate_index.as_ref(),
2190 )?;
2191
2192 if workspaces.is_empty() {
2193 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2194 return Ok(result);
2195 }
2196
2197 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2198
2199 let ws_results = run_workspace_plugins(
2200 ®istry,
2201 config,
2202 workspace_pkgs,
2203 &file_paths,
2204 &result.active_plugins,
2205 candidate_index.as_ref(),
2206 );
2207 merge_workspace_plugin_results(&mut result, ws_results)?;
2208
2209 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2210
2211 Ok(result)
2212}
2213
2214type WorkspacePluginResult = Result<
2215 (plugins::AggregatedPluginResult, String),
2216 Vec<plugins::registry::PluginRegexValidationError>,
2217>;
2218
2219fn run_root_plugins(
2221 registry: &plugins::PluginRegistry,
2222 config: &ResolvedConfig,
2223 root_pkg: Option<&PackageJson>,
2224 file_paths: &[std::path::PathBuf],
2225 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2226) -> Result<plugins::AggregatedPluginResult, FallowError> {
2227 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2228 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2229 .iter()
2230 .map(std::path::PathBuf::as_path)
2231 .collect();
2232
2233 let mut result = if let Some(pkg) = root_pkg {
2234 registry
2235 .try_run_with_search_roots(
2236 pkg,
2237 &config.root,
2238 file_paths,
2239 &root_config_search_root_refs,
2240 config.production,
2241 candidate_index,
2242 )
2243 .map_err(|errors| {
2244 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2245 })?
2246 } else {
2247 plugins::AggregatedPluginResult::default()
2248 };
2249 if let Some(pkg) = root_pkg {
2250 append_package_file_asset_patterns(&mut result, "", pkg);
2251 }
2252 Ok(result)
2253}
2254
2255fn run_workspace_plugins(
2258 registry: &plugins::PluginRegistry,
2259 config: &ResolvedConfig,
2260 workspace_pkgs: &[LoadedWorkspacePackage],
2261 file_paths: &[std::path::PathBuf],
2262 root_active_plugins: &[String],
2263 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2264) -> Vec<WorkspacePluginResult> {
2265 let root_active_plugins: rustc_hash::FxHashSet<&str> =
2266 root_active_plugins.iter().map(String::as_str).collect();
2267
2268 let precompiled_matchers = registry.precompile_config_matchers();
2269 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2270
2271 workspace_pkgs
2272 .par_iter()
2273 .zip(workspace_relative_files.par_iter())
2274 .filter_map(|((ws, ws_pkg), relative_files)| {
2275 let ws_result =
2276 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2277 pkg: ws_pkg,
2278 root: &ws.root,
2279 project_root: &config.root,
2280 precompiled_config_matchers: &precompiled_matchers,
2281 relative_files,
2282 skip_config_plugins: &root_active_plugins,
2283 production_mode: config.production,
2284 candidate_index,
2285 }) {
2286 Ok(result) => result,
2287 Err(errors) => return Some(Err(errors)),
2288 };
2289 if ws_result.active_plugins.is_empty() {
2290 return None;
2291 }
2292 let ws_prefix = ws
2293 .root
2294 .strip_prefix(&config.root)
2295 .unwrap_or(&ws.root)
2296 .to_string_lossy()
2297 .into_owned();
2298 Some(Ok((ws_result, ws_prefix)))
2299 })
2300 .collect::<Vec<_>>()
2301}
2302
2303fn merge_workspace_plugin_results(
2306 result: &mut plugins::AggregatedPluginResult,
2307 ws_results: Vec<WorkspacePluginResult>,
2308) -> Result<(), FallowError> {
2309 let mut regex_errors = Vec::new();
2310 for ws_result in ws_results {
2311 match ws_result {
2312 Ok((mut ws_result, ws_prefix)) => {
2313 ws_result.apply_workspace_prefix(&ws_prefix);
2314 ws_result.config_patterns.clear();
2315 ws_result.script_used_packages.clear();
2316 result.merge_into(ws_result);
2317 }
2318 Err(mut errors) => regex_errors.append(&mut errors),
2319 }
2320 }
2321 if !regex_errors.is_empty() {
2322 return Err(FallowError::config(
2323 plugins::registry::format_plugin_regex_errors(®ex_errors),
2324 ));
2325 }
2326 Ok(())
2327}
2328
2329fn gate_auto_import_entry_patterns(
2335 result: &mut plugins::AggregatedPluginResult,
2336 config: &ResolvedConfig,
2337 workspaces: &[fallow_config::WorkspaceInfo],
2338) {
2339 if !config.auto_imports {
2340 return;
2341 }
2342 if !result.active_plugins.iter().any(|name| name == "nuxt") {
2343 return;
2344 }
2345 let components_custom = plugins::nuxt::config_declares_components(&config.root)
2346 || workspaces
2347 .iter()
2348 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
2349 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
2350 || workspaces
2351 .iter()
2352 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
2353 result.entry_patterns.retain(|(rule, plugin)| {
2354 if plugin != "nuxt" {
2355 return true;
2356 }
2357 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2358 return false;
2359 }
2360 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2361 return false;
2362 }
2363 true
2364 });
2365}
2366
2367fn bucket_files_by_workspace(
2368 workspace_pkgs: &[LoadedWorkspacePackage],
2369 file_paths: &[std::path::PathBuf],
2370) -> Vec<Vec<(std::path::PathBuf, String)>> {
2371 let workspace_roots: Vec<_> = workspace_pkgs
2372 .iter()
2373 .map(|(workspace, _)| workspace.root.as_path())
2374 .collect();
2375 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2376}
2377
2378fn bucket_files_by_workspace_roots(
2379 workspace_roots: &[&Path],
2380 file_paths: &[std::path::PathBuf],
2381) -> Vec<Vec<(std::path::PathBuf, String)>> {
2382 use rayon::prelude::*;
2383
2384 let mut workspace_by_root: rustc_hash::FxHashMap<&Path, usize> =
2388 rustc_hash::FxHashMap::default();
2389 for (idx, root) in workspace_roots.iter().enumerate() {
2390 workspace_by_root.entry(root).or_insert(idx);
2391 }
2392
2393 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2394 .par_iter()
2395 .map(|file_path| {
2396 let idx = file_path
2397 .ancestors()
2398 .filter_map(|ancestor| workspace_by_root.get(ancestor).copied())
2399 .min()?;
2400 let relative = file_path.strip_prefix(workspace_roots[idx]).ok()?;
2401 Some((
2402 idx,
2403 file_path.clone(),
2404 relative.to_string_lossy().into_owned(),
2405 ))
2406 })
2407 .collect();
2408
2409 let mut buckets = vec![Vec::new(); workspace_roots.len()];
2410 for (idx, file_path, relative) in assignments.into_iter().flatten() {
2411 buckets[idx].push((file_path, relative));
2412 }
2413
2414 buckets
2415}
2416
2417#[doc(hidden)]
2419pub fn benchmark_bucket_files_by_workspace(
2420 workspace_roots: &[std::path::PathBuf],
2421 file_paths: &[std::path::PathBuf],
2422) -> Vec<Vec<(std::path::PathBuf, String)>> {
2423 let workspace_roots: Vec<_> = workspace_roots
2424 .iter()
2425 .map(std::path::PathBuf::as_path)
2426 .collect();
2427 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2428}
2429
2430fn collect_config_search_roots(
2431 root: &Path,
2432 file_paths: &[std::path::PathBuf],
2433) -> Vec<std::path::PathBuf> {
2434 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2435 roots.insert(root.to_path_buf());
2436
2437 for file_path in file_paths {
2438 let mut current = file_path.parent();
2439 while let Some(dir) = current {
2440 if !dir.starts_with(root) {
2441 break;
2442 }
2443 roots.insert(dir.to_path_buf());
2444 if dir == root {
2445 break;
2446 }
2447 current = dir.parent();
2448 }
2449 }
2450
2451 let mut roots_vec: Vec<_> = roots.into_iter().collect();
2452 roots_vec.sort();
2453 roots_vec
2454}
2455
2456fn config_for_project(
2464 root: &Path,
2465 config_path: Option<&Path>,
2466) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2467 let user_config = if let Some(path) = config_path {
2468 Some((
2469 fallow_config::FallowConfig::load(path)
2470 .map_err(|e| FallowError::config(format!("{e:#}")))?,
2471 path.to_path_buf(),
2472 ))
2473 } else {
2474 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2475 };
2476
2477 let config = match user_config {
2478 Some((config, path)) => resolve_user_config(config, path, root)?,
2479 None => (
2480 fallow_config::FallowConfig::default().resolve(
2481 root.to_path_buf(),
2482 fallow_config::OutputFormat::Human,
2483 num_cpus(),
2484 false,
2485 true,
2486 None,
2487 ),
2488 None,
2489 ),
2490 };
2491
2492 Ok(config)
2493}
2494
2495fn resolve_user_config(
2498 mut config: fallow_config::FallowConfig,
2499 path: std::path::PathBuf,
2500 root: &Path,
2501) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2502 let dead_code_production = config
2503 .production
2504 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2505 config.production = dead_code_production.into();
2506 config
2507 .validate_resolved_boundaries(root)
2508 .map_err(|errors| {
2509 let joined = errors
2510 .iter()
2511 .map(ToString::to_string)
2512 .collect::<Vec<_>>()
2513 .join("\n - ");
2514 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
2515 })?;
2516 let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2517 let joined = errors
2518 .iter()
2519 .map(ToString::to_string)
2520 .collect::<Vec<_>>()
2521 .join("\n - ");
2522 FallowError::config(format!("invalid rule pack:\n - {joined}"))
2523 })?;
2524 let boundaries =
2525 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
2526 let zone_errors = fallow_config::validate_rule_pack_zone_references(
2527 root,
2528 &config.rule_packs,
2529 &packs,
2530 &boundaries,
2531 );
2532 if !zone_errors.is_empty() {
2533 let joined = zone_errors
2534 .iter()
2535 .map(ToString::to_string)
2536 .collect::<Vec<_>>()
2537 .join("\n - ");
2538 return Err(FallowError::config(format!(
2539 "invalid rule pack:\n - {joined}"
2540 )));
2541 }
2542 Ok((
2543 config.resolve(
2544 root.to_path_buf(),
2545 fallow_config::OutputFormat::Human,
2546 num_cpus(),
2547 false,
2548 true, None, ),
2551 Some(path),
2552 ))
2553}
2554
2555#[cfg_attr(
2566 not(test),
2567 allow(
2568 dead_code,
2569 reason = "config resolution fallback is exercised by session tests"
2570 )
2571)]
2572pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2573 config_for_project(root, None).map_or_else(
2574 |_| {
2575 fallow_config::FallowConfig::default().resolve(
2576 root.to_path_buf(),
2577 fallow_config::OutputFormat::Human,
2578 num_cpus(),
2579 false,
2580 true,
2581 None,
2582 )
2583 },
2584 |(config, _)| config,
2585 )
2586}
2587
2588fn num_cpus() -> usize {
2589 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2590}
2591
2592#[cfg(test)]
2593mod tests {
2594 use super::{
2595 AnalysisSession, bucket_files_by_workspace, bucket_files_by_workspace_roots,
2596 collect_config_search_roots, credit_workspace_package_usage, default_config,
2597 format_undeclared_workspace_warning, parse_analysis_modules, plugin_config_hash,
2598 resolver_options_hash, warn_undeclared_workspaces,
2599 };
2600 use std::path::{Path, PathBuf};
2601 use std::time::Instant;
2602
2603 use fallow_config::{
2604 AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
2605 };
2606 use fallow_types::discover::{DiscoveredFile, FileId};
2607 use fallow_types::extract::{ImportInfo, ImportedName};
2608
2609 fn plugin_result() -> crate::plugins::AggregatedPluginResult {
2610 let mut result = crate::plugins::AggregatedPluginResult::default();
2611 result.active_plugins.push("nuxt".to_string());
2612 result
2613 .path_aliases
2614 .push(("@/".to_string(), "src/".to_string()));
2615 result
2616 }
2617
2618 #[test]
2619 fn commonjs_internal_import_credits_workspace_package_usage() {
2620 let workspace = fallow_config::WorkspaceInfo {
2621 root: PathBuf::from("/repo/packages/shared"),
2622 name: "@repo/shared".to_string(),
2623 is_internal_dependency: true,
2624 };
2625 let resolved = vec![crate::resolve::ResolvedModule {
2626 file_id: FileId(0),
2627 resolved_imports: vec![crate::resolve::ResolvedImport {
2628 info: ImportInfo {
2629 source: "@repo/shared".to_string(),
2630 imported_name: ImportedName::Namespace,
2631 local_name: "shared".to_string(),
2632 is_type_only: false,
2633 is_type_only_star: false,
2634 from_style: false,
2635 span: oxc_span::Span::new(0, 20),
2636 source_span: oxc_span::Span::new(8, 20),
2637 },
2638 target: crate::resolve::ResolveResult::CommonJsInternalModule(FileId(1)),
2639 }],
2640 ..crate::resolve::ResolvedModule::default()
2641 }];
2642 let mut graph = crate::graph::ModuleGraph::build(&[], &[], &[]);
2643
2644 credit_workspace_package_usage(&mut graph, &resolved, &[workspace]);
2645
2646 assert_eq!(
2647 graph.package_usage.get("@repo/shared"),
2648 Some(&vec![FileId(0)])
2649 );
2650 }
2651
2652 #[test]
2653 fn graph_cache_resolver_hash_includes_project_root() {
2654 let dir_a = tempfile::tempdir().expect("create temp dir a");
2655 let dir_b = tempfile::tempdir().expect("create temp dir b");
2656 let config_a = session_config(dir_a.path());
2657 let config_b = session_config(dir_b.path());
2658
2659 assert_ne!(
2660 resolver_options_hash(&config_a),
2661 resolver_options_hash(&config_b),
2662 "shared cache dirs must not reuse graphs across project roots"
2663 );
2664 }
2665
2666 #[test]
2667 fn graph_cache_resolver_hash_includes_resolve_conditions() {
2668 let dir = tempfile::tempdir().expect("create temp dir");
2669 let config_a = session_config(dir.path());
2670 let mut config_b = session_config(dir.path());
2671 config_b.resolve.conditions.push("react-server".to_string());
2672
2673 assert_ne!(
2674 resolver_options_hash(&config_a),
2675 resolver_options_hash(&config_b),
2676 "resolve condition changes must invalidate the graph cache"
2677 );
2678 }
2679
2680 #[test]
2681 fn graph_cache_plugin_hash_includes_auto_imports() {
2682 let mut without_auto_import = plugin_result();
2683 let mut with_auto_import = plugin_result();
2684 with_auto_import.auto_imports.push(AutoImportRule {
2685 name: "useCounter".to_string(),
2686 source: PathBuf::from("/project/composables/useCounter.ts"),
2687 kind: AutoImportKind::Named,
2688 });
2689
2690 assert_ne!(
2691 plugin_config_hash(&without_auto_import),
2692 plugin_config_hash(&with_auto_import),
2693 "auto-import edge changes must invalidate the graph cache"
2694 );
2695
2696 without_auto_import.auto_imports.push(AutoImportRule {
2697 name: "useCounter".to_string(),
2698 source: PathBuf::from("/project/composables/useCounter.ts"),
2699 kind: AutoImportKind::Default,
2700 });
2701 assert_ne!(
2702 plugin_config_hash(&without_auto_import),
2703 plugin_config_hash(&with_auto_import),
2704 "auto-import kind changes must invalidate the graph cache"
2705 );
2706 }
2707
2708 #[test]
2709 fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
2710 let base = plugin_result();
2711 let mut with_scss = base.clone();
2712 with_scss
2713 .scss_include_paths
2714 .push(PathBuf::from("/project/styles"));
2715 assert_ne!(
2716 plugin_config_hash(&base),
2717 plugin_config_hash(&with_scss),
2718 "SCSS include path changes must invalidate the graph cache"
2719 );
2720
2721 let mut with_static_dir = base.clone();
2722 with_static_dir
2723 .static_dir_mappings
2724 .push((PathBuf::from("/project/public"), "/".to_string()));
2725 assert_ne!(
2726 plugin_config_hash(&base),
2727 plugin_config_hash(&with_static_dir),
2728 "static directory mapping changes must invalidate the graph cache"
2729 );
2730 }
2731
2732 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2733 WorkspaceDiagnostic::new(
2734 root,
2735 root.join(relative),
2736 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2737 )
2738 }
2739
2740 fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
2741 let mut config = default_config(root);
2742 config.no_cache = true;
2743 config.quiet = true;
2744 config
2745 }
2746
2747 fn write_session_fixture(root: &Path) {
2748 let src = root.join("src");
2749 std::fs::create_dir_all(&src).expect("create src");
2750 std::fs::write(
2751 root.join("package.json"),
2752 r#"{"name":"session-fixture","type":"module"}"#,
2753 )
2754 .expect("write package json");
2755 std::fs::write(
2756 src.join("index.ts"),
2757 "import { used } from './used';\nconsole.log(used);\n",
2758 )
2759 .expect("write index");
2760 std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
2761 }
2762
2763 #[test]
2764 fn analysis_session_discovers_project_files() {
2765 let dir = tempfile::tempdir().expect("create temp dir");
2766 write_session_fixture(dir.path());
2767 let config = session_config(dir.path());
2768
2769 let session = AnalysisSession::new(&config).expect("session setup should succeed");
2770
2771 assert!(
2772 session
2773 .files()
2774 .iter()
2775 .any(|file| file.path.ends_with("src/index.ts")),
2776 "session should own discovered project files"
2777 );
2778 assert_eq!(session.workspaces().len(), 0);
2779 }
2780
2781 #[test]
2782 fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
2783 let project = tempfile::tempdir().expect("create project");
2784 let root = project.path();
2785 let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
2786 for (index, path) in paths.iter().enumerate() {
2787 std::fs::write(path, format!("export const value{index} = {index};\n"))
2788 .expect("write source");
2789 }
2790 let files: Vec<DiscoveredFile> = paths
2791 .iter()
2792 .enumerate()
2793 .map(|(index, path)| DiscoveredFile {
2794 id: FileId(u32::try_from(index).expect("test index fits u32")),
2795 path: path.clone(),
2796 size_bytes: std::fs::metadata(path).expect("source metadata").len(),
2797 })
2798 .collect();
2799 std::fs::remove_file(&paths[1]).expect("remove source after discovery");
2800 let config = session_config(root);
2801
2802 let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
2803
2804 assert_eq!(
2805 parsed
2806 .modules
2807 .iter()
2808 .map(|module| module.file_id)
2809 .collect::<Vec<_>>(),
2810 vec![FileId(0), FileId(2)]
2811 );
2812 let diagnostics = fallow_config::workspace_diagnostics_for(root);
2813 let diagnostic = diagnostics
2814 .iter()
2815 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2816 .expect("source read failure diagnostic");
2817 assert_eq!(diagnostic.path, paths[1]);
2818 assert!(matches!(
2819 diagnostic.kind,
2820 WorkspaceDiagnosticKind::SourceReadFailure { .. }
2821 ));
2822 }
2823
2824 #[test]
2825 fn analysis_session_parses_owned_modules() {
2826 let dir = tempfile::tempdir().expect("create temp dir");
2827 write_session_fixture(dir.path());
2828 let config = session_config(dir.path());
2829
2830 let session = AnalysisSession::new(&config).expect("session setup should succeed");
2831 let parsed = session.parse_modules(false);
2832
2833 assert!(
2834 parsed
2835 .modules
2836 .iter()
2837 .any(|module| session.files()[module.file_id.0 as usize]
2838 .path
2839 .ends_with("src/index.ts")),
2840 "session parsing should return modules keyed to session files"
2841 );
2842 }
2843
2844 #[test]
2845 fn undeclared_workspace_warning_is_singular_for_one_path() {
2846 let root = Path::new("/repo");
2847 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2848 .expect("warning should be rendered");
2849
2850 assert_eq!(
2851 warning,
2852 "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."
2853 );
2854 }
2855
2856 #[test]
2857 fn undeclared_workspace_warning_summarizes_many_paths() {
2858 let root = PathBuf::from("/repo");
2859 let diagnostics = [
2860 "examples/a",
2861 "examples/b",
2862 "examples/c",
2863 "examples/d",
2864 "examples/e",
2865 "examples/f",
2866 ]
2867 .into_iter()
2868 .map(|path| diag(&root, path))
2869 .collect::<Vec<_>>();
2870
2871 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2872 .expect("warning should be rendered");
2873
2874 assert_eq!(
2875 warning,
2876 "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."
2877 );
2878 }
2879
2880 #[test]
2881 fn collect_config_search_roots_includes_file_ancestors_once() {
2882 let root = PathBuf::from("/repo");
2883 let search_roots = collect_config_search_roots(
2884 &root,
2885 &[
2886 root.join("apps/query/src/main.ts"),
2887 root.join("packages/shared/lib/index.ts"),
2888 ],
2889 );
2890
2891 assert_eq!(
2892 search_roots,
2893 vec![
2894 root.clone(),
2895 root.join("apps"),
2896 root.join("apps/query"),
2897 root.join("apps/query/src"),
2898 root.join("packages"),
2899 root.join("packages/shared"),
2900 root.join("packages/shared/lib"),
2901 ]
2902 );
2903 }
2904
2905 #[test]
2906 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2907 let root = PathBuf::from("/repo");
2908 let ui = fallow_config::WorkspaceInfo {
2909 root: root.join("apps/ui"),
2910 name: "ui".to_string(),
2911 is_internal_dependency: false,
2912 };
2913 let api = fallow_config::WorkspaceInfo {
2914 root: root.join("apps/api"),
2915 name: "api".to_string(),
2916 is_internal_dependency: false,
2917 };
2918 let workspace_pkgs = vec![
2919 (
2920 ui,
2921 fallow_config::PackageJson {
2922 name: Some("ui".to_string()),
2923 ..Default::default()
2924 },
2925 ),
2926 (
2927 api,
2928 fallow_config::PackageJson {
2929 name: Some("api".to_string()),
2930 ..Default::default()
2931 },
2932 ),
2933 ];
2934 let files = vec![
2935 root.join("apps/ui/vite.config.ts"),
2936 root.join("apps/ui/src/main.ts"),
2937 root.join("apps/api/src/server.ts"),
2938 root.join("tools/build.ts"),
2939 ];
2940
2941 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2942
2943 assert_eq!(
2944 buckets[0],
2945 vec![
2946 (
2947 root.join("apps/ui/vite.config.ts"),
2948 "vite.config.ts".to_string()
2949 ),
2950 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2951 ]
2952 );
2953 assert_eq!(
2954 buckets[1],
2955 vec![(
2956 root.join("apps/api/src/server.ts"),
2957 "src/server.ts".to_string()
2958 )]
2959 );
2960 }
2961
2962 #[test]
2963 fn workspace_bucketing_preserves_first_declared_match_and_file_order() {
2964 let root = PathBuf::from("/repo");
2965 let parent = root.join("apps");
2966 let child = parent.join("web");
2967 let nested_first = child.join("src/first.ts");
2968 let nested_second = child.join("src/second.ts");
2969 let unmatched = root.join("tools/build.ts");
2970 let files = vec![nested_first.clone(), unmatched, nested_second.clone()];
2971
2972 let normalize = |bucket: &[(PathBuf, String)]| -> Vec<(PathBuf, String)> {
2977 bucket
2978 .iter()
2979 .map(|(path, rel)| (path.clone(), rel.replace('\\', "/")))
2980 .collect()
2981 };
2982
2983 let parent_first = bucket_files_by_workspace_roots(&[&parent, &child, &child], &files);
2984 assert_eq!(
2985 normalize(&parent_first[0]),
2986 vec![
2987 (nested_first.clone(), "web/src/first.ts".to_string()),
2988 (nested_second.clone(), "web/src/second.ts".to_string()),
2989 ]
2990 );
2991 assert!(parent_first[1].is_empty());
2992 assert!(parent_first[2].is_empty());
2993
2994 let child_first = bucket_files_by_workspace_roots(&[&child, &parent], &files);
2995 assert_eq!(
2996 normalize(&child_first[0]),
2997 vec![
2998 (nested_first, "src/first.ts".to_string()),
2999 (nested_second, "src/second.ts".to_string()),
3000 ]
3001 );
3002 assert!(child_first[1].is_empty());
3003 }
3004
3005 #[test]
3006 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
3007 let dir = tempfile::tempdir().expect("create temp dir");
3008 let pkg_good = dir.path().join("packages").join("good");
3009 let pkg_bad = dir.path().join("packages").join("bad");
3010 std::fs::create_dir_all(&pkg_good).unwrap();
3011 std::fs::create_dir_all(&pkg_bad).unwrap();
3012 std::fs::write(
3013 dir.path().join("package.json"),
3014 r#"{"workspaces": ["packages/*"]}"#,
3015 )
3016 .unwrap();
3017 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
3018 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
3019
3020 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
3021 dir.path(),
3022 &globset::GlobSet::empty(),
3023 )
3024 .expect("root package.json is valid");
3025 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
3026 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
3027
3028 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
3029
3030 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3031 let mut malformed = 0;
3032 let mut undeclared_for_bad = 0;
3033 for diag in &diagnostics {
3034 if matches!(
3035 diag.kind,
3036 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
3037 ) && diag.path.ends_with("bad")
3038 {
3039 malformed += 1;
3040 }
3041 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
3042 && diag.path.ends_with("bad")
3043 {
3044 undeclared_for_bad += 1;
3045 }
3046 }
3047 assert_eq!(
3048 malformed, 1,
3049 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
3050 );
3051 assert_eq!(
3052 undeclared_for_bad, 0,
3053 "warn_undeclared_workspaces must NOT re-flag a path that already \
3054 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
3055 );
3056 }
3057}