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, PipelineCounters, 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 for package_name in &module.type_package_references {
146 record_graph_package_usage(graph, package_name, module.file_id, true);
147 }
148 }
149}
150
151fn credit_bin_path_references(
157 graph: &mut graph::ModuleGraph,
158 modules: &[extract::ModuleInfo],
159 binaries: &scripts::DependencyBinaries,
160) {
161 for module in modules {
162 for binary in &module.bin_path_references {
163 if let Some(package_name) = binaries.package_for(binary) {
164 record_graph_package_usage(graph, &package_name, module.file_id, false);
165 }
166 }
167 }
168}
169
170#[doc(hidden)]
172pub struct AnalysisOutput {
173 pub results: AnalysisResults,
174 pub timings: Option<PipelineTimings>,
175 pub graph: Option<graph::ModuleGraph>,
176 pub modules: Option<Vec<extract::ModuleInfo>>,
180 pub files: Option<Vec<discover::DiscoveredFile>>,
182 pub script_used_packages: rustc_hash::FxHashSet<String>,
187 pub trace_provenance: fallow_types::trace::TraceProvenance,
190 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
199}
200
201#[derive(Debug, Clone, Copy)]
204#[doc(hidden)]
205pub struct AnalysisParseMetrics {
206 parse_ms: f64,
207 cache_ms: f64,
208 cache_hits: usize,
209 cache_misses: usize,
210 parse_cpu_ms: f64,
211 cache_rejection: Option<CacheRejection>,
212}
213
214fn update_cache(
224 store: &mut cache::CacheStore,
225 modules: &[extract::ModuleInfo],
226 files: &[discover::DiscoveredFile],
227 need_complexity: bool,
228) -> bool {
229 let mut dirty = false;
230 for module in modules {
231 if let Some(file) = files.get(module.file_id.0 as usize) {
232 let fingerprint = file_fingerprint(&file.path);
233 if let Some(cached) = store.get_by_path_only(&file.path)
234 && cached.content_hash == module.content_hash
235 {
236 let stale_metadata = cached.source_fingerprint() != fingerprint;
237 let adds_complexity = need_complexity && !cached.complexity_extracted;
238 if stale_metadata || adds_complexity {
239 let preserved_last_access = cached.last_access_secs;
240 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
241 .then(|| cached.complexity.clone());
242 let mut refreshed =
243 cache::module_to_cached(module, fingerprint, need_complexity);
244 refreshed.last_access_secs = preserved_last_access;
245 if let Some(complexity) = preserved_complexity {
246 refreshed.complexity = complexity;
247 refreshed.complexity_extracted = true;
248 }
249 store.insert(&file.path, refreshed);
250 dirty = true;
251 }
252 continue;
253 }
254 store.insert(
255 &file.path,
256 cache::module_to_cached(module, fingerprint, need_complexity),
257 );
258 dirty = true;
259 }
260 }
261 let removed_stale_paths = store.retain_paths(files);
262 dirty || removed_stale_paths
263}
264
265#[must_use]
273fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
274 config
275 .cache_max_size_mb
276 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
277 (mb as usize).saturating_mul(1024 * 1024)
278 })
279}
280
281fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
283 std::fs::metadata(path).map_or(
284 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
285 |metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
286 )
287}
288
289fn format_undeclared_workspace_warning(
290 root: &Path,
291 undeclared: &[fallow_config::WorkspaceDiagnostic],
292) -> Option<String> {
293 if undeclared.is_empty() {
294 return None;
295 }
296
297 let preview = undeclared
298 .iter()
299 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
300 .map(|diag| {
301 diag.path
302 .strip_prefix(root)
303 .unwrap_or(&diag.path)
304 .display()
305 .to_string()
306 .replace('\\', "/")
307 })
308 .collect::<Vec<_>>();
309 let remaining = undeclared
310 .len()
311 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
312 let tail = if remaining > 0 {
313 format!(" (and {remaining} more)")
314 } else {
315 String::new()
316 };
317 let noun = if undeclared.len() == 1 {
318 "directory with package.json is"
319 } else {
320 "directories with package.json are"
321 };
322 let guidance = if undeclared.len() == 1 {
323 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
324 } else {
325 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
326 };
327
328 Some(format!(
329 "{} {} not declared as {}: {}{}. {}",
330 undeclared.len(),
331 noun,
332 if undeclared.len() == 1 {
333 "a workspace"
334 } else {
335 "workspaces"
336 },
337 preview.join(", "),
338 tail,
339 guidance
340 ))
341}
342
343fn warn_undeclared_workspaces(
344 root: &Path,
345 workspaces_vec: &[fallow_config::WorkspaceInfo],
346 ignore_patterns: &fallow_config::IgnorePatternSet,
347 quiet: bool,
348) {
349 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
350 if undeclared.is_empty() {
351 return;
352 }
353
354 let existing = fallow_config::workspace_diagnostics_for(root);
355 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
356 .iter()
357 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
358 .collect();
359 let undeclared: Vec<_> = undeclared
360 .into_iter()
361 .filter(|diag| {
362 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
363 !already_flagged.contains(&canonical)
364 })
365 .collect();
366 if undeclared.is_empty() {
367 return;
368 }
369
370 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
371
372 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
373 tracing::warn!("{message}");
374 }
375}
376
377#[doc(hidden)]
383#[deprecated(
384 since = "2.76.0",
385 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."
386)]
387pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
388 let output = analyze_full(config, false, false, false, false)?;
389 Ok(output.results)
390}
391
392#[doc(hidden)]
398#[deprecated(
399 since = "2.76.0",
400 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."
401)]
402pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
403 let output = analyze_full(config, false, true, false, false)?;
404 Ok(output.results)
405}
406
407#[doc(hidden)]
413#[deprecated(
414 since = "2.76.0",
415 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."
416)]
417pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
418 analyze_full(config, true, false, false, false)
419}
420
421#[doc(hidden)]
431#[deprecated(
432 since = "2.76.0",
433 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."
434)]
435pub fn analyze_retaining_modules(
436 config: &ResolvedConfig,
437 need_complexity: bool,
438 retain_graph: bool,
439) -> Result<AnalysisOutput, FallowError> {
440 analyze_full(config, retain_graph, false, need_complexity, true)
441}
442
443fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
444 let show_progress = !config.quiet
445 && std::io::IsTerminal::is_terminal(&std::io::stderr())
446 && matches!(
447 config.output,
448 fallow_config::OutputFormat::Human
449 | fallow_config::OutputFormat::Compact
450 | fallow_config::OutputFormat::Markdown
451 );
452 progress::AnalysisProgress::new(show_progress)
453}
454
455fn discover_analysis_workspaces(
456 config: &ResolvedConfig,
457) -> Result<(Vec<fallow_config::WorkspaceInfo>, f64), FallowError> {
458 let t = Instant::now();
459 let (workspaces, diagnostics) =
460 discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
461 .map_err(|error| FallowError::config(error.to_string()))?;
462 fallow_config::stash_workspace_diagnostics(&config.root, diagnostics);
463 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
464 if !workspaces.is_empty() {
465 tracing::info!(count = workspaces.len(), "workspaces discovered");
466 }
467
468 warn_undeclared_workspaces(
469 &config.root,
470 &workspaces,
471 &config.ignore_patterns,
472 config.quiet,
473 );
474
475 Ok((workspaces, workspaces_ms))
476}
477
478struct AnalysisSetup {
482 progress: progress::AnalysisProgress,
483 project: project::ProjectState,
484 root_pkg: Option<PackageJson>,
485 config_candidates: Vec<std::path::PathBuf>,
490 discover_ms: f64,
491 workspaces_ms: f64,
492}
493
494#[derive(Debug, Clone)]
500#[doc(hidden)]
501pub struct AnalysisDiscovery {
502 files: Vec<discover::DiscoveredFile>,
503 workspaces: Vec<fallow_config::WorkspaceInfo>,
504 root_pkg: Option<PackageJson>,
505 config_candidates: Vec<std::path::PathBuf>,
506 discover_ms: f64,
507 workspaces_ms: f64,
508}
509
510impl AnalysisDiscovery {
511 #[must_use]
513 pub fn from_parts(
514 files: Vec<discover::DiscoveredFile>,
515 workspaces: Vec<fallow_config::WorkspaceInfo>,
516 root_pkg: Option<PackageJson>,
517 config_candidates: Vec<std::path::PathBuf>,
518 discover_ms: f64,
519 workspaces_ms: f64,
520 ) -> Self {
521 Self {
522 files,
523 workspaces,
524 root_pkg,
525 config_candidates,
526 discover_ms,
527 workspaces_ms,
528 }
529 }
530
531 #[must_use]
533 fn files(&self) -> &[discover::DiscoveredFile] {
534 &self.files
535 }
536
537 #[must_use]
539 pub fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
540 &self.workspaces
541 }
542
543 #[must_use]
545 pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
546 self.files
547 }
548}
549
550pub(crate) struct AnalysisSession<'a> {
555 config: &'a ResolvedConfig,
556 pipeline_start: Instant,
557 progress: progress::AnalysisProgress,
558 project: project::ProjectState,
559 root_pkg: Option<PackageJson>,
560 config_candidates: Vec<std::path::PathBuf>,
561 discover_ms: f64,
562 workspaces_ms: f64,
563}
564
565impl<'a> AnalysisSession<'a> {
566 fn new(config: &'a ResolvedConfig) -> Result<Self, FallowError> {
567 let pipeline_start = Instant::now();
568 let AnalysisSetup {
569 progress,
570 project,
571 root_pkg,
572 config_candidates,
573 discover_ms,
574 workspaces_ms,
575 } = run_analysis_setup(config)?;
576
577 Ok(Self {
578 config,
579 pipeline_start,
580 progress,
581 project,
582 root_pkg,
583 config_candidates,
584 discover_ms,
585 workspaces_ms,
586 })
587 }
588
589 fn files(&self) -> &[discover::DiscoveredFile] {
590 self.project.files()
591 }
592
593 fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
594 self.project.workspaces()
595 }
596
597 fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage> {
598 load_workspace_packages(self.workspaces())
599 }
600
601 fn run_plugins_and_scripts(
602 &self,
603 workspace_pkgs: &[LoadedWorkspacePackage],
604 ) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
605 run_plugins_and_scripts(&PluginScriptInput {
606 config: self.config,
607 progress: &self.progress,
608 files: self.files(),
609 workspaces: self.workspaces(),
610 root_pkg: self.root_pkg.as_ref(),
611 workspace_pkgs,
612 config_candidates: &self.config_candidates,
613 })
614 }
615
616 fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
617 PreludeTimings {
618 discover_ms: self.discover_ms,
619 workspaces_ms: self.workspaces_ms,
620 plugins_ms,
621 scripts_ms,
622 }
623 }
624
625 fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
626 let t = Instant::now();
627 self.progress
628 .set_stage(&format!("parsing {} files...", self.files().len()));
629 parse_analysis_modules(self.config, self.files(), need_complexity, t)
630 }
631
632 fn run_owned_core(
633 &self,
634 workspace_pkgs: &[LoadedWorkspacePackage],
635 plugin_result: &plugins::AggregatedPluginResult,
636 mut modules: Vec<extract::ModuleInfo>,
637 collect_usages: bool,
638 ) -> OwnedAnalysisCore {
639 let shared = AnalysisCoreSharedInput {
640 config: self.config,
641 progress: &self.progress,
642 files: self.files(),
643 workspaces: self.workspaces(),
644 root_pkg: self.root_pkg.as_ref(),
645 workspace_pkgs,
646 plugin_result,
647 };
648
649 let entry_points = discover_analysis_entry_points(&shared);
650 let mut graph_cache_rejection = None;
651 let (resolved, graph) =
652 match try_load_analysis_graph_cache(&shared, &entry_points, &modules) {
653 Ok(hit) => (
654 TimedResolvedModules {
655 project: hit.project,
656 elapsed_ms: 0.0,
657 },
658 TimedGraph {
659 graph: hit.graph,
660 elapsed_ms: hit.elapsed_ms,
661 },
662 ),
663 Err(rejection) => {
664 graph_cache_rejection = rejection;
665 let resolved = resolve_analysis_imports_timed(&shared, &modules);
666 let graph = build_analysis_graph_timed(
667 &shared,
668 &resolved.project,
669 &entry_points,
670 &modules,
671 );
672 (resolved, graph)
673 }
674 };
675 release_resolution_payloads(&mut modules);
676 let analysis = analyze_dead_code_timed(
677 &shared,
678 &graph.graph,
679 &resolved.project.modules,
680 &modules,
681 collect_usages,
682 entry_points.summary,
683 );
684
685 OwnedAnalysisCore {
686 result: analysis.result,
687 graph: graph.graph,
688 modules,
689 entry_point_count: entry_points.count,
690 entry_points_ms: entry_points.elapsed_ms,
691 entry_point_spans: entry_points.spans,
692 resolve_ms: resolved.elapsed_ms,
693 graph_ms: graph.elapsed_ms,
694 analyze_ms: analysis.elapsed_ms,
695 graph_cache_rejection,
696 resolve_work: resolved.project.work,
697 }
698 }
699
700 fn run_full(
701 self,
702 retain: bool,
703 collect_usages: bool,
704 need_complexity: bool,
705 retain_modules: bool,
706 ) -> Result<AnalysisOutput, FallowError> {
707 let workspace_pkgs = self.load_workspace_packages();
708 let (plugin_result, plugins_ms, scripts_ms) =
709 self.run_plugins_and_scripts(&workspace_pkgs)?;
710
711 let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
712 let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
713 self.progress.finish();
714
715 let profile = full_analysis_pipeline_profile(
716 &self.prelude_timings(plugins_ms, scripts_ms),
717 self.pipeline_start,
718 self.files(),
719 self.workspaces(),
720 &core,
721 &metrics,
722 );
723 trace_pipeline_profile(&profile);
724
725 let mut trace_provenance = plugins::federation_trace_provenance(
726 &self.config.root,
727 self.files(),
728 &plugin_result.federation_sources,
729 &core.modules,
730 );
731 plugins::push_tooling_trace_credits(
732 &mut trace_provenance,
733 &self.config.root,
734 self.workspaces(),
735 &plugin_result,
736 );
737 let mut output = assemble_full_output(
738 core,
739 plugin_result,
740 &profile,
741 self.files(),
742 retain,
743 retain_modules,
744 );
745 output.trace_provenance = trace_provenance;
746 Ok(output)
747 }
748}
749
750fn run_analysis_setup(config: &ResolvedConfig) -> Result<AnalysisSetup, FallowError> {
753 let progress = new_analysis_progress(config);
754
755 let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config)?;
756 let root_pkg = fallow_config::load_dir_package_json(&config.root);
757 let discovery_hidden_dir_scopes =
758 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
759
760 let t = Instant::now();
761 progress.set_stage("discovering files...");
762 let (discovered_files, config_candidates) =
763 discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
764 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
765
766 let project = project::ProjectState::new(discovered_files, workspaces_vec);
767
768 Ok(AnalysisSetup {
769 progress,
770 project,
771 root_pkg,
772 config_candidates,
773 discover_ms,
774 workspaces_ms,
775 })
776}
777
778struct PluginScriptInput<'a> {
780 config: &'a ResolvedConfig,
781 progress: &'a progress::AnalysisProgress,
782 files: &'a [discover::DiscoveredFile],
783 workspaces: &'a [fallow_config::WorkspaceInfo],
784 root_pkg: Option<&'a PackageJson>,
785 workspace_pkgs: &'a [LoadedWorkspacePackage],
786 config_candidates: &'a [std::path::PathBuf],
787}
788
789fn run_plugins_and_scripts(
792 input: &PluginScriptInput<'_>,
793) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
794 let t = Instant::now();
795 input.progress.set_stage("detecting plugins...");
796 let mut plugin_result = run_plugins(
797 input.config,
798 input.files,
799 input.workspaces,
800 input.root_pkg,
801 input.workspace_pkgs,
802 input.config_candidates,
803 )?;
804 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
805
806 let t = Instant::now();
807 analyze_all_scripts(
808 input.config,
809 input.workspaces,
810 input.root_pkg,
811 input.workspace_pkgs,
812 &mut plugin_result,
813 );
814 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
815
816 Ok((plugin_result, plugins_ms, scripts_ms))
817}
818
819#[derive(Debug, Clone, Copy)]
821#[doc(hidden)]
822pub struct DeadCodePreludeTimings {
823 pub discover_ms: f64,
824 pub workspaces_ms: f64,
825 pub plugins_ms: f64,
826 pub scripts_ms: f64,
827}
828
829#[doc(hidden)]
834pub struct DeadCodeBackendPrelude<'a> {
835 config: &'a ResolvedConfig,
836 pipeline_start: Instant,
837 progress: progress::AnalysisProgress,
838 discovery: AnalysisDiscovery,
839 workspace_pkgs: Vec<LoadedWorkspacePackage>,
840 plugin_result: plugins::AggregatedPluginResult,
841 plugins_ms: f64,
842 scripts_ms: f64,
843}
844
845impl DeadCodeBackendPrelude<'_> {
846 #[must_use]
847 pub fn timings(&self) -> DeadCodePreludeTimings {
848 DeadCodePreludeTimings {
849 discover_ms: self.discovery.discover_ms,
850 workspaces_ms: self.discovery.workspaces_ms,
851 plugins_ms: self.plugins_ms,
852 scripts_ms: self.scripts_ms,
853 }
854 }
855
856 #[must_use]
857 pub fn elapsed_ms(&self) -> f64 {
858 self.pipeline_start.elapsed().as_secs_f64() * 1000.0
859 }
860
861 #[must_use]
862 pub fn script_used_packages(&self) -> FxHashSet<String> {
863 self.plugin_result.script_used_packages.clone()
864 }
865
866 #[must_use]
869 pub fn trace_provenance(
870 &self,
871 modules: &[extract::ModuleInfo],
872 ) -> fallow_types::trace::TraceProvenance {
873 let mut provenance = plugins::federation_trace_provenance(
874 &self.config.root,
875 self.discovery.files(),
876 &self.plugin_result.federation_sources,
877 modules,
878 );
879 plugins::push_tooling_trace_credits(
880 &mut provenance,
881 &self.config.root,
882 self.discovery.workspaces(),
883 &self.plugin_result,
884 );
885 provenance
886 }
887
888 #[must_use]
891 pub const fn plugin_result(&self) -> &plugins::AggregatedPluginResult {
892 &self.plugin_result
893 }
894
895 pub fn finish(&self) {
896 self.progress.finish();
897 }
898}
899
900#[doc(hidden)]
902pub struct DeadCodeEntryPoints {
903 inner: TimedEntryPoints,
904}
905
906impl DeadCodeEntryPoints {
907 #[must_use]
908 pub fn count(&self) -> usize {
909 self.inner.count
910 }
911
912 #[must_use]
913 pub fn elapsed_ms(&self) -> f64 {
914 self.inner.elapsed_ms
915 }
916
917 #[must_use]
919 pub fn spans(&self) -> EntryPointSpans {
920 self.inner.spans
921 }
922
923 #[must_use]
925 pub fn all(&self) -> &[discover::EntryPoint] {
926 &self.inner.entry_points.all
927 }
928}
929
930#[doc(hidden)]
932pub struct DeadCodeResolvedModules {
933 pub project: resolve::ResolvedProject,
934 pub elapsed_ms: f64,
935}
936
937#[doc(hidden)]
939pub struct DeadCodeGraphRun {
940 pub graph: graph::ModuleGraph,
941 pub elapsed_ms: f64,
942}
943
944#[doc(hidden)]
946pub struct DeadCodeDetectorRun {
947 pub results: AnalysisResults,
948 pub elapsed_ms: f64,
949}
950
951pub fn prepare_dead_code_backend_prelude(
957 config: &ResolvedConfig,
958 discovery: AnalysisDiscovery,
959) -> Result<DeadCodeBackendPrelude<'_>, FallowError> {
960 let progress = new_analysis_progress(config);
961 let pipeline_start = Instant::now();
962 let workspace_pkgs = load_workspace_packages(&discovery.workspaces);
963 let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
964 config,
965 progress: &progress,
966 files: discovery.files(),
967 workspaces: &discovery.workspaces,
968 root_pkg: discovery.root_pkg.as_ref(),
969 workspace_pkgs: &workspace_pkgs,
970 config_candidates: &discovery.config_candidates,
971 })?;
972
973 Ok(DeadCodeBackendPrelude {
974 config,
975 pipeline_start,
976 progress,
977 discovery,
978 workspace_pkgs,
979 plugin_result,
980 plugins_ms,
981 scripts_ms,
982 })
983}
984
985#[must_use]
987pub fn discover_dead_code_entry_points(
988 prelude: &DeadCodeBackendPrelude<'_>,
989) -> DeadCodeEntryPoints {
990 let shared = prelude.shared_input();
991 DeadCodeEntryPoints {
992 inner: discover_analysis_entry_points(&shared),
993 }
994}
995
996pub fn try_load_dead_code_graph_cache(
1006 prelude: &DeadCodeBackendPrelude<'_>,
1007 entry_points: &DeadCodeEntryPoints,
1008 modules: &[extract::ModuleInfo],
1009) -> Result<(DeadCodeResolvedModules, DeadCodeGraphRun), Option<CacheRejection>> {
1010 let shared = prelude.shared_input();
1011 try_load_analysis_graph_cache(&shared, &entry_points.inner, modules).map(|hit| {
1012 (
1013 DeadCodeResolvedModules {
1014 project: hit.project,
1015 elapsed_ms: 0.0,
1016 },
1017 DeadCodeGraphRun {
1018 graph: hit.graph,
1019 elapsed_ms: hit.elapsed_ms,
1020 },
1021 )
1022 })
1023}
1024
1025#[must_use]
1027pub fn resolve_dead_code_imports(
1028 prelude: &DeadCodeBackendPrelude<'_>,
1029 modules: &[extract::ModuleInfo],
1030) -> DeadCodeResolvedModules {
1031 let shared = prelude.shared_input();
1032 let resolved = resolve_analysis_imports_timed(&shared, modules);
1033 DeadCodeResolvedModules {
1034 project: resolved.project,
1035 elapsed_ms: resolved.elapsed_ms,
1036 }
1037}
1038
1039#[must_use]
1041pub fn build_dead_code_graph(
1042 prelude: &DeadCodeBackendPrelude<'_>,
1043 project: &resolve::ResolvedProject,
1044 entry_points: &DeadCodeEntryPoints,
1045 modules: &[extract::ModuleInfo],
1046) -> DeadCodeGraphRun {
1047 let shared = prelude.shared_input();
1048 let graph = build_analysis_graph_timed(&shared, project, &entry_points.inner, modules);
1049 DeadCodeGraphRun {
1050 graph: graph.graph,
1051 elapsed_ms: graph.elapsed_ms,
1052 }
1053}
1054
1055#[must_use]
1057pub fn run_dead_code_detectors(
1058 prelude: &DeadCodeBackendPrelude<'_>,
1059 graph: &graph::ModuleGraph,
1060 resolved: &[resolve::ResolvedModule],
1061 modules: &[extract::ModuleInfo],
1062 collect_usages: bool,
1063 entry_points: &DeadCodeEntryPoints,
1064) -> DeadCodeDetectorRun {
1065 let shared = prelude.shared_input();
1066 let analysis = analyze_dead_code_timed(
1067 &shared,
1068 graph,
1069 resolved,
1070 modules,
1071 collect_usages,
1072 entry_points.inner.summary.clone(),
1073 );
1074 DeadCodeDetectorRun {
1075 results: analysis.result,
1076 elapsed_ms: analysis.elapsed_ms,
1077 }
1078}
1079
1080impl<'a> DeadCodeBackendPrelude<'a> {
1081 fn shared_input(&'a self) -> AnalysisCoreSharedInput<'a> {
1082 AnalysisCoreSharedInput {
1083 config: self.config,
1084 progress: &self.progress,
1085 files: self.discovery.files(),
1086 workspaces: &self.discovery.workspaces,
1087 root_pkg: self.discovery.root_pkg.as_ref(),
1088 workspace_pkgs: &self.workspace_pkgs,
1089 plugin_result: &self.plugin_result,
1090 }
1091 }
1092}
1093
1094struct PreludeMetrics {
1097 discover_ms: f64,
1098 workspaces_ms: f64,
1099 plugins_ms: f64,
1100 scripts_ms: f64,
1101 total_ms: f64,
1102 file_count: usize,
1103 workspace_count: usize,
1104 module_count: usize,
1105}
1106
1107#[expect(
1109 clippy::struct_field_names,
1110 reason = "timings are all milliseconds; the _ms suffix is the unit"
1111)]
1112struct PreludeTimings {
1113 discover_ms: f64,
1114 workspaces_ms: f64,
1115 plugins_ms: f64,
1116 scripts_ms: f64,
1117}
1118
1119fn prelude_metrics(
1122 timings: &PreludeTimings,
1123 pipeline_start: Instant,
1124 files: &[discover::DiscoveredFile],
1125 workspaces: &[fallow_config::WorkspaceInfo],
1126 module_count: usize,
1127) -> PreludeMetrics {
1128 PreludeMetrics {
1129 discover_ms: timings.discover_ms,
1130 workspaces_ms: timings.workspaces_ms,
1131 plugins_ms: timings.plugins_ms,
1132 scripts_ms: timings.scripts_ms,
1133 total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
1134 file_count: files.len(),
1135 workspace_count: workspaces.len(),
1136 module_count,
1137 }
1138}
1139
1140struct AnalysisCoreSharedInput<'a> {
1141 config: &'a ResolvedConfig,
1142 progress: &'a progress::AnalysisProgress,
1143 files: &'a [discover::DiscoveredFile],
1144 workspaces: &'a [fallow_config::WorkspaceInfo],
1145 root_pkg: Option<&'a PackageJson>,
1146 workspace_pkgs: &'a [LoadedWorkspacePackage],
1147 plugin_result: &'a plugins::AggregatedPluginResult,
1148}
1149
1150struct TimedEntryPoints {
1151 entry_points: discover::CategorizedEntryPoints,
1152 summary: results::EntryPointSummary,
1153 count: usize,
1154 elapsed_ms: f64,
1155 spans: EntryPointSpans,
1156}
1157
1158struct TimedResolvedModules {
1159 project: resolve::ResolvedProject,
1160 elapsed_ms: f64,
1161}
1162
1163struct TimedGraph {
1164 graph: graph::ModuleGraph,
1165 elapsed_ms: f64,
1166}
1167
1168struct GraphCacheHit {
1169 graph: graph::ModuleGraph,
1170 project: resolve::ResolvedProject,
1171 elapsed_ms: f64,
1172}
1173
1174#[derive(Clone, Copy)]
1175struct DiscoverAllEntryPointsInput<'a> {
1176 config: &'a ResolvedConfig,
1177 files: &'a [discover::DiscoveredFile],
1178 workspaces: &'a [fallow_config::WorkspaceInfo],
1179 root_pkg: Option<&'a PackageJson>,
1180 workspace_pkgs: &'a [LoadedWorkspacePackage],
1181 plugin_result: &'a plugins::AggregatedPluginResult,
1182}
1183
1184struct TimedAnalysis {
1185 result: AnalysisResults,
1186 elapsed_ms: f64,
1187}
1188
1189fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1190 let t = Instant::now();
1191 let (entry_points, spans) = discover_all_entry_points(DiscoverAllEntryPointsInput {
1192 config: input.config,
1193 files: input.files,
1194 workspaces: input.workspaces,
1195 root_pkg: input.root_pkg,
1196 workspace_pkgs: input.workspace_pkgs,
1197 plugin_result: input.plugin_result,
1198 });
1199 let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1200 let summary = summarize_entry_points(&entry_points.all);
1201 let count = entry_points.all.len();
1202
1203 TimedEntryPoints {
1204 entry_points,
1205 summary,
1206 count,
1207 elapsed_ms,
1208 spans,
1209 }
1210}
1211
1212fn try_load_analysis_graph_cache(
1222 input: &AnalysisCoreSharedInput<'_>,
1223 entry_points: &TimedEntryPoints,
1224 modules: &[extract::ModuleInfo],
1225) -> Result<GraphCacheHit, Option<CacheRejection>> {
1226 if input.config.no_cache {
1227 return Err(None);
1228 }
1229
1230 let t = Instant::now();
1231 input.progress.set_stage("loading module graph cache...");
1232 let unseeded_declaration_files = orphan_module_declaration_files(input, modules);
1233 let current = build_graph_cache_manifest(
1234 input.config,
1235 input.plugin_result,
1236 &entry_points.entry_points,
1237 input.files,
1238 modules,
1239 &unseeded_declaration_files,
1240 );
1241 let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir).map_err(Some)?;
1242 if store.manifest.matches_inputs(¤t) {
1243 let project = restore_cached_resolved_project(input, modules, &store.resolved_project)?;
1244 tracing::debug!("Graph cache hit: skipping import resolution and graph build");
1245 let mut graph = store.graph;
1246 credit_bin_path_references(
1247 &mut graph,
1248 modules,
1249 &input.plugin_result.dependency_binaries,
1250 );
1251
1252 return Ok(GraphCacheHit {
1253 graph,
1254 project,
1255 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1256 });
1257 }
1258
1259 if let Some(rejection) = store.manifest.classify_resolution_mismatch(¤t) {
1260 if rejection.discarded_existing_work() {
1265 tracing::warn!(
1266 "Graph cache decoded but not reused: {}",
1267 rejection.describe()
1268 );
1269 } else {
1270 tracing::debug!(
1271 "Graph cache decoded but not reused: {}",
1272 rejection.describe()
1273 );
1274 }
1275 return Err(Some(rejection));
1276 }
1277
1278 let project = restore_cached_resolved_project(input, modules, &store.resolved_project)?;
1279 tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
1280 let graph = build_analysis_graph_timed(input, &project, entry_points, modules);
1281
1282 Ok(GraphCacheHit {
1283 graph: graph.graph,
1284 project,
1285 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1286 })
1287}
1288
1289fn restore_cached_resolved_project(
1295 input: &AnalysisCoreSharedInput<'_>,
1296 modules: &[extract::ModuleInfo],
1297 resolved_project: &graph_cache::CachedResolvedProject,
1298) -> Result<resolve::ResolvedProject, Option<CacheRejection>> {
1299 graph_cache::restore_resolved_project(
1300 &input.config.root,
1301 modules,
1302 input.files,
1303 resolved_project,
1304 )
1305 .inspect(|project| {
1306 record_unreadable_auto_import_reads(
1307 project,
1308 input.files,
1309 input.plugin_result,
1310 input.config,
1311 );
1312 })
1313 .ok_or_else(|| {
1314 tracing::debug!(
1318 "Graph cache decoded but its resolver payload no longer maps to the discovered files"
1319 );
1320 Some(CacheRejection::FileSetChanged)
1321 })
1322}
1323
1324fn resolve_analysis_imports_timed(
1325 input: &AnalysisCoreSharedInput<'_>,
1326 modules: &[extract::ModuleInfo],
1327) -> TimedResolvedModules {
1328 let t = Instant::now();
1329 input.progress.set_stage("resolving imports...");
1330 let project = resolve_analysis_imports(
1331 modules,
1332 input.files,
1333 input.workspaces,
1334 input.plugin_result,
1335 input.config,
1336 );
1337 TimedResolvedModules {
1338 project,
1339 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1340 }
1341}
1342
1343fn build_analysis_graph_timed(
1344 input: &AnalysisCoreSharedInput<'_>,
1345 project: &resolve::ResolvedProject,
1346 entry_points: &TimedEntryPoints,
1347 modules: &[extract::ModuleInfo],
1348) -> TimedGraph {
1349 let t = Instant::now();
1350 input.progress.set_stage("building module graph...");
1351 let unseeded_declaration_files = orphan_module_declaration_files(input, modules);
1352 let mut graph = build_analysis_graph(&BuildAnalysisGraphInput {
1353 config: input.config,
1354 plugin_result: input.plugin_result,
1355 project,
1356 entry_points: &entry_points.entry_points,
1357 files: input.files,
1358 modules,
1359 workspaces: input.workspaces,
1360 unseeded_declaration_files: &unseeded_declaration_files,
1361 });
1362 credit_bin_path_references(
1363 &mut graph,
1364 modules,
1365 &input.plugin_result.dependency_binaries,
1366 );
1367 TimedGraph {
1368 graph,
1369 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1370 }
1371}
1372
1373fn orphan_module_declaration_files(
1376 input: &AnalysisCoreSharedInput<'_>,
1377 modules: &[extract::ModuleInfo],
1378) -> FxHashSet<discover::FileId> {
1379 discover::find_orphan_module_declaration_files(&input.config.root, input.files, modules)
1380}
1381
1382fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1383 for module in modules {
1384 module.release_resolution_payload();
1385 }
1386}
1387
1388fn analyze_dead_code_timed(
1389 input: &AnalysisCoreSharedInput<'_>,
1390 graph: &graph::ModuleGraph,
1391 resolved: &[resolve::ResolvedModule],
1392 modules: &[extract::ModuleInfo],
1393 collect_usages: bool,
1394 entry_point_summary: results::EntryPointSummary,
1395) -> TimedAnalysis {
1396 let t = Instant::now();
1397 input.progress.set_stage("analyzing...");
1398 let mut result = analyze::find_dead_code_full(
1399 graph,
1400 input.config,
1401 resolved,
1402 Some(input.plugin_result),
1403 input.workspaces,
1404 modules,
1405 collect_usages,
1406 );
1407 result.entry_point_summary = Some(entry_point_summary);
1408 TimedAnalysis {
1409 result,
1410 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1411 }
1412}
1413
1414fn analyze_full(
1415 config: &ResolvedConfig,
1416 retain: bool,
1417 collect_usages: bool,
1418 need_complexity: bool,
1419 retain_modules: bool,
1420) -> Result<AnalysisOutput, FallowError> {
1421 let _span = tracing::info_span!("fallow_analyze").entered();
1422 AnalysisSession::new(config)?.run_full(retain, collect_usages, need_complexity, retain_modules)
1423}
1424
1425fn full_analysis_pipeline_profile(
1426 timings: &PreludeTimings,
1427 pipeline_start: Instant,
1428 files: &[discover::DiscoveredFile],
1429 workspaces: &[fallow_config::WorkspaceInfo],
1430 core: &OwnedAnalysisCore,
1431 metrics: &ParseMetrics,
1432) -> PipelineProfile {
1433 let prelude = prelude_metrics(
1434 timings,
1435 pipeline_start,
1436 files,
1437 workspaces,
1438 core.modules.len(),
1439 );
1440 full_pipeline_profile(&prelude, core, metrics)
1441}
1442
1443fn assemble_full_output(
1446 core: OwnedAnalysisCore,
1447 plugin_result: plugins::AggregatedPluginResult,
1448 profile: &PipelineProfile,
1449 files: &[discover::DiscoveredFile],
1450 retain: bool,
1451 retain_modules: bool,
1452) -> AnalysisOutput {
1453 let file_hashes = collect_file_hashes(&core.modules, files);
1454 AnalysisOutput {
1455 results: core.result,
1456 timings: retained_pipeline_timings(retain, profile),
1457 graph: if retain { Some(core.graph) } else { None },
1458 modules: if retain_modules {
1459 Some(core.modules)
1460 } else {
1461 None
1462 },
1463 files: if retain_modules {
1464 Some(files.to_vec())
1465 } else {
1466 None
1467 },
1468 script_used_packages: plugin_result.script_used_packages,
1469 trace_provenance: fallow_types::trace::TraceProvenance::default(),
1470 file_hashes,
1471 }
1472}
1473
1474struct OwnedAnalysisCore {
1477 result: AnalysisResults,
1478 graph: graph::ModuleGraph,
1479 modules: Vec<extract::ModuleInfo>,
1480 entry_point_count: usize,
1481 entry_points_ms: f64,
1482 entry_point_spans: EntryPointSpans,
1483 resolve_ms: f64,
1484 graph_ms: f64,
1485 analyze_ms: f64,
1486 graph_cache_rejection: Option<CacheRejection>,
1487 resolve_work: resolve::ResolveWork,
1488}
1489
1490fn full_pipeline_profile(
1492 prelude: &PreludeMetrics,
1493 core: &OwnedAnalysisCore,
1494 parse: &ParseMetrics,
1495) -> PipelineProfile {
1496 PipelineProfile {
1497 discover_ms: prelude.discover_ms,
1498 workspaces_ms: prelude.workspaces_ms,
1499 plugins_ms: prelude.plugins_ms,
1500 scripts_ms: prelude.scripts_ms,
1501 parse_ms: parse.parse_ms,
1502 cache_ms: parse.cache_ms,
1503 entry_points_ms: core.entry_points_ms,
1504 entry_point_spans: core.entry_point_spans,
1505 resolve_ms: core.resolve_ms,
1506 graph_ms: core.graph_ms,
1507 analyze_ms: core.analyze_ms,
1508 total_ms: prelude.total_ms,
1509 file_count: prelude.file_count,
1510 workspace_count: prelude.workspace_count,
1511 module_count: prelude.module_count,
1512 entry_point_count: core.entry_point_count,
1513 cache_hits: parse.cache_hits,
1514 cache_misses: parse.cache_misses,
1515 parse_cpu_ms: parse.parse_cpu_ms,
1516 parse_cache_load_ms: parse.parse_cache_load_ms,
1517 cache_rejection: parse.cache_rejection,
1518 graph_cache_rejection: core.graph_cache_rejection,
1519 counters: PipelineCounters {
1520 files_read: parse.files_read,
1521 source_bytes_read: parse.source_bytes_read,
1522 parse_cache_bytes_read: parse.parse_cache_bytes_read,
1523 css_masked_bytes: parse.css_masked_bytes,
1524 resolve_specifier_calls: core.resolve_work.specifier_calls,
1525 unique_specifiers: core.resolve_work.unique_specifiers,
1526 oxc_resolve_calls: core.resolve_work.oxc_resolve_calls,
1527 canonicalize_calls: core.resolve_work.canonicalize_calls,
1528 },
1529 }
1530}
1531
1532#[derive(Clone, Copy)]
1533struct PipelineProfile {
1534 discover_ms: f64,
1535 workspaces_ms: f64,
1536 plugins_ms: f64,
1537 scripts_ms: f64,
1538 parse_ms: f64,
1539 cache_ms: f64,
1540 entry_points_ms: f64,
1541 entry_point_spans: EntryPointSpans,
1542 resolve_ms: f64,
1543 graph_ms: f64,
1544 analyze_ms: f64,
1545 total_ms: f64,
1546 file_count: usize,
1547 workspace_count: usize,
1548 module_count: usize,
1549 entry_point_count: usize,
1550 cache_hits: usize,
1551 cache_misses: usize,
1552 parse_cpu_ms: f64,
1553 parse_cache_load_ms: f64,
1554 cache_rejection: Option<CacheRejection>,
1555 graph_cache_rejection: Option<CacheRejection>,
1556 counters: PipelineCounters,
1557}
1558
1559struct AnalysisParseOutput {
1560 modules: Vec<extract::ModuleInfo>,
1561 metrics: ParseMetrics,
1562}
1563
1564struct ParseMetrics {
1566 parse_ms: f64,
1567 cache_ms: f64,
1568 cache_hits: usize,
1569 cache_misses: usize,
1570 parse_cpu_ms: f64,
1571 cache_rejection: Option<CacheRejection>,
1573 files_read: u64,
1574 source_bytes_read: u64,
1575 parse_cache_bytes_read: u64,
1576 css_masked_bytes: u64,
1577 parse_cache_load_ms: f64,
1578}
1579
1580impl From<AnalysisParseMetrics> for ParseMetrics {
1581 fn from(metrics: AnalysisParseMetrics) -> Self {
1582 Self {
1583 parse_ms: metrics.parse_ms,
1584 cache_ms: metrics.cache_ms,
1585 cache_hits: metrics.cache_hits,
1586 cache_misses: metrics.cache_misses,
1587 parse_cpu_ms: metrics.parse_cpu_ms,
1588 cache_rejection: metrics.cache_rejection,
1589 files_read: 0,
1590 source_bytes_read: 0,
1591 parse_cache_bytes_read: 0,
1592 css_masked_bytes: 0,
1593 parse_cache_load_ms: 0.0,
1594 }
1595 }
1596}
1597
1598fn parse_analysis_modules(
1599 config: &ResolvedConfig,
1600 files: &[discover::DiscoveredFile],
1601 need_complexity: bool,
1602 start: Instant,
1603) -> AnalysisParseOutput {
1604 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1605 let mut cache_rejection = None;
1606 let mut parse_cache_bytes_read = 0;
1607 let mut parse_cache_load_ms = 0.0;
1608 let mut cache_store = if config.no_cache {
1609 None
1610 } else {
1611 let load_start = Instant::now();
1612 let (loaded, bytes_read) = cache::CacheStore::load_counting_bytes(
1613 &config.cache_dir,
1614 &config.root,
1615 config.cache_config_hash,
1616 cache_max_size_bytes,
1617 );
1618 parse_cache_bytes_read = bytes_read;
1619 parse_cache_load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
1620 match loaded {
1621 Ok(store) => Some(store),
1622 Err(rejection) => {
1623 cache_rejection = Some(rejection);
1624 None
1625 }
1626 }
1627 };
1628
1629 let parse_result = extract::parse_all_files_cancellable(
1630 files,
1631 cache_store.as_ref(),
1632 need_complexity,
1633 None,
1634 &config.flags.patterns(),
1635 );
1636 let _ = fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
1637 let _ = fallow_config::record_source_parse_degradations(
1638 &config.root,
1639 &parse_result.parse_degradations,
1640 );
1641 let modules = parse_result.modules;
1642 let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1643 let cache_ms = update_parse_cache_if_enabled(
1644 config,
1645 &mut cache_store,
1646 &modules,
1647 files,
1648 cache_max_size_bytes,
1649 need_complexity,
1650 );
1651
1652 AnalysisParseOutput {
1653 modules,
1654 metrics: ParseMetrics {
1655 parse_ms,
1656 cache_ms,
1657 cache_hits: parse_result.cache_hits,
1658 cache_misses: parse_result.cache_misses,
1659 parse_cpu_ms: parse_result.parse_cpu_ms,
1660 cache_rejection,
1661 files_read: parse_result.files_read,
1662 source_bytes_read: parse_result.source_bytes_read,
1663 parse_cache_bytes_read,
1664 css_masked_bytes: parse_result.css_masked_bytes,
1665 parse_cache_load_ms,
1666 },
1667 }
1668}
1669
1670fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1671 retain.then_some(PipelineTimings {
1672 discover_files_ms: profile.discover_ms,
1673 file_count: profile.file_count,
1674 workspaces_ms: profile.workspaces_ms,
1675 workspace_count: profile.workspace_count,
1676 plugins_ms: profile.plugins_ms,
1677 script_analysis_ms: profile.scripts_ms,
1678 parse_extract_ms: profile.parse_ms,
1679 parse_cpu_ms: profile.parse_cpu_ms,
1680 parse_cache_load_ms: profile.parse_cache_load_ms,
1681 module_count: profile.module_count,
1682 cache_hits: profile.cache_hits,
1683 cache_misses: profile.cache_misses,
1684 cache_rejection: profile.cache_rejection,
1685 graph_cache_rejection: profile.graph_cache_rejection,
1686 cache_update_ms: profile.cache_ms,
1687 entry_points_ms: profile.entry_points_ms,
1688 entry_point_spans: profile.entry_point_spans,
1689 entry_point_count: profile.entry_point_count,
1690 resolve_imports_ms: profile.resolve_ms,
1691 build_graph_ms: profile.graph_ms,
1692 analyze_ms: profile.analyze_ms,
1693 duplication_ms: None,
1694 total_ms: profile.total_ms,
1695 counters: profile.counters,
1696 })
1697}
1698
1699fn update_parse_cache_if_enabled(
1700 config: &ResolvedConfig,
1701 cache_store: &mut Option<cache::CacheStore>,
1702 modules: &[extract::ModuleInfo],
1703 files: &[discover::DiscoveredFile],
1704 cache_max_size_bytes: usize,
1705 need_complexity: bool,
1706) -> f64 {
1707 let t = Instant::now();
1708 if !config.no_cache {
1709 let store = cache_store.get_or_insert_with(|| cache::CacheStore::new(&config.root));
1710 if update_cache(store, modules, files, need_complexity)
1711 && let Err(error) = store.save(
1712 &config.cache_dir,
1713 config.cache_config_hash,
1714 cache_max_size_bytes,
1715 )
1716 {
1717 tracing::warn!("Failed to save cache: {error}");
1718 }
1719 }
1720 t.elapsed().as_secs_f64() * 1000.0
1721}
1722
1723fn resolve_analysis_imports(
1724 modules: &[extract::ModuleInfo],
1725 files: &[discover::DiscoveredFile],
1726 workspaces: &[fallow_config::WorkspaceInfo],
1727 plugin_result: &plugins::AggregatedPluginResult,
1728 config: &ResolvedConfig,
1729) -> resolve::ResolvedProject {
1730 let mut project = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1731 modules,
1732 files,
1733 workspaces,
1734 active_plugins: &plugin_result.active_plugins,
1735 path_aliases: &plugin_result.path_aliases,
1736 auto_imports: &plugin_result.auto_imports,
1737 scss_include_paths: &plugin_result.scss_include_paths,
1738 static_dir_mappings: &plugin_result.static_dir_mappings,
1739 framework_static_dir_mappings: &plugin_result.framework_static_dir_mappings,
1740 root: &config.root,
1741 extra_conditions: &config.resolve.conditions,
1742 });
1743 external_style_usage::augment_external_style_package_usage(
1744 &mut project.modules,
1745 config,
1746 workspaces,
1747 plugin_result,
1748 );
1749 record_unreadable_auto_import_reads(&project, files, plugin_result, config);
1750 project
1751}
1752
1753fn record_unreadable_auto_import_reads(
1765 project: &resolve::ResolvedProject,
1766 files: &[discover::DiscoveredFile],
1767 plugin_result: &plugins::AggregatedPluginResult,
1768 config: &ResolvedConfig,
1769) {
1770 if !config.auto_imports
1771 || plugin_result.auto_imports.is_empty()
1772 || !plugin_result
1773 .active_plugins
1774 .iter()
1775 .any(|name| name == "nuxt")
1776 {
1777 return;
1778 }
1779 let diagnostics: Vec<fallow_config::WorkspaceDiagnostic> =
1780 resolve::unreadable_auto_import_reads(&project.modules)
1781 .into_iter()
1782 .filter_map(|read| {
1783 let file = files.get(read.file_id.0 as usize)?;
1784 let diagnostic = plugins::PluginConfigDiagnostic::not_modeled(
1785 &file.path,
1786 "nuxt",
1787 read.module,
1788 AUTO_IMPORT_KEY_NOT_MODELED,
1789 );
1790 Some(diagnostic.into_workspace_diagnostic(&config.root))
1791 })
1792 .collect();
1793 fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
1794}
1795
1796struct BuildAnalysisGraphInput<'a> {
1797 config: &'a ResolvedConfig,
1798 plugin_result: &'a plugins::AggregatedPluginResult,
1799 project: &'a resolve::ResolvedProject,
1800 entry_points: &'a discover::CategorizedEntryPoints,
1801 files: &'a [discover::DiscoveredFile],
1802 modules: &'a [extract::ModuleInfo],
1803 workspaces: &'a [fallow_config::WorkspaceInfo],
1804 unseeded_declaration_files: &'a FxHashSet<discover::FileId>,
1805}
1806
1807fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1815 let caching_enabled = !input.config.no_cache;
1816 let current_manifest = caching_enabled.then(|| {
1817 build_graph_cache_manifest(
1818 input.config,
1819 input.plugin_result,
1820 input.entry_points,
1821 input.files,
1822 input.modules,
1823 input.unseeded_declaration_files,
1824 )
1825 });
1826
1827 let mut graph = graph::ModuleGraph::build_with_declaration_seeding(
1828 &input.project.modules,
1829 &input.project.replaced_module_targets,
1830 &graph::ReachabilityRoots {
1831 all: &input.entry_points.all,
1832 runtime: &input.entry_points.runtime,
1833 test: &input.entry_points.test,
1834 },
1835 input.files,
1836 input.unseeded_declaration_files,
1837 );
1838 credit_package_path_references(&mut graph, input.modules);
1839 credit_workspace_package_usage(&mut graph, &input.project.modules, input.workspaces);
1840
1841 if let Some(manifest) = current_manifest {
1842 let Some(resolved_project) =
1843 graph_cache::cache_resolved_project(&input.config.root, input.files, input.project)
1844 else {
1845 return graph;
1846 };
1847 let store = graph_cache::GraphCacheStore {
1848 version: graph_cache::GRAPH_CACHE_VERSION,
1849 manifest,
1850 graph,
1851 resolved_project,
1852 };
1853 store.save(&input.config.cache_dir);
1854 return store.graph;
1859 }
1860
1861 graph
1862}
1863
1864fn build_graph_cache_manifest(
1867 config: &ResolvedConfig,
1868 plugin_result: &plugins::AggregatedPluginResult,
1869 entry_points: &discover::CategorizedEntryPoints,
1870 files: &[discover::DiscoveredFile],
1871 modules: &[extract::ModuleInfo],
1872 unseeded_declaration_files: &FxHashSet<discover::FileId>,
1873) -> graph_cache::GraphCacheManifest {
1874 let mode = graph_cache::GraphCacheMode::new(
1875 resolver_options_hash(config),
1876 entry_points_hash(
1877 entry_points,
1878 files,
1879 unseeded_declaration_files,
1880 &config.root,
1881 ),
1882 plugin_config_hash(plugin_result, &config.root),
1883 );
1884 let mut content_hashes = vec![0u64; files.len()];
1889 for module in modules {
1890 if let Some(slot) = content_hashes.get_mut(module.file_id.0 as usize) {
1891 *slot = module.content_hash;
1892 }
1893 }
1894 graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |file| {
1895 content_hashes
1896 .get(file.id.0 as usize)
1897 .copied()
1898 .unwrap_or_default()
1899 })
1900}
1901
1902fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1920 use std::hash::{Hash, Hasher};
1921 let mut hasher = rustc_hash::FxHasher::default();
1922 config.cache_config_hash.hash(&mut hasher);
1923 config.resolve.conditions.hash(&mut hasher);
1924 hasher.finish()
1925}
1926
1927fn root_relative_key(root: &std::path::Path, path: &std::path::Path) -> String {
1931 path.strip_prefix(root)
1932 .unwrap_or(path)
1933 .to_string_lossy()
1934 .replace('\\', "/")
1935}
1936
1937fn entry_points_hash(
1942 entry_points: &discover::CategorizedEntryPoints,
1943 files: &[discover::DiscoveredFile],
1944 unseeded_declaration_files: &FxHashSet<discover::FileId>,
1945 root: &std::path::Path,
1946) -> u64 {
1947 use std::hash::{Hash, Hasher};
1948 let mut hasher = rustc_hash::FxHasher::default();
1949 for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1950 let mut keys: Vec<String> = role
1951 .iter()
1952 .map(|ep| root_relative_key(root, &ep.path))
1953 .collect();
1954 keys.sort_unstable();
1955 keys.len().hash(&mut hasher);
1956 for key in keys {
1957 key.hash(&mut hasher);
1958 }
1959 }
1960 let mut unseeded: Vec<String> = files
1961 .iter()
1962 .filter(|file| unseeded_declaration_files.contains(&file.id))
1963 .map(|file| root_relative_key(root, &file.path))
1964 .collect();
1965 unseeded.sort_unstable();
1966 unseeded.len().hash(&mut hasher);
1967 for key in unseeded {
1968 key.hash(&mut hasher);
1969 }
1970 hasher.finish()
1971}
1972
1973fn plugin_config_hash(
1976 plugin_result: &plugins::AggregatedPluginResult,
1977 root: &std::path::Path,
1978) -> u64 {
1979 use std::hash::{Hash, Hasher};
1980 let mut hasher = rustc_hash::FxHasher::default();
1981
1982 hash_active_plugins(plugin_result, &mut hasher);
1983 hash_path_aliases(plugin_result, root, &mut hasher);
1984
1985 let mut auto_imports: Vec<AutoImportHashKey<'_>> = plugin_result
1986 .auto_imports
1987 .iter()
1988 .map(|rule| {
1989 let mut scope: Vec<String> = rule
1990 .scope
1991 .iter()
1992 .map(|scope_root| root_relative_key(root, scope_root))
1993 .collect();
1994 scope.sort_unstable();
1995 (
1996 rule.name.as_str(),
1997 root_relative_key(root, &rule.source),
1998 auto_import_kind_rank(rule.kind),
1999 scope,
2000 )
2001 })
2002 .collect();
2003 auto_imports.sort_unstable();
2004 auto_imports.len().hash(&mut hasher);
2005 for key in &auto_imports {
2006 key.hash(&mut hasher);
2007 }
2008
2009 let mut scss_include_paths: Vec<String> = plugin_result
2010 .scss_include_paths
2011 .iter()
2012 .map(|path| root_relative_key(root, path))
2013 .collect();
2014 scss_include_paths.sort_unstable();
2015 scss_include_paths.len().hash(&mut hasher);
2016 for path in scss_include_paths {
2017 path.hash(&mut hasher);
2018 }
2019
2020 let mut static_dir_mappings: Vec<(String, &str)> = plugin_result
2021 .static_dir_mappings
2022 .iter()
2023 .map(|(from_dir, mount)| (root_relative_key(root, from_dir), mount.as_str()))
2024 .collect();
2025 static_dir_mappings.sort_unstable();
2026 static_dir_mappings.len().hash(&mut hasher);
2027 for (from_dir, mount) in static_dir_mappings {
2028 from_dir.hash(&mut hasher);
2029 mount.hash(&mut hasher);
2030 }
2031
2032 hasher.finish()
2033}
2034
2035fn hash_active_plugins(
2036 plugin_result: &plugins::AggregatedPluginResult,
2037 hasher: &mut rustc_hash::FxHasher,
2038) {
2039 use std::hash::Hash;
2040 let mut active: Vec<&str> = plugin_result
2041 .active_plugins
2042 .iter()
2043 .map(String::as_str)
2044 .collect();
2045 active.sort_unstable();
2046 active.len().hash(hasher);
2047 for name in active {
2048 name.hash(hasher);
2049 }
2050}
2051
2052fn hash_path_aliases(
2056 plugin_result: &plugins::AggregatedPluginResult,
2057 root: &std::path::Path,
2058 hasher: &mut rustc_hash::FxHasher,
2059) {
2060 use std::hash::Hash;
2061 let mut aliases: Vec<(&str, String)> = plugin_result
2062 .path_aliases
2063 .iter()
2064 .map(|(prefix, replacement)| {
2065 (
2066 prefix.as_str(),
2067 root_relative_key(root, std::path::Path::new(replacement)),
2068 )
2069 })
2070 .collect();
2071 aliases.sort_unstable();
2072 aliases.len().hash(hasher);
2073 for (prefix, replacement) in aliases {
2074 prefix.hash(hasher);
2075 replacement.hash(hasher);
2076 }
2077}
2078
2079type AutoImportHashKey<'a> = (&'a str, String, u8, Vec<String>);
2082
2083fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
2084 match kind {
2085 fallow_config::AutoImportKind::Named => 0,
2086 fallow_config::AutoImportKind::Default => 1,
2087 fallow_config::AutoImportKind::DefaultComponent => 2,
2088 }
2089}
2090
2091fn collect_file_hashes(
2092 modules: &[extract::ModuleInfo],
2093 files: &[discover::DiscoveredFile],
2094) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
2095 modules
2096 .iter()
2097 .filter_map(|module| {
2098 files
2099 .get(module.file_id.0 as usize)
2100 .map(|file| (file.path.clone(), module.content_hash))
2101 })
2102 .collect()
2103}
2104
2105fn trace_pipeline_profile(profile: &PipelineProfile) {
2106 let PipelineProfile {
2107 discover_ms,
2108 workspaces_ms,
2109 plugins_ms,
2110 scripts_ms,
2111 parse_ms,
2112 cache_ms,
2113 entry_points_ms,
2114 resolve_ms,
2115 graph_ms,
2116 analyze_ms,
2117 total_ms,
2118 file_count,
2119 module_count,
2120 entry_point_count,
2121 cache_hits,
2122 cache_misses,
2123 cache_rejection,
2124 ..
2125 } = *profile;
2126 let cache_summary = cache_rejection.map_or_else(
2127 || format!(" ({cache_hits} cached, {cache_misses} parsed)"),
2128 |rejection| {
2129 format!(
2130 " ({cache_hits} cached, {cache_misses} parsed, cache refused: {})",
2131 rejection.describe()
2132 )
2133 },
2134 );
2135
2136 tracing::debug!(
2137 "\n┌─ Pipeline Profile ─────────────────────────────\n\
2138 │ discover files: {:>8.1}ms ({} files)\n\
2139 │ workspaces: {:>8.1}ms\n\
2140 │ plugin detection: {:>8.1}ms\n\
2141 │ script analysis: {:>8.1}ms\n\
2142 │ parse/extract: {:>8.1}ms ({} modules{})\n\
2143 │ cache update: {:>8.1}ms\n\
2144 │ entry points: {:>8.1}ms ({} entries)\n\
2145 │ resolve imports: {:>8.1}ms\n\
2146 │ build graph: {:>8.1}ms\n\
2147 │ analyze: {:>8.1}ms\n\
2148 │ ────────────────────────────────────────────\n\
2149 │ TOTAL: {:>8.1}ms\n\
2150 └─────────────────────────────────────────────────",
2151 discover_ms,
2152 file_count,
2153 workspaces_ms,
2154 plugins_ms,
2155 scripts_ms,
2156 parse_ms,
2157 module_count,
2158 cache_summary,
2159 cache_ms,
2160 entry_points_ms,
2161 entry_point_count,
2162 resolve_ms,
2163 graph_ms,
2164 analyze_ms,
2165 total_ms,
2166 );
2167}
2168
2169fn load_workspace_packages(
2170 workspaces: &[fallow_config::WorkspaceInfo],
2171) -> Vec<LoadedWorkspacePackage> {
2172 workspaces
2173 .iter()
2174 .filter_map(|ws| {
2175 fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
2176 })
2177 .collect()
2178}
2179
2180fn analyze_all_scripts(
2185 config: &ResolvedConfig,
2186 workspaces: &[fallow_config::WorkspaceInfo],
2187 root_pkg: Option<&PackageJson>,
2188 workspace_pkgs: &[LoadedWorkspacePackage],
2189 plugin_result: &mut plugins::AggregatedPluginResult,
2190) {
2191 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
2192 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
2193 let workspace_packages =
2194 discover::collect_workspace_packages(&config.root, root_pkg, workspace_pkgs);
2195 let all_scripts = collect_all_scripts(root_pkg, workspace_pkgs, &workspace_packages);
2196
2197 let nm_roots = collect_node_modules_roots(config, workspaces);
2198 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
2199 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2200 let deps = ScriptDependencyContext {
2201 bin_map: &bin_map,
2202 all_dep_set: &all_dep_set,
2203 workspace_packages: &workspace_packages,
2204 registry: ®istry,
2205 };
2206
2207 analyze_root_scripts(config, root_pkg, &deps, plugin_result);
2208 analyze_workspace_scripts(config, workspace_pkgs, &deps, plugin_result);
2209 analyze_ci_scripts(config, &bin_map, &all_dep_set, &all_scripts, plugin_result);
2210 analyze_hook_scripts(
2211 config,
2212 workspaces,
2213 &scripts::hooks::HookContext {
2214 bin_map: &bin_map,
2215 declared_packages: &all_dep_set,
2216 scripts: &all_scripts,
2217 ignored: scripts::IgnoredCommandEntries::new(&config.ignore_command_entries),
2218 },
2219 plugin_result,
2220 );
2221 plugin_result.dependency_binaries =
2222 scripts::DependencyBinaries::new(config.root.clone(), bin_map, all_dep_set);
2223 plugin_result.dev_dependency_names = root_pkg
2224 .into_iter()
2225 .chain(workspace_pkgs.iter().map(|(_, ws_pkg)| ws_pkg))
2226 .flat_map(PackageJson::dev_dependency_names)
2227 .collect();
2228
2229 plugin_result
2230 .entry_point_roles
2231 .entry("scripts".to_string())
2232 .or_insert(EntryPointRole::Support);
2233}
2234
2235fn collect_all_dependency_names(
2237 root_pkg: Option<&PackageJson>,
2238 workspace_pkgs: &[LoadedWorkspacePackage],
2239) -> Vec<String> {
2240 let mut all_dep_names: Vec<String> = Vec::new();
2241 if let Some(pkg) = root_pkg {
2242 all_dep_names.extend(pkg.all_dependency_names());
2243 }
2244 for (_, ws_pkg) in workspace_pkgs {
2245 all_dep_names.extend(ws_pkg.all_dependency_names());
2246 }
2247 all_dep_names.sort_unstable();
2248 all_dep_names.dedup();
2249 all_dep_names
2250}
2251
2252struct ScriptDependencyContext<'a> {
2255 bin_map: &'a rustc_hash::FxHashMap<String, String>,
2256 all_dep_set: &'a FxHashSet<String>,
2257 workspace_packages: &'a std::sync::Arc<scripts::WorkspacePackages>,
2258 registry: &'a plugins::PluginRegistry,
2259}
2260
2261fn workspace_dir(root: &std::path::Path, ws: &fallow_config::WorkspaceInfo) -> String {
2264 ws.root
2265 .strip_prefix(root)
2266 .unwrap_or(&ws.root)
2267 .to_string_lossy()
2268 .replace('\\', "/")
2269}
2270
2271fn collect_all_scripts(
2273 root_pkg: Option<&PackageJson>,
2274 workspace_pkgs: &[LoadedWorkspacePackage],
2275 workspace_packages: &std::sync::Arc<scripts::WorkspacePackages>,
2276) -> scripts::ScriptCatalog {
2277 let mut catalog = scripts::ScriptCatalog::default();
2278 if let Some(pkg) = root_pkg
2279 && let Some(ref pkg_scripts) = pkg.scripts
2280 {
2281 catalog.merge_scripts(pkg_scripts);
2282 }
2283 for (_, ws_pkg) in workspace_pkgs {
2284 if let Some(ref ws_scripts) = ws_pkg.scripts {
2285 catalog.merge_workspace_scripts(ws_scripts);
2286 }
2287 }
2288 catalog.with_workspaces(std::sync::Arc::clone(workspace_packages), "")
2289}
2290
2291fn collect_node_modules_roots<'a>(
2293 config: &'a ResolvedConfig,
2294 workspaces: &'a [fallow_config::WorkspaceInfo],
2295) -> Vec<&'a std::path::Path> {
2296 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
2297 if config.root.join("node_modules").is_dir() {
2298 nm_roots.push(&config.root);
2299 }
2300 for ws in workspaces {
2301 if ws.root.join("node_modules").is_dir() {
2302 nm_roots.push(&ws.root);
2303 }
2304 }
2305 nm_roots
2306}
2307
2308fn analyze_root_scripts(
2310 config: &ResolvedConfig,
2311 root_pkg: Option<&PackageJson>,
2312 deps: &ScriptDependencyContext<'_>,
2313 plugin_result: &mut plugins::AggregatedPluginResult,
2314) {
2315 let Some(pkg) = root_pkg else {
2316 return;
2317 };
2318 let Some(ref pkg_scripts) = pkg.scripts else {
2319 return;
2320 };
2321 let scripts_to_analyze = if config.production {
2322 scripts::filter_production_scripts(pkg_scripts)
2323 } else {
2324 pkg_scripts.clone()
2325 };
2326 let catalog =
2327 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze)
2328 .with_workspaces(std::sync::Arc::clone(deps.workspace_packages), "");
2329 let script_analysis = scripts::analyze_scripts_with_dependency_context(
2330 &scripts_to_analyze,
2331 &config.root,
2332 deps.bin_map,
2333 deps.all_dep_set,
2334 &catalog,
2335 scripts::IgnoredCommandEntries::new(&config.ignore_command_entries),
2336 );
2337 plugin_result.script_used_packages = script_analysis.used_packages;
2338 plugin_result.merge_into(deps.registry.resolve_script_config_files(
2339 &script_analysis.binary_config_files,
2340 &config.root,
2341 "",
2342 ));
2343
2344 for config_file in &script_analysis.config_files {
2345 plugin_result
2346 .discovered_always_used
2347 .push((config_file.clone(), "scripts".to_string()));
2348 }
2349 for entry in &script_analysis.entry_files {
2350 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2351 plugin_result
2352 .entry_patterns
2353 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2354 }
2355 }
2356}
2357
2358type WsScriptOut = (
2360 Vec<String>,
2361 Vec<(String, String)>,
2362 Vec<(plugins::PathRule, String)>,
2363 plugins::AggregatedPluginResult,
2364);
2365
2366fn analyze_workspace_scripts(
2367 config: &ResolvedConfig,
2368 workspace_pkgs: &[LoadedWorkspacePackage],
2369 deps: &ScriptDependencyContext<'_>,
2370 plugin_result: &mut plugins::AggregatedPluginResult,
2371) {
2372 let ws_results: Vec<WsScriptOut> = workspace_pkgs
2373 .par_iter()
2374 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, deps))
2375 .collect();
2376 for (used_packages, discovered_always_used, entry_patterns, config_result) in ws_results {
2377 plugin_result.script_used_packages.extend(used_packages);
2378 plugin_result.merge_into(config_result);
2379 plugin_result
2380 .discovered_always_used
2381 .extend(discovered_always_used);
2382 plugin_result.entry_patterns.extend(entry_patterns);
2383 }
2384}
2385
2386fn analyze_one_workspace_scripts(
2390 config: &ResolvedConfig,
2391 ws: &fallow_config::WorkspaceInfo,
2392 ws_pkg: &PackageJson,
2393 deps: &ScriptDependencyContext<'_>,
2394) -> WsScriptOut {
2395 let mut used_packages = Vec::new();
2396 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
2397 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
2398 let Some(ref ws_scripts) = ws_pkg.scripts else {
2399 return (
2400 used_packages,
2401 discovered_always_used,
2402 entry_patterns,
2403 plugins::AggregatedPluginResult::default(),
2404 );
2405 };
2406 let scripts_to_analyze = if config.production {
2407 scripts::filter_production_scripts(ws_scripts)
2408 } else {
2409 ws_scripts.clone()
2410 };
2411 let ws_prefix = workspace_dir(&config.root, ws);
2412 let catalog = scripts::ScriptCatalog::from_scripts_with_bodies(ws_scripts, &scripts_to_analyze)
2413 .with_workspaces(std::sync::Arc::clone(deps.workspace_packages), &ws_prefix);
2414 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
2415 &scripts_to_analyze,
2416 &ws.root,
2417 deps.bin_map,
2418 deps.all_dep_set,
2419 &catalog,
2420 scripts::IgnoredCommandEntries::new(&config.ignore_command_entries),
2421 );
2422 used_packages.extend(ws_analysis.used_packages);
2423 let config_result = deps.registry.resolve_script_config_files(
2424 &ws_analysis.binary_config_files,
2425 &ws.root,
2426 &ws_prefix,
2427 );
2428
2429 for config_file in &ws_analysis.config_files {
2430 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
2431 }
2432 for entry in &ws_analysis.entry_files {
2433 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
2434 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
2435 }
2436 }
2437 (
2438 used_packages,
2439 discovered_always_used,
2440 entry_patterns,
2441 config_result,
2442 )
2443}
2444
2445fn analyze_ci_scripts(
2447 config: &ResolvedConfig,
2448 bin_map: &rustc_hash::FxHashMap<String, String>,
2449 all_dep_set: &FxHashSet<String>,
2450 all_scripts: &scripts::ScriptCatalog,
2451 plugin_result: &mut plugins::AggregatedPluginResult,
2452) {
2453 let ci_analysis = scripts::ci::analyze_ci_files(
2454 &config.root,
2455 bin_map,
2456 all_dep_set,
2457 all_scripts,
2458 scripts::IgnoredCommandEntries::new(&config.ignore_command_entries),
2459 );
2460 plugin_result
2461 .script_used_packages
2462 .extend(ci_analysis.used_packages);
2463 for entry in &ci_analysis.entry_files {
2464 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2465 plugin_result
2466 .entry_patterns
2467 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2468 }
2469 }
2470}
2471
2472fn analyze_hook_scripts(
2478 config: &ResolvedConfig,
2479 workspaces: &[fallow_config::WorkspaceInfo],
2480 context: &scripts::hooks::HookContext<'_>,
2481 plugin_result: &mut plugins::AggregatedPluginResult,
2482) {
2483 if config.production {
2484 return;
2485 }
2486 let roots = std::iter::once(config.root.as_path()).chain(
2487 workspaces
2488 .iter()
2489 .map(|ws| ws.root.as_path())
2490 .filter(|root| *root != config.root.as_path()),
2491 );
2492 for root in roots {
2493 plugin_result
2494 .script_used_packages
2495 .extend(scripts::hooks::analyze_hook_files(root, context));
2496 }
2497}
2498
2499fn discover_all_entry_points(
2501 input: DiscoverAllEntryPointsInput<'_>,
2502) -> (discover::CategorizedEntryPoints, EntryPointSpans) {
2503 let mut spans = EntryPointSpans::default();
2504 let mut mark = Instant::now();
2505 let mut entry_points = discover::CategorizedEntryPoints::default();
2506 let workspace_packages = discover::collect_workspace_packages(
2509 &input.config.root,
2510 input.root_pkg,
2511 input.workspace_pkgs,
2512 );
2513 let runtime_seeds = discover::workspace_runtime_script_seeds(&workspace_packages);
2514 let discovered = discover::DiscoveredPaths::new(input.files);
2515 let is_discovered = |path: &std::path::Path| discovered.contains(path);
2516 let script_workspaces = discover::ScriptWorkspaces {
2517 packages: &workspace_packages,
2518 project_root: &input.config.root,
2519 runtime_seeds: &runtime_seeds,
2520 discovered: Some(&is_discovered),
2521 };
2522 let root_discovery = discover::discover_root_entry_points(
2523 input.config,
2524 input.files,
2525 input.root_pkg,
2526 input.workspaces.is_empty(),
2527 script_workspaces,
2528 );
2529 spans.root_ms = split_ms(&mut mark);
2530
2531 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> = input
2532 .workspace_pkgs
2533 .iter()
2534 .map(|(ws, pkg)| (ws.root.clone(), pkg))
2535 .collect();
2536 let workspace_discovery: Vec<discover::EntryPointDiscovery> = input
2537 .workspaces
2538 .par_iter()
2539 .map(|ws| {
2540 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2541 discover::discover_workspace_package_entry_points(
2542 &ws.root,
2543 input.files,
2544 pkg,
2545 scripts::IgnoredCommandEntries::new(&input.config.ignore_command_entries),
2546 script_workspaces,
2547 )
2548 })
2549 .collect();
2550 let mut skipped_entries = rustc_hash::FxHashMap::default();
2551 entry_points.extend_runtime(root_discovery.entries);
2552 entry_points.extend_support(root_discovery.support_entries);
2553 for (path, count) in root_discovery.skipped_entries {
2554 *skipped_entries.entry(path).or_insert(0) += count;
2555 }
2556 let mut ws_entries = Vec::new();
2557 let mut ws_support_entries = Vec::new();
2558 for workspace in workspace_discovery {
2559 ws_entries.extend(workspace.entries);
2560 ws_support_entries.extend(workspace.support_entries);
2561 for (path, count) in workspace.skipped_entries {
2562 *skipped_entries.entry(path).or_insert(0) += count;
2563 }
2564 }
2565 discover::warn_skipped_entry_summary(&skipped_entries);
2566 entry_points.extend_runtime(ws_entries);
2567 entry_points.extend_support(ws_support_entries);
2568 spans.workspaces_ms = split_ms(&mut mark);
2569
2570 let plugin_entries = discover::discover_plugin_entry_point_sets_timed(
2571 input.plugin_result,
2572 input.config,
2573 input.files,
2574 );
2575 spans.plugin_glob_build_ms = plugin_entries.build_ms;
2576 spans.plugin_glob_match_ms = plugin_entries.match_ms;
2577 entry_points.extend(plugin_entries.entries);
2578 spans.plugins_ms = split_ms(&mut mark);
2579
2580 let all_scripts =
2583 collect_all_scripts(input.root_pkg, input.workspace_pkgs, &workspace_packages);
2584 let infra_entries = discover::discover_infrastructure_entry_points(
2585 &input.config.root,
2586 discover::CommandRefContext {
2587 ignored: scripts::IgnoredCommandEntries::new(&input.config.ignore_command_entries),
2588 scripts: &all_scripts,
2589 },
2590 );
2591 entry_points.extend_runtime(infra_entries);
2592 spans.infrastructure_ms = split_ms(&mut mark);
2593
2594 if !input.config.dynamically_loaded.is_empty() {
2595 let dynamic_entries =
2596 discover::discover_dynamically_loaded_entry_points(input.config, input.files);
2597 entry_points.extend_runtime(dynamic_entries);
2598 }
2599 spans.dynamic_ms = split_ms(&mut mark);
2600
2601 let deduped = entry_points.dedup();
2602 spans.dedup_ms = split_ms(&mut mark);
2603 (deduped, spans)
2604}
2605
2606fn split_ms(mark: &mut Instant) -> f64 {
2611 let now = Instant::now();
2612 let elapsed = now.duration_since(*mark).as_secs_f64() * 1000.0;
2613 *mark = now;
2614 elapsed
2615}
2616
2617fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2619 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2620 for ep in entry_points {
2621 let category = match &ep.source {
2622 discover::EntryPointSource::PackageJsonMain
2623 | discover::EntryPointSource::PackageJsonModule
2624 | discover::EntryPointSource::PackageJsonExports
2625 | discover::EntryPointSource::PackageJsonBin
2626 | discover::EntryPointSource::PackageJsonScript => "package.json",
2627 discover::EntryPointSource::Plugin { .. } => "plugin",
2628 discover::EntryPointSource::TestFile => "test file",
2629 discover::EntryPointSource::DefaultIndex => "default index",
2630 discover::EntryPointSource::ManualEntry => "manual entry",
2631 discover::EntryPointSource::InfrastructureConfig => "config",
2632 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2633 };
2634 *counts.entry(category.to_string()).or_insert(0) += 1;
2635 }
2636 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2637 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2638 results::EntryPointSummary {
2639 total: entry_points.len(),
2640 by_source,
2641 }
2642}
2643
2644fn append_package_file_asset_patterns(
2645 result: &mut plugins::AggregatedPluginResult,
2646 prefix: &str,
2647 pkg: &PackageJson,
2648) {
2649 let prefix = prefix.trim_matches('/');
2650 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2651 let pattern = if prefix.is_empty() {
2652 pattern
2653 } else {
2654 format!("{prefix}/{pattern}")
2655 };
2656 result
2657 .discovered_always_used
2658 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2659 }
2660}
2661
2662fn append_workspace_package_file_asset_patterns(
2663 result: &mut plugins::AggregatedPluginResult,
2664 config: &ResolvedConfig,
2665 workspace_pkgs: &[LoadedWorkspacePackage],
2666) {
2667 for (ws, ws_pkg) in workspace_pkgs {
2668 let ws_prefix = ws
2669 .root
2670 .strip_prefix(&config.root)
2671 .unwrap_or(&ws.root)
2672 .to_string_lossy()
2673 .replace('\\', "/");
2674 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2675 }
2676}
2677
2678fn run_plugins(
2680 config: &ResolvedConfig,
2681 files: &[discover::DiscoveredFile],
2682 workspaces: &[fallow_config::WorkspaceInfo],
2683 root_pkg: Option<&PackageJson>,
2684 workspace_pkgs: &[LoadedWorkspacePackage],
2685 config_candidates: &[std::path::PathBuf],
2686) -> Result<plugins::AggregatedPluginResult, FallowError> {
2687 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2688 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2689
2690 let candidate_index = (!config.production).then(|| {
2695 plugins::registry::ConfigCandidateIndex::build(
2696 file_paths
2697 .iter()
2698 .map(std::path::PathBuf::as_path)
2699 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2700 )
2701 });
2702
2703 let mut result = run_root_plugins(
2704 ®istry,
2705 config,
2706 root_pkg,
2707 &file_paths,
2708 candidate_index.as_ref(),
2709 )?;
2710
2711 if workspaces.is_empty() {
2712 share_auto_imports_across_layers(&mut result, config, workspaces);
2713 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2714 record_plugin_config_diagnostics(&result, &config.root);
2715 return Ok(result);
2716 }
2717
2718 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2719
2720 let ws_results = run_workspace_plugins(
2721 ®istry,
2722 config,
2723 workspace_pkgs,
2724 &file_paths,
2725 &result.active_plugins,
2726 candidate_index.as_ref(),
2727 );
2728 merge_workspace_plugin_results(&mut result, ws_results)?;
2729
2730 share_auto_imports_across_layers(&mut result, config, workspaces);
2731 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2732 record_plugin_config_diagnostics(&result, &config.root);
2733
2734 Ok(result)
2735}
2736
2737fn record_plugin_config_diagnostics(result: &plugins::AggregatedPluginResult, root: &Path) {
2749 let diagnostics = result
2750 .config_diagnostics
2751 .iter()
2752 .cloned()
2753 .map(|diagnostic| diagnostic.into_workspace_diagnostic(root))
2754 .collect();
2755 let _ = fallow_config::record_plugin_config_diagnostics(root, diagnostics);
2756}
2757
2758type WorkspacePluginResult = Result<
2759 (plugins::AggregatedPluginResult, String),
2760 Vec<plugins::registry::PluginRegexValidationError>,
2761>;
2762
2763fn run_root_plugins(
2765 registry: &plugins::PluginRegistry,
2766 config: &ResolvedConfig,
2767 root_pkg: Option<&PackageJson>,
2768 file_paths: &[std::path::PathBuf],
2769 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2770) -> Result<plugins::AggregatedPluginResult, FallowError> {
2771 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2772 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2773 .iter()
2774 .map(std::path::PathBuf::as_path)
2775 .collect();
2776
2777 let mut result = if let Some(pkg) = root_pkg {
2778 registry
2779 .try_run_with_search_roots(
2780 pkg,
2781 &config.root,
2782 file_paths,
2783 &root_config_search_root_refs,
2784 config.production,
2785 candidate_index,
2786 )
2787 .map_err(|errors| {
2788 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2789 })?
2790 } else {
2791 plugins::AggregatedPluginResult::default()
2792 };
2793 if let Some(pkg) = root_pkg {
2794 append_package_file_asset_patterns(&mut result, "", pkg);
2795 }
2796 Ok(result)
2797}
2798
2799fn run_workspace_plugins(
2802 registry: &plugins::PluginRegistry,
2803 config: &ResolvedConfig,
2804 workspace_pkgs: &[LoadedWorkspacePackage],
2805 file_paths: &[std::path::PathBuf],
2806 root_active_plugins: &[String],
2807 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2808) -> Vec<WorkspacePluginResult> {
2809 let root_active_plugins: rustc_hash::FxHashSet<&str> =
2810 root_active_plugins.iter().map(String::as_str).collect();
2811
2812 let precompiled_matchers = registry.precompile_config_matchers();
2813 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2814
2815 workspace_pkgs
2816 .par_iter()
2817 .zip(workspace_relative_files.par_iter())
2818 .filter_map(|((ws, ws_pkg), relative_files)| {
2819 let ws_result =
2820 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2821 pkg: ws_pkg,
2822 root: &ws.root,
2823 project_root: &config.root,
2824 precompiled_config_matchers: &precompiled_matchers,
2825 relative_files,
2826 skip_config_plugins: &root_active_plugins,
2827 production_mode: config.production,
2828 candidate_index,
2829 }) {
2830 Ok(result) => result,
2831 Err(errors) => return Some(Err(errors)),
2832 };
2833 if ws_result.active_plugins.is_empty() {
2834 return None;
2835 }
2836 Some(Ok((ws_result, workspace_prefix(&config.root, &ws.root))))
2837 })
2838 .collect::<Vec<_>>()
2839}
2840
2841fn merge_workspace_plugin_results(
2844 result: &mut plugins::AggregatedPluginResult,
2845 ws_results: Vec<WorkspacePluginResult>,
2846) -> Result<(), FallowError> {
2847 let mut regex_errors = Vec::new();
2848 for ws_result in ws_results {
2849 match ws_result {
2850 Ok((mut ws_result, ws_prefix)) => {
2851 ws_result.apply_workspace_prefix(&ws_prefix);
2852 ws_result.config_patterns.clear();
2853 ws_result.script_used_packages.clear();
2854 result.merge_into(ws_result);
2855 }
2856 Err(mut errors) => regex_errors.append(&mut errors),
2857 }
2858 }
2859 if !regex_errors.is_empty() {
2860 return Err(FallowError::config(
2861 plugins::registry::format_plugin_regex_errors(®ex_errors),
2862 ));
2863 }
2864 Ok(())
2865}
2866
2867fn workspace_prefix(root: &Path, workspace_root: &Path) -> String {
2871 workspace_root
2872 .strip_prefix(root)
2873 .unwrap_or(workspace_root)
2874 .to_string_lossy()
2875 .into_owned()
2876}
2877
2878fn share_auto_imports_across_layers(
2894 result: &mut plugins::AggregatedPluginResult,
2895 config: &ResolvedConfig,
2896 workspaces: &[fallow_config::WorkspaceInfo],
2897) {
2898 if result.auto_imports.is_empty() || !result.active_plugins.iter().any(|name| name == "nuxt") {
2899 return;
2900 }
2901 let links = layer_links(config, workspaces);
2902 if links.is_empty() {
2903 return;
2904 }
2905 let mut related: rustc_hash::FxHashMap<PathBuf, Vec<PathBuf>> =
2906 rustc_hash::FxHashMap::default();
2907 for rule in &mut result.auto_imports {
2908 let declared = rule.scope.clone();
2909 for root in &declared {
2910 let roots = related.entry(root.clone()).or_insert_with(|| {
2911 let mut roots = reachable_roots(root, &links, |(app, layer)| (app, layer));
2912 roots.extend(reachable_roots(root, &links, |(app, layer)| (layer, app)));
2913 roots
2914 });
2915 for extra in roots.iter() {
2916 if !rule.scope.contains(extra) {
2917 rule.scope.push(extra.clone());
2918 }
2919 }
2920 }
2921 }
2922}
2923
2924fn reachable_roots<'a>(
2928 start: &Path,
2929 links: &'a [(PathBuf, PathBuf)],
2930 direction: impl Fn(&'a (PathBuf, PathBuf)) -> (&'a PathBuf, &'a PathBuf),
2931) -> Vec<PathBuf> {
2932 let mut found: Vec<PathBuf> = Vec::new();
2933 let mut pending: Vec<&Path> = vec![start];
2934 while let Some(current) = pending.pop() {
2935 for link in links {
2936 let (from, to) = direction(link);
2937 if from.as_path() == current && to.as_path() != start && !found.contains(to) {
2938 found.push(to.clone());
2939 pending.push(to.as_path());
2940 }
2941 }
2942 }
2943 found
2944}
2945
2946fn layer_links(
2950 config: &ResolvedConfig,
2951 workspaces: &[fallow_config::WorkspaceInfo],
2952) -> Vec<(PathBuf, PathBuf)> {
2953 let roots_by_name: rustc_hash::FxHashMap<&str, &Path> = workspaces
2954 .iter()
2955 .map(|ws| (ws.name.as_str(), ws.root.as_path()))
2956 .collect();
2957 let app_roots =
2958 std::iter::once(config.root.as_path()).chain(workspaces.iter().map(|ws| ws.root.as_path()));
2959 let mut links: Vec<(PathBuf, PathBuf)> = Vec::new();
2960 for app in app_roots {
2961 let package_layers = plugins::nuxt::package_layer_names(app)
2962 .into_iter()
2963 .filter_map(|name| {
2964 roots_by_name
2965 .get(name.as_str())
2966 .map(|root| root.to_path_buf())
2967 });
2968 for layer in plugins::nuxt::outside_layer_roots(app)
2969 .into_iter()
2970 .chain(package_layers)
2971 {
2972 let link = (app.to_path_buf(), layer);
2973 if link.0 != link.1 && !links.contains(&link) {
2974 links.push(link);
2975 }
2976 }
2977 }
2978 links
2979}
2980
2981fn gate_auto_import_entry_patterns(
3000 result: &mut plugins::AggregatedPluginResult,
3001 config: &ResolvedConfig,
3002 workspaces: &[fallow_config::WorkspaceInfo],
3003) {
3004 if !config.auto_imports {
3005 return;
3006 }
3007 if !result.active_plugins.iter().any(|name| name == "nuxt") {
3008 return;
3009 }
3010 let root_settings = plugins::nuxt::auto_import_settings(&config.root);
3011 let workspace_settings: Vec<_> = workspaces
3012 .iter()
3013 .map(|ws| {
3014 (
3015 workspace_prefix(&config.root, &ws.root),
3016 plugins::nuxt::auto_import_settings(&ws.root),
3017 )
3018 })
3019 .collect();
3020 let mut retained: Vec<plugins::PluginConfigDiagnostic> = Vec::new();
3021 result.entry_patterns.retain(|(rule, plugin)| {
3022 if plugin != "nuxt" {
3023 return true;
3024 }
3025 let setting =
3026 settings_for_entry_pattern(&root_settings, &workspace_settings, &rule.pattern);
3027 if plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
3028 if !setting.components.is_custom() {
3029 return false;
3030 }
3031 record_retained_auto_import_surface(
3032 &mut retained,
3033 &setting.components_origin,
3034 "components",
3035 );
3036 return true;
3037 }
3038 if plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
3039 if !setting.scripts.is_custom() {
3040 return false;
3041 }
3042 record_retained_auto_import_surface(&mut retained, &setting.scripts_origin, "imports");
3043 return true;
3044 }
3045 true
3046 });
3047 result.config_diagnostics.extend(retained);
3048}
3049
3050const AUTO_IMPORT_PROPERTY_UNREADABLE: &str = "config-property-unreadable";
3055const AUTO_IMPORT_KEY_NOT_MODELED: &str = "key-effect-not-modeled";
3056
3057fn record_retained_auto_import_surface(
3060 retained: &mut Vec<plugins::PluginConfigDiagnostic>,
3061 origin: &plugins::nuxt::SurfaceOrigin,
3062 key: &str,
3063) {
3064 let Some(config_path) = origin.config_path.as_deref() else {
3065 return;
3066 };
3067 let reason = if origin.unreadable_property {
3068 AUTO_IMPORT_PROPERTY_UNREADABLE
3069 } else {
3070 AUTO_IMPORT_KEY_NOT_MODELED
3071 };
3072 let diagnostic = plugins::PluginConfigDiagnostic::not_modeled(config_path, "nuxt", key, reason);
3073 if !retained.contains(&diagnostic) {
3074 retained.push(diagnostic);
3075 }
3076}
3077
3078fn settings_for_entry_pattern<'a>(
3083 root: &'a plugins::nuxt::AutoImportSettings,
3084 workspaces: &'a [(String, plugins::nuxt::AutoImportSettings)],
3085 pattern: &str,
3086) -> &'a plugins::nuxt::AutoImportSettings {
3087 workspaces
3088 .iter()
3089 .filter(|(prefix, _)| {
3090 !prefix.is_empty()
3091 && pattern
3092 .strip_prefix(prefix.as_str())
3093 .is_some_and(|rest| rest.starts_with('/'))
3094 })
3095 .max_by_key(|(prefix, _)| prefix.len())
3096 .map_or(root, |(_, setting)| setting)
3097}
3098
3099fn bucket_files_by_workspace(
3100 workspace_pkgs: &[LoadedWorkspacePackage],
3101 file_paths: &[std::path::PathBuf],
3102) -> Vec<Vec<(std::path::PathBuf, String)>> {
3103 let workspace_roots: Vec<_> = workspace_pkgs
3104 .iter()
3105 .map(|(workspace, _)| workspace.root.as_path())
3106 .collect();
3107 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
3108}
3109
3110fn bucket_files_by_workspace_roots(
3111 workspace_roots: &[&Path],
3112 file_paths: &[std::path::PathBuf],
3113) -> Vec<Vec<(std::path::PathBuf, String)>> {
3114 use rayon::prelude::*;
3115
3116 let mut workspace_by_root: rustc_hash::FxHashMap<&Path, usize> =
3120 rustc_hash::FxHashMap::default();
3121 for (idx, root) in workspace_roots.iter().enumerate() {
3122 workspace_by_root.entry(root).or_insert(idx);
3123 }
3124
3125 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
3126 .par_iter()
3127 .map(|file_path| {
3128 let idx = file_path
3129 .ancestors()
3130 .filter_map(|ancestor| workspace_by_root.get(ancestor).copied())
3131 .min()?;
3132 let relative = file_path.strip_prefix(workspace_roots[idx]).ok()?;
3133 Some((
3134 idx,
3135 file_path.clone(),
3136 relative.to_string_lossy().into_owned(),
3137 ))
3138 })
3139 .collect();
3140
3141 let mut buckets = vec![Vec::new(); workspace_roots.len()];
3142 for (idx, file_path, relative) in assignments.into_iter().flatten() {
3143 buckets[idx].push((file_path, relative));
3144 }
3145
3146 buckets
3147}
3148
3149#[doc(hidden)]
3151pub fn benchmark_bucket_files_by_workspace(
3152 workspace_roots: &[std::path::PathBuf],
3153 file_paths: &[std::path::PathBuf],
3154) -> Vec<Vec<(std::path::PathBuf, String)>> {
3155 let workspace_roots: Vec<_> = workspace_roots
3156 .iter()
3157 .map(std::path::PathBuf::as_path)
3158 .collect();
3159 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
3160}
3161
3162fn collect_config_search_roots(
3163 root: &Path,
3164 file_paths: &[std::path::PathBuf],
3165) -> Vec<std::path::PathBuf> {
3166 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
3167 roots.insert(root.to_path_buf());
3168
3169 for file_path in file_paths {
3170 let mut current = file_path.parent();
3171 while let Some(dir) = current {
3172 if !dir.starts_with(root) {
3173 break;
3174 }
3175 roots.insert(dir.to_path_buf());
3176 if dir == root {
3177 break;
3178 }
3179 current = dir.parent();
3180 }
3181 }
3182
3183 let mut roots_vec: Vec<_> = roots.into_iter().collect();
3184 roots_vec.sort();
3185 roots_vec
3186}
3187
3188fn config_for_project(
3196 root: &Path,
3197 config_path: Option<&Path>,
3198) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
3199 let user_config = if let Some(path) = config_path {
3200 Some((
3201 fallow_config::FallowConfig::load(path)
3202 .map_err(|e| FallowError::config(format!("{e:#}")))?,
3203 path.to_path_buf(),
3204 ))
3205 } else {
3206 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
3207 };
3208
3209 let config = match user_config {
3210 Some((config, path)) => resolve_user_config(config, path, root)?,
3211 None => (
3212 fallow_config::FallowConfig::default().resolve(
3213 root.to_path_buf(),
3214 fallow_config::OutputFormat::Human,
3215 num_cpus(),
3216 false,
3217 true,
3218 None,
3219 ),
3220 None,
3221 ),
3222 };
3223
3224 Ok(config)
3225}
3226
3227fn resolve_user_config(
3230 mut config: fallow_config::FallowConfig,
3231 path: std::path::PathBuf,
3232 root: &Path,
3233) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
3234 let dead_code_production = config
3235 .production
3236 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
3237 config.production = dead_code_production.into();
3238 config
3239 .validate_resolved_boundaries(root)
3240 .map_err(|errors| {
3241 let joined = errors
3242 .iter()
3243 .map(ToString::to_string)
3244 .collect::<Vec<_>>()
3245 .join("\n - ");
3246 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
3247 })?;
3248 let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
3249 let joined = errors
3250 .iter()
3251 .map(ToString::to_string)
3252 .collect::<Vec<_>>()
3253 .join("\n - ");
3254 FallowError::config(format!("invalid rule pack:\n - {joined}"))
3255 })?;
3256 let zone_errors = fallow_config::validate_rule_pack_zones(
3257 root,
3258 &config.boundaries,
3259 &config.rule_packs,
3260 &packs,
3261 );
3262 if !zone_errors.is_empty() {
3263 let joined = zone_errors
3264 .iter()
3265 .map(ToString::to_string)
3266 .collect::<Vec<_>>()
3267 .join("\n - ");
3268 return Err(FallowError::config(format!(
3269 "invalid rule pack:\n - {joined}"
3270 )));
3271 }
3272 Ok((
3273 config.resolve(
3274 root.to_path_buf(),
3275 fallow_config::OutputFormat::Human,
3276 num_cpus(),
3277 false,
3278 true, None, ),
3281 Some(path),
3282 ))
3283}
3284
3285#[cfg_attr(
3296 not(test),
3297 allow(
3298 dead_code,
3299 reason = "config resolution fallback is exercised by session tests"
3300 )
3301)]
3302pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
3303 config_for_project(root, None).map_or_else(
3304 |_| {
3305 fallow_config::FallowConfig::default().resolve(
3306 root.to_path_buf(),
3307 fallow_config::OutputFormat::Human,
3308 num_cpus(),
3309 false,
3310 true,
3311 None,
3312 )
3313 },
3314 |(config, _)| config,
3315 )
3316}
3317
3318fn num_cpus() -> usize {
3319 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
3320}
3321
3322#[cfg(test)]
3323mod tests {
3324 use super::{
3325 AnalysisSession, bucket_files_by_workspace, bucket_files_by_workspace_roots,
3326 collect_config_search_roots, credit_workspace_package_usage, default_config,
3327 format_undeclared_workspace_warning, gate_auto_import_entry_patterns,
3328 parse_analysis_modules, plugin_config_hash, resolver_options_hash,
3329 settings_for_entry_pattern, warn_undeclared_workspaces, workspace_prefix,
3330 };
3331 use std::path::{Path, PathBuf};
3332 use std::time::Instant;
3333
3334 use fallow_config::{
3335 AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
3336 };
3337 use fallow_types::discover::{DiscoveredFile, FileId};
3338 use fallow_types::extract::{ImportInfo, ImportedName};
3339
3340 fn plugin_result() -> crate::plugins::AggregatedPluginResult {
3341 let mut result = crate::plugins::AggregatedPluginResult::default();
3342 result.active_plugins.push("nuxt".to_string());
3343 result
3344 .path_aliases
3345 .push(("@/".to_string(), "src/".to_string()));
3346 result
3347 }
3348
3349 fn auto_import_settings(
3350 components: crate::plugins::nuxt::AutoImportSetting,
3351 ) -> crate::plugins::nuxt::AutoImportSettings {
3352 crate::plugins::nuxt::AutoImportSettings {
3353 components,
3354 scripts: crate::plugins::nuxt::AutoImportSetting::Default,
3355 components_origin: crate::plugins::nuxt::SurfaceOrigin {
3356 config_path: Some(std::path::PathBuf::from("nuxt.config.ts")),
3357 unreadable_property: false,
3358 },
3359 scripts_origin: crate::plugins::nuxt::SurfaceOrigin::default(),
3360 }
3361 }
3362
3363 #[test]
3364 fn entry_pattern_settings_prefer_the_longest_workspace_prefix() {
3365 let root = auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Default);
3366 let workspaces = vec![
3367 (
3368 "packages/web".to_string(),
3369 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Custom),
3370 ),
3371 (
3372 "packages/web-admin".to_string(),
3373 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Disabled),
3374 ),
3375 ];
3376
3377 let admin = settings_for_entry_pattern(
3378 &root,
3379 &workspaces,
3380 "packages/web-admin/components/**/*.{vue,ts,tsx,js,jsx}",
3381 );
3382 assert_eq!(
3383 admin.components,
3384 crate::plugins::nuxt::AutoImportSetting::Disabled,
3385 "a sibling whose name starts with another workspace name is its own"
3386 );
3387
3388 let web = settings_for_entry_pattern(
3389 &root,
3390 &workspaces,
3391 "packages/web/components/**/*.{vue,ts,tsx,js,jsx}",
3392 );
3393 assert_eq!(
3394 web.components,
3395 crate::plugins::nuxt::AutoImportSetting::Custom
3396 );
3397 }
3398
3399 #[test]
3400 fn entry_pattern_without_a_workspace_prefix_belongs_to_the_project_root() {
3401 let root = auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Custom);
3402 let workspaces = vec![(
3403 "packages/web".to_string(),
3404 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Default),
3405 )];
3406
3407 let setting = settings_for_entry_pattern(
3408 &root,
3409 &workspaces,
3410 "app/components/**/*.{vue,ts,tsx,js,jsx}",
3411 );
3412 assert_eq!(
3413 setting.components,
3414 crate::plugins::nuxt::AutoImportSetting::Custom
3415 );
3416 }
3417
3418 fn nuxt_gate_fixture(
3421 config_source: &str,
3422 ) -> (tempfile::TempDir, crate::plugins::AggregatedPluginResult) {
3423 let dir = tempfile::tempdir().expect("temp project");
3424 std::fs::write(dir.path().join("nuxt.config.ts"), config_source).expect("nuxt config");
3425 let mut result = crate::plugins::AggregatedPluginResult::default();
3426 result.active_plugins.push("nuxt".to_string());
3427 for pattern in [
3428 "app/components/**/*.{vue,ts,tsx,js,jsx}",
3429 "app/composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
3430 ] {
3431 result.entry_patterns.push((
3432 crate::plugins::PathRule::new(pattern.to_string()),
3433 "nuxt".to_string(),
3434 ));
3435 }
3436 (dir, result)
3437 }
3438
3439 fn gate(root: &Path, result: &mut crate::plugins::AggregatedPluginResult) {
3440 let config = fallow_config::FallowConfig {
3441 auto_imports: true,
3442 ..fallow_config::FallowConfig::default()
3443 };
3444 let resolved = config.resolve(
3445 root.to_path_buf(),
3446 fallow_config::OutputFormat::Json,
3447 1,
3448 false,
3449 true,
3450 None,
3451 );
3452 assert!(resolved.auto_imports, "the gate only runs when opted in");
3453 gate_auto_import_entry_patterns(result, &resolved, &[]);
3454 }
3455
3456 #[test]
3460 fn a_retained_auto_import_surface_records_one_advisory_per_surface() {
3461 let (project, mut result) =
3462 nuxt_gate_fixture("export default { components: { dirs: ['~/ui'] } };\n");
3463 gate(project.path(), &mut result);
3464
3465 let recorded: Vec<(&str, &str, &str)> = result
3466 .config_diagnostics
3467 .iter()
3468 .map(|diagnostic| {
3469 (
3470 diagnostic.plugin.as_str(),
3471 diagnostic.key.as_str(),
3472 diagnostic.reason.as_str(),
3473 )
3474 })
3475 .collect();
3476 assert_eq!(
3477 recorded,
3478 vec![("nuxt", "components", "key-effect-not-modeled")],
3479 "only the surface that kept its patterns is reported: {:?}",
3480 result.config_diagnostics
3481 );
3482 assert_eq!(
3483 result.config_diagnostics[0].config_path,
3484 project.path().join("nuxt.config.ts")
3485 );
3486 assert_eq!(
3487 result.entry_patterns.len(),
3488 1,
3489 "the modeled surface still loses its patterns: {:?}",
3490 result.entry_patterns
3491 );
3492 }
3493
3494 #[test]
3498 fn an_unreadable_top_level_property_reports_both_surfaces_with_its_own_reason() {
3499 let (project, mut result) =
3500 nuxt_gate_fixture("export default { ...baseConfig, modules: [] };\n");
3501 gate(project.path(), &mut result);
3502
3503 let recorded: Vec<(&str, &str)> = result
3504 .config_diagnostics
3505 .iter()
3506 .map(|diagnostic| (diagnostic.key.as_str(), diagnostic.reason.as_str()))
3507 .collect();
3508 assert_eq!(
3509 recorded,
3510 vec![
3511 ("components", "config-property-unreadable"),
3512 ("imports", "config-property-unreadable"),
3513 ],
3514 "{:?}",
3515 result.config_diagnostics
3516 );
3517 assert_eq!(
3518 result.entry_patterns.len(),
3519 2,
3520 "a spread keeps every gated pattern"
3521 );
3522 }
3523
3524 #[test]
3527 fn a_modeled_nuxt_config_records_no_advisory() {
3528 let (project, mut result) = nuxt_gate_fixture("export default { modules: [] };\n");
3529 gate(project.path(), &mut result);
3530 assert!(
3531 result.config_diagnostics.is_empty(),
3532 "{:?}",
3533 result.config_diagnostics
3534 );
3535 assert!(
3536 result.entry_patterns.is_empty(),
3537 "both modeled surfaces lose their patterns: {:?}",
3538 result.entry_patterns
3539 );
3540 }
3541
3542 #[test]
3543 fn a_workspace_outside_the_project_keeps_its_own_prefix() {
3544 let project_root = Path::new("/repo");
3545 let outside = Path::new("/elsewhere/app");
3546 let prefix = workspace_prefix(project_root, outside);
3547 assert_eq!(prefix, "/elsewhere/app");
3548
3549 let root = auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Default);
3550 let workspaces = vec![(
3551 prefix,
3552 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Custom),
3553 )];
3554 let setting = settings_for_entry_pattern(
3555 &root,
3556 &workspaces,
3557 "/elsewhere/app/components/**/*.{vue,ts,tsx,js,jsx}",
3558 );
3559 assert_eq!(
3560 setting.components,
3561 crate::plugins::nuxt::AutoImportSetting::Custom,
3562 "an out-of-tree workspace is classified on its own config"
3563 );
3564 }
3565
3566 #[test]
3567 fn commonjs_internal_import_credits_workspace_package_usage() {
3568 let workspace = fallow_config::WorkspaceInfo {
3569 root: PathBuf::from("/repo/packages/shared"),
3570 name: "@repo/shared".to_string(),
3571 is_internal_dependency: true,
3572 };
3573 let resolved = vec![crate::resolve::ResolvedModule {
3574 file_id: FileId(0),
3575 resolved_imports: vec![crate::resolve::ResolvedImport {
3576 info: ImportInfo {
3577 source: "@repo/shared".to_string(),
3578 imported_name: ImportedName::Namespace,
3579 local_name: "shared".to_string(),
3580 is_type_only: false,
3581 is_type_only_star: false,
3582 from_style: false,
3583 span: oxc_span::Span::new(0, 20),
3584 source_span: oxc_span::Span::new(8, 20),
3585 },
3586 target: crate::resolve::ResolveResult::CommonJsInternalModule(FileId(1)),
3587 }],
3588 ..crate::resolve::ResolvedModule::default()
3589 }];
3590 let mut graph = crate::graph::ModuleGraph::build(&[], &[], &[]);
3591
3592 credit_workspace_package_usage(&mut graph, &resolved, &[workspace]);
3593
3594 assert_eq!(
3595 graph.package_usage.get("@repo/shared"),
3596 Some(&vec![FileId(0)])
3597 );
3598 }
3599
3600 #[test]
3603 fn graph_cache_resolver_hash_is_independent_of_the_project_root() {
3604 let dir_a = tempfile::tempdir().expect("create temp dir a");
3605 let dir_b = tempfile::tempdir().expect("create temp dir b");
3606 let config_a = session_config(dir_a.path());
3607 let config_b = session_config(dir_b.path());
3608
3609 assert_eq!(
3610 resolver_options_hash(&config_a),
3611 resolver_options_hash(&config_b),
3612 "root identity is handled separately from resolver options"
3613 );
3614 }
3615
3616 #[test]
3619 fn graph_cache_manifest_still_rejects_a_different_file_set() {
3620 let dir_a = tempfile::tempdir().expect("create temp dir a");
3621 let mode = crate::graph_cache::GraphCacheMode::new(1, 2, 3);
3622 let files_a = [crate::discover::DiscoveredFile {
3623 id: crate::discover::FileId(0),
3624 path: dir_a.path().join("src/a.ts"),
3625 size_bytes: 1,
3626 }];
3627 let files_b = [crate::discover::DiscoveredFile {
3628 id: crate::discover::FileId(0),
3629 path: dir_a.path().join("src/b.ts"),
3630 size_bytes: 1,
3631 }];
3632
3633 let manifest_a = crate::graph_cache::GraphCacheManifest::from_discovered_files(
3634 dir_a.path(),
3635 &files_a,
3636 mode,
3637 |_| 7,
3638 );
3639 let manifest_b = crate::graph_cache::GraphCacheManifest::from_discovered_files(
3640 dir_a.path(),
3641 &files_b,
3642 mode,
3643 |_| 7,
3644 );
3645
3646 assert_eq!(
3647 manifest_a.classify_resolution_mismatch(&manifest_b),
3648 Some(fallow_types::cache_rejection::CacheRejection::FileSetChanged)
3649 );
3650 }
3651
3652 #[test]
3653 fn graph_cache_resolver_hash_includes_resolve_conditions() {
3654 let dir = tempfile::tempdir().expect("create temp dir");
3655 let config_a = session_config(dir.path());
3656 let mut config_b = session_config(dir.path());
3657 config_b.resolve.conditions.push("react-server".to_string());
3658
3659 assert_ne!(
3660 resolver_options_hash(&config_a),
3661 resolver_options_hash(&config_b),
3662 "resolve condition changes must invalidate the graph cache"
3663 );
3664 }
3665
3666 #[test]
3667 fn graph_cache_plugin_hash_includes_auto_imports() {
3668 let mut without_auto_import = plugin_result();
3669 let mut with_auto_import = plugin_result();
3670 with_auto_import.auto_imports.push(AutoImportRule::new(
3671 "useCounter".to_string(),
3672 PathBuf::from("/project/composables/useCounter.ts"),
3673 AutoImportKind::Named,
3674 ));
3675
3676 assert_ne!(
3677 plugin_config_hash(&without_auto_import, std::path::Path::new("")),
3678 plugin_config_hash(&with_auto_import, std::path::Path::new("")),
3679 "auto-import edge changes must invalidate the graph cache"
3680 );
3681
3682 without_auto_import.auto_imports.push(AutoImportRule::new(
3683 "useCounter".to_string(),
3684 PathBuf::from("/project/composables/useCounter.ts"),
3685 AutoImportKind::Default,
3686 ));
3687 assert_ne!(
3688 plugin_config_hash(&without_auto_import, std::path::Path::new("")),
3689 plugin_config_hash(&with_auto_import, std::path::Path::new("")),
3690 "auto-import kind changes must invalidate the graph cache"
3691 );
3692
3693 let mut scoped = with_auto_import.auto_imports.clone();
3694 scoped[0].scope = vec![PathBuf::from("/project/packages/a")];
3695 let mut with_scoped_auto_import = plugin_result();
3696 with_scoped_auto_import.auto_imports = scoped;
3697 assert_ne!(
3698 plugin_config_hash(&with_scoped_auto_import, std::path::Path::new("")),
3699 plugin_config_hash(&with_auto_import, std::path::Path::new("")),
3700 "auto-import scope changes must invalidate the graph cache"
3701 );
3702 }
3703
3704 #[test]
3705 fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
3706 let base = plugin_result();
3707 let mut with_scss = base.clone();
3708 with_scss
3709 .scss_include_paths
3710 .push(PathBuf::from("/project/styles"));
3711 assert_ne!(
3712 plugin_config_hash(&base, std::path::Path::new("")),
3713 plugin_config_hash(&with_scss, std::path::Path::new("")),
3714 "SCSS include path changes must invalidate the graph cache"
3715 );
3716
3717 let mut with_static_dir = base.clone();
3718 with_static_dir
3719 .static_dir_mappings
3720 .push((PathBuf::from("/project/public"), "/".to_string()));
3721 assert_ne!(
3722 plugin_config_hash(&base, std::path::Path::new("")),
3723 plugin_config_hash(&with_static_dir, std::path::Path::new("")),
3724 "static directory mapping changes must invalidate the graph cache"
3725 );
3726 }
3727
3728 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
3729 WorkspaceDiagnostic::new(
3730 root,
3731 root.join(relative),
3732 WorkspaceDiagnosticKind::UndeclaredWorkspace,
3733 )
3734 }
3735
3736 fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
3737 let mut config = default_config(root);
3738 config.no_cache = true;
3739 config.quiet = true;
3740 config
3741 }
3742
3743 fn write_session_fixture(root: &Path) {
3744 let src = root.join("src");
3745 std::fs::create_dir_all(&src).expect("create src");
3746 std::fs::write(
3747 root.join("package.json"),
3748 r#"{"name":"session-fixture","type":"module"}"#,
3749 )
3750 .expect("write package json");
3751 std::fs::write(
3752 src.join("index.ts"),
3753 "import { used } from './used';\nconsole.log(used);\n",
3754 )
3755 .expect("write index");
3756 std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
3757 }
3758
3759 #[test]
3760 fn analysis_session_discovers_project_files() {
3761 let dir = tempfile::tempdir().expect("create temp dir");
3762 write_session_fixture(dir.path());
3763 let config = session_config(dir.path());
3764
3765 let session = AnalysisSession::new(&config).expect("session setup should succeed");
3766
3767 assert!(
3768 session
3769 .files()
3770 .iter()
3771 .any(|file| file.path.ends_with("src/index.ts")),
3772 "session should own discovered project files"
3773 );
3774 assert_eq!(session.workspaces().len(), 0);
3775 }
3776
3777 #[test]
3778 fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
3779 let project = tempfile::tempdir().expect("create project");
3780 let root = project.path();
3781 let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
3782 for (index, path) in paths.iter().enumerate() {
3783 std::fs::write(path, format!("export const value{index} = {index};\n"))
3784 .expect("write source");
3785 }
3786 let files: Vec<DiscoveredFile> = paths
3787 .iter()
3788 .enumerate()
3789 .map(|(index, path)| DiscoveredFile {
3790 id: FileId(u32::try_from(index).expect("test index fits u32")),
3791 path: path.clone(),
3792 size_bytes: std::fs::metadata(path).expect("source metadata").len(),
3793 })
3794 .collect();
3795 std::fs::remove_file(&paths[1]).expect("remove source after discovery");
3796 let config = session_config(root);
3797
3798 let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
3799
3800 assert_eq!(
3801 parsed
3802 .modules
3803 .iter()
3804 .map(|module| module.file_id)
3805 .collect::<Vec<_>>(),
3806 vec![FileId(0), FileId(2)]
3807 );
3808 let diagnostics = fallow_config::workspace_diagnostics_for(root);
3809 let diagnostic = diagnostics
3810 .iter()
3811 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
3812 .expect("source read failure diagnostic");
3813 assert_eq!(diagnostic.path, paths[1]);
3814 assert!(matches!(
3815 diagnostic.kind,
3816 WorkspaceDiagnosticKind::SourceReadFailure { .. }
3817 ));
3818 }
3819
3820 #[test]
3821 fn analysis_session_parses_owned_modules() {
3822 let dir = tempfile::tempdir().expect("create temp dir");
3823 write_session_fixture(dir.path());
3824 let config = session_config(dir.path());
3825
3826 let session = AnalysisSession::new(&config).expect("session setup should succeed");
3827 let parsed = session.parse_modules(false);
3828
3829 assert!(
3830 parsed
3831 .modules
3832 .iter()
3833 .any(|module| session.files()[module.file_id.0 as usize]
3834 .path
3835 .ends_with("src/index.ts")),
3836 "session parsing should return modules keyed to session files"
3837 );
3838 }
3839
3840 #[test]
3841 fn undeclared_workspace_warning_is_singular_for_one_path() {
3842 let root = Path::new("/repo");
3843 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
3844 .expect("warning should be rendered");
3845
3846 assert_eq!(
3847 warning,
3848 "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."
3849 );
3850 }
3851
3852 #[test]
3853 fn undeclared_workspace_warning_summarizes_many_paths() {
3854 let root = PathBuf::from("/repo");
3855 let diagnostics = [
3856 "examples/a",
3857 "examples/b",
3858 "examples/c",
3859 "examples/d",
3860 "examples/e",
3861 "examples/f",
3862 ]
3863 .into_iter()
3864 .map(|path| diag(&root, path))
3865 .collect::<Vec<_>>();
3866
3867 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
3868 .expect("warning should be rendered");
3869
3870 assert_eq!(
3871 warning,
3872 "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."
3873 );
3874 }
3875
3876 #[test]
3877 fn collect_config_search_roots_includes_file_ancestors_once() {
3878 let root = PathBuf::from("/repo");
3879 let search_roots = collect_config_search_roots(
3880 &root,
3881 &[
3882 root.join("apps/query/src/main.ts"),
3883 root.join("packages/shared/lib/index.ts"),
3884 ],
3885 );
3886
3887 assert_eq!(
3888 search_roots,
3889 vec![
3890 root.clone(),
3891 root.join("apps"),
3892 root.join("apps/query"),
3893 root.join("apps/query/src"),
3894 root.join("packages"),
3895 root.join("packages/shared"),
3896 root.join("packages/shared/lib"),
3897 ]
3898 );
3899 }
3900
3901 #[test]
3902 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
3903 let root = PathBuf::from("/repo");
3904 let ui = fallow_config::WorkspaceInfo {
3905 root: root.join("apps/ui"),
3906 name: "ui".to_string(),
3907 is_internal_dependency: false,
3908 };
3909 let api = fallow_config::WorkspaceInfo {
3910 root: root.join("apps/api"),
3911 name: "api".to_string(),
3912 is_internal_dependency: false,
3913 };
3914 let workspace_pkgs = vec![
3915 (
3916 ui,
3917 fallow_config::PackageJson {
3918 name: Some("ui".to_string()),
3919 ..Default::default()
3920 },
3921 ),
3922 (
3923 api,
3924 fallow_config::PackageJson {
3925 name: Some("api".to_string()),
3926 ..Default::default()
3927 },
3928 ),
3929 ];
3930 let files = vec![
3931 root.join("apps/ui/vite.config.ts"),
3932 root.join("apps/ui/src/main.ts"),
3933 root.join("apps/api/src/server.ts"),
3934 root.join("tools/build.ts"),
3935 ];
3936
3937 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
3938
3939 assert_eq!(
3940 buckets[0],
3941 vec![
3942 (
3943 root.join("apps/ui/vite.config.ts"),
3944 "vite.config.ts".to_string()
3945 ),
3946 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
3947 ]
3948 );
3949 assert_eq!(
3950 buckets[1],
3951 vec![(
3952 root.join("apps/api/src/server.ts"),
3953 "src/server.ts".to_string()
3954 )]
3955 );
3956 }
3957
3958 #[test]
3959 fn workspace_bucketing_preserves_first_declared_match_and_file_order() {
3960 let root = PathBuf::from("/repo");
3961 let parent = root.join("apps");
3962 let child = parent.join("web");
3963 let nested_first = child.join("src/first.ts");
3964 let nested_second = child.join("src/second.ts");
3965 let unmatched = root.join("tools/build.ts");
3966 let files = vec![nested_first.clone(), unmatched, nested_second.clone()];
3967
3968 let normalize = |bucket: &[(PathBuf, String)]| -> Vec<(PathBuf, String)> {
3973 bucket
3974 .iter()
3975 .map(|(path, rel)| (path.clone(), rel.replace('\\', "/")))
3976 .collect()
3977 };
3978
3979 let parent_first = bucket_files_by_workspace_roots(&[&parent, &child, &child], &files);
3980 assert_eq!(
3981 normalize(&parent_first[0]),
3982 vec![
3983 (nested_first.clone(), "web/src/first.ts".to_string()),
3984 (nested_second.clone(), "web/src/second.ts".to_string()),
3985 ]
3986 );
3987 assert!(parent_first[1].is_empty());
3988 assert!(parent_first[2].is_empty());
3989
3990 let child_first = bucket_files_by_workspace_roots(&[&child, &parent], &files);
3991 assert_eq!(
3992 normalize(&child_first[0]),
3993 vec![
3994 (nested_first, "src/first.ts".to_string()),
3995 (nested_second, "src/second.ts".to_string()),
3996 ]
3997 );
3998 assert!(child_first[1].is_empty());
3999 }
4000
4001 #[test]
4002 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
4003 let dir = tempfile::tempdir().expect("create temp dir");
4004 let pkg_good = dir.path().join("packages").join("good");
4005 let pkg_bad = dir.path().join("packages").join("bad");
4006 std::fs::create_dir_all(&pkg_good).unwrap();
4007 std::fs::create_dir_all(&pkg_bad).unwrap();
4008 std::fs::write(
4009 dir.path().join("package.json"),
4010 r#"{"workspaces": ["packages/*"]}"#,
4011 )
4012 .unwrap();
4013 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
4014 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
4015
4016 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
4017 dir.path(),
4018 &fallow_config::IgnorePatternSet::empty(),
4019 )
4020 .expect("root package.json is valid");
4021 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
4022 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
4023
4024 warn_undeclared_workspaces(
4025 dir.path(),
4026 &workspaces,
4027 &fallow_config::IgnorePatternSet::empty(),
4028 false,
4029 );
4030
4031 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
4032 let mut malformed = 0;
4033 let mut undeclared_for_bad = 0;
4034 for diag in &diagnostics {
4035 if matches!(
4036 diag.kind,
4037 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
4038 ) && diag.path.ends_with("bad")
4039 {
4040 malformed += 1;
4041 }
4042 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
4043 && diag.path.ends_with("bad")
4044 {
4045 undeclared_for_bad += 1;
4046 }
4047 }
4048 assert_eq!(
4049 malformed, 1,
4050 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
4051 );
4052 assert_eq!(
4053 undeclared_for_bad, 0,
4054 "warn_undeclared_workspaces must NOT re-flag a path that already \
4055 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
4056 );
4057 }
4058}