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