1use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8use fallow_config::{DuplicatesConfig, ResolvedConfig, WorkspaceInfo};
9use fallow_types::cache_rejection::CacheRejection;
10use fallow_types::discover::DiscoveredFile;
11use fallow_types::extract::ModuleInfo;
12#[cfg(test)]
13use fallow_types::results::AnalysisResults;
14use fallow_types::source_fingerprint::SourceFingerprint;
15use fallow_types::workspace::{WorkspaceDiagnostic, merge_workspace_diagnostics};
16use rustc_hash::{FxHashMap, FxHashSet};
17
18use crate::{
19 EngineResult, core_backend, duplicates,
20 project_analysis::{
21 ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
22 },
23 project_config::{ProjectConfig, config_for_project, default_project_config},
24 results::{
25 DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
26 SharedDeadCodeAnalysisArtifacts,
27 },
28};
29
30#[derive(Debug)]
36pub struct AnalysisSession {
37 config: ResolvedConfig,
38 config_path: Option<PathBuf>,
39 discovery: crate::discover::AnalysisDiscovery,
40 workspaces: Vec<WorkspaceInfo>,
41 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
42 parsed_cache: Mutex<Option<ParsedModuleCache>>,
43 styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
44 cancellation: Option<Arc<AtomicBool>>,
45}
46
47#[derive(Debug)]
48struct ParsedModuleCache {
49 need_complexity: bool,
50 fingerprints: Vec<SourceFingerprint>,
51 modules: Arc<[ModuleInfo]>,
52}
53
54#[derive(Debug)]
56pub struct AnalysisSessionParts {
57 pub config: ResolvedConfig,
59 pub config_path: Option<PathBuf>,
61 pub files: Vec<DiscoveredFile>,
63 pub workspaces: Vec<WorkspaceInfo>,
65 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
67}
68
69#[derive(Debug)]
71pub struct ParsedAnalysisSessionParts {
72 pub config: ResolvedConfig,
74 pub config_path: Option<PathBuf>,
76 pub files: Vec<DiscoveredFile>,
78 pub modules: Vec<ModuleInfo>,
80 pub workspaces: Vec<WorkspaceInfo>,
82 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
84 pub parse_ms: f64,
86 pub cache_update_ms: f64,
88 pub cache_hits: usize,
90 pub cache_misses: usize,
92 pub parse_cpu_ms: f64,
94}
95
96#[derive(Debug)]
97pub(crate) struct SharedParsedAnalysisSessionParts {
98 pub(crate) config: ResolvedConfig,
99 pub(crate) files: Vec<DiscoveredFile>,
100 pub(crate) modules: Arc<[ModuleInfo]>,
101 pub workspaces: Vec<WorkspaceInfo>,
102 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
103 pub parse_ms: f64,
104 pub parse_cpu_ms: f64,
105}
106
107#[derive(Debug)]
109pub struct AnalysisSessionArtifacts {
110 pub analysis: DeadCodeAnalysisArtifacts,
112 pub changed_files: Option<FxHashSet<PathBuf>>,
114 pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
116}
117
118impl AnalysisSession {
119 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
125 let project_config = config_for_project(root, config_path)?;
126 Ok(Self::from_config(project_config))
127 }
128
129 pub fn load_with_config(
136 root: &Path,
137 config_path: Option<&Path>,
138 configure: impl FnOnce(&mut ResolvedConfig),
139 ) -> EngineResult<Self> {
140 Self::load_with_config_options(
141 root,
142 config_path,
143 fallow_config::ConfigLoadOptions::default(),
144 configure,
145 )
146 }
147
148 pub fn load_with_config_options(
155 root: &Path,
156 config_path: Option<&Path>,
157 load_options: fallow_config::ConfigLoadOptions,
158 configure: impl FnOnce(&mut ResolvedConfig),
159 ) -> EngineResult<Self> {
160 let mut project_config = crate::project_config::config_for_project_with_load_options(
161 root,
162 config_path,
163 load_options,
164 )?;
165 configure(&mut project_config.config);
166 project_config.workspaces.clear();
167 project_config.workspace_diagnostics.clear();
168 project_config.workspace_discovery_ms = None;
169 Ok(Self::from_config(project_config))
170 }
171
172 #[must_use]
177 pub fn load_default(root: &Path) -> Self {
178 Self::from_config(default_project_config(root))
179 }
180
181 #[must_use]
183 pub fn from_config(project_config: ProjectConfig) -> Self {
184 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
185 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
186 {
187 crate::discover::prepare_analysis_discovery_with_workspaces(
188 &project_config.config,
189 &project_config.workspaces,
190 workspace_discovery_ms,
191 )
192 } else {
193 crate::discover::prepare_analysis_discovery(&project_config.config)
194 };
195 let workspaces = if uses_preloaded_workspaces {
196 project_config.workspaces
197 } else {
198 discovery.workspaces().to_vec()
199 };
200 let workspace_diagnostics = merge_workspace_diagnostics(
212 merge_workspace_diagnostics(
213 project_config.workspace_diagnostics,
214 fallow_config::workspace_diagnostics_for(&project_config.config.root)
215 .into_iter()
216 .filter(|diagnostic| {
217 !diagnostic.kind.is_analysis_stage()
218 && !diagnostic.kind.is_source_discovery()
219 })
220 .collect(),
221 ),
222 discovery.source_diagnostics().to_vec(),
223 );
224 Self {
225 config: project_config.config,
226 config_path: project_config.path,
227 discovery,
228 workspaces,
229 workspace_diagnostics,
230 parsed_cache: Mutex::new(None),
231 styling_cache: Mutex::new(None),
232 cancellation: None,
233 }
234 }
235
236 #[must_use]
251 pub fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
252 self.cancellation = Some(cancellation);
253 self
254 }
255
256 #[must_use]
258 pub fn is_cancelled(&self) -> bool {
259 self.cancellation
260 .as_ref()
261 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
262 }
263
264 fn ensure_not_cancelled(&self, stage: &str) -> EngineResult<()> {
265 if self.is_cancelled() {
266 return Err(crate::EngineError::cancelled(stage));
267 }
268 Ok(())
269 }
270
271 pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
279 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
280 crate::project_config::collect_workspace_metadata(&config)?;
281 Ok(Self::from_config(ProjectConfig {
282 config,
283 path: None,
284 workspaces,
285 workspace_diagnostics,
286 workspace_discovery_ms: Some(workspace_discovery_ms),
287 }))
288 }
289
290 #[must_use]
292 pub fn root(&self) -> &Path {
293 &self.config.root
294 }
295
296 #[must_use]
298 pub fn config(&self) -> &ResolvedConfig {
299 &self.config
300 }
301
302 #[must_use]
304 pub fn config_path(&self) -> Option<&Path> {
305 self.config_path.as_deref()
306 }
307
308 #[must_use]
310 pub fn files(&self) -> &[DiscoveredFile] {
311 self.discovery.files()
312 }
313
314 #[must_use]
316 pub fn workspaces(&self) -> &[WorkspaceInfo] {
317 &self.workspaces
318 }
319
320 #[must_use]
322 fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
323 self.discovery
324 .files()
325 .iter()
326 .map(|file| {
327 let fingerprint = std::fs::metadata(&file.path).map_or_else(
328 |_| SourceFingerprint::new(0, file.size_bytes),
329 |metadata| SourceFingerprint::from_metadata(&metadata),
330 );
331 (file.path.clone(), fingerprint)
332 })
333 .collect()
334 }
335
336 pub(crate) fn changed_files_since(
343 &self,
344 git_ref: &str,
345 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
346 crate::changed_files::changed_files(&self.config.root, git_ref)
347 }
348
349 pub(crate) const fn discovery(&self) -> &crate::discover::AnalysisDiscovery {
351 &self.discovery
352 }
353
354 #[must_use]
356 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
357 &self.workspace_diagnostics
358 }
359
360 #[must_use]
373 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
374 merge_workspace_diagnostics(
375 self.workspace_diagnostics.clone(),
376 fallow_config::registry_diagnostics_to_fold(&self.config.root),
377 )
378 }
379
380 pub(crate) fn styling_analysis_artifacts(
381 &self,
382 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
383 if let Ok(cache) = self.styling_cache.lock()
384 && let Some(artifacts) = cache.as_ref()
385 {
386 return Arc::clone(artifacts);
387 }
388
389 let modules = self.shared_parsed_modules(false);
390 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
391 self.files(),
392 &modules,
393 self.config(),
394 ));
395 if let Ok(mut cache) = self.styling_cache.lock() {
396 *cache = Some(Arc::clone(&artifacts));
397 }
398 artifacts
399 }
400
401 #[must_use]
403 pub fn into_parts(self) -> AnalysisSessionParts {
404 let workspace_diagnostics = self.current_workspace_diagnostics();
405 AnalysisSessionParts {
406 config: self.config,
407 config_path: self.config_path,
408 files: self.discovery.into_files(),
409 workspaces: self.workspaces,
410 workspace_diagnostics,
411 }
412 }
413
414 #[must_use]
416 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
417 let AnalysisSessionParts {
418 config,
419 config_path,
420 files,
421 workspaces,
422 workspace_diagnostics,
423 } = self.into_parts();
424 let ParsedModules {
425 modules,
426 metrics,
427 source_diagnostics,
428 } = parse_files_with_config(&config, &files, need_complexity, None);
429 ParsedAnalysisSessionParts {
430 config,
431 config_path,
432 files,
433 modules,
434 workspaces,
435 workspace_diagnostics: merge_workspace_diagnostics(
436 workspace_diagnostics,
437 source_diagnostics,
438 ),
439 parse_ms: metrics.parse_ms,
440 cache_update_ms: metrics.cache_ms,
441 cache_hits: metrics.cache_hits,
442 cache_misses: metrics.cache_misses,
443 parse_cpu_ms: metrics.parse_cpu_ms,
444 }
445 }
446
447 #[must_use]
449 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
450 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
451 self.parsed_parts_from_modules(modules.to_vec(), metrics)
452 }
453
454 #[must_use]
456 pub(crate) fn shared_parsed_parts(
457 &self,
458 need_complexity: bool,
459 ) -> SharedParsedAnalysisSessionParts {
460 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
461 SharedParsedAnalysisSessionParts {
462 config: self.config.clone(),
463 files: self.discovery.files().to_vec(),
464 modules,
465 workspaces: self.workspaces.clone(),
466 workspace_diagnostics: self.current_workspace_diagnostics(),
467 parse_ms: metrics.parse_ms,
468 parse_cpu_ms: metrics.parse_cpu_ms,
469 }
470 }
471
472 #[doc(hidden)]
478 #[must_use]
479 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
480 self.parse_modules(need_complexity, None).modules
481 }
482
483 pub(crate) fn shared_parsed_modules_cancellable(
494 &self,
495 need_complexity: bool,
496 stage: &str,
497 ) -> EngineResult<Arc<[ModuleInfo]>> {
498 self.ensure_not_cancelled("parsing")?;
499 let modules = self
500 .parse_modules(need_complexity, self.cancellation.as_deref())
501 .modules;
502 self.ensure_not_cancelled(stage)?;
503 Ok(modules)
504 }
505
506 #[must_use]
509 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
510 let ParsedModules {
511 modules,
512 metrics,
513 source_diagnostics: _,
514 } = parse_files_with_config(&self.config, self.files(), need_complexity, None);
515 self.parsed_parts_from_modules(modules, metrics)
516 }
517
518 fn parsed_parts_from_modules(
519 &self,
520 modules: Vec<ModuleInfo>,
521 metrics: core_backend::ParseMetrics,
522 ) -> ParsedAnalysisSessionParts {
523 ParsedAnalysisSessionParts {
524 config: self.config.clone(),
525 config_path: self.config_path.clone(),
526 files: self.discovery.files().to_vec(),
527 modules,
528 workspaces: self.workspaces.clone(),
529 workspace_diagnostics: self.current_workspace_diagnostics(),
530 parse_ms: metrics.parse_ms,
531 cache_update_ms: metrics.cache_ms,
532 cache_hits: metrics.cache_hits,
533 cache_misses: metrics.cache_misses,
534 parse_cpu_ms: metrics.parse_cpu_ms,
535 }
536 }
537
538 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
544 self.analyze_dead_code_with_artifacts(false, false)
545 .map(|output| DeadCodeAnalysis {
546 results: output.results,
547 })
548 }
549
550 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
556 self.analyze_dead_code_with_artifacts(true, false)
557 .map(|output| DeadCodeAnalysisOutput {
558 results: output.results,
559 modules: output.modules,
560 files: output.files,
561 })
562 }
563
564 pub fn analyze_dead_code_with_artifacts(
570 &self,
571 need_complexity: bool,
572 retain_graph: bool,
573 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
574 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
575 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
576 }
577
578 #[doc(hidden)]
588 pub fn analyze_dead_code_with_shared_artifacts(
589 &self,
590 need_complexity: bool,
591 retain_graph: bool,
592 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
593 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
594 }
595
596 pub fn analyze_dead_code_retaining_files(
603 &self,
604 need_complexity: bool,
605 retain_graph: bool,
606 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
607 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
608 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
609 }
610
611 pub fn analyze_dead_code_with_parsed_modules(
620 &self,
621 modules: &[ModuleInfo],
622 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
623 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
624 }
625
626 #[doc(hidden)]
632 pub(crate) fn analyze_dead_code_with_shared_modules(
633 &self,
634 modules: Arc<[ModuleInfo]>,
635 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
636 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
637 config: &self.config,
638 discovery: &self.discovery,
639 modules,
640 metrics: reused_parse_metrics(),
641 collect_usages: true,
642 retain_graph: true,
643 retain_modules: false,
644 retain_files: false,
645 cancellation: self.cancellation.as_deref(),
646 })
647 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
648 }
649
650 fn analyze_dead_code_with_reuse_artifacts(
651 &self,
652 need_complexity: bool,
653 retain_graph: bool,
654 retain_files: bool,
655 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
656 self.ensure_not_cancelled("parsing")?;
657 let SharedParsedModules { modules, metrics } =
658 self.parse_modules(need_complexity, self.cancellation.as_deref());
659 self.ensure_not_cancelled("the dead-code pipeline")?;
663 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
664 config: &self.config,
665 discovery: &self.discovery,
666 modules,
667 metrics,
668 collect_usages: true,
669 retain_graph,
670 retain_modules: need_complexity,
671 retain_files,
672 cancellation: self.cancellation.as_deref(),
673 })
674 }
675
676 pub fn analyze_dead_code_with_session_artifacts(
687 &self,
688 need_complexity: bool,
689 retain_graph: bool,
690 changed_files: Option<FxHashSet<PathBuf>>,
691 ) -> EngineResult<AnalysisSessionArtifacts> {
692 Ok(AnalysisSessionArtifacts {
693 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
694 changed_files,
695 source_fingerprints: self.source_fingerprints(),
696 })
697 }
698
699 pub fn analyze_project_with(
708 &self,
709 duplicates_config: &DuplicatesConfig,
710 retain_complexity_artifacts: bool,
711 ) -> EngineResult<ProjectAnalysisOutput> {
712 self.analyze_project_with_artifacts(
713 duplicates_config,
714 ProjectAnalysisArtifactOptions {
715 retain_complexity_artifacts,
716 ..ProjectAnalysisArtifactOptions::default()
717 },
718 )
719 .map(ProjectAnalysisArtifacts::into_output)
720 }
721
722 pub fn analyze_project_with_artifacts(
732 &self,
733 duplicates_config: &DuplicatesConfig,
734 options: ProjectAnalysisArtifactOptions,
735 ) -> EngineResult<ProjectAnalysisArtifacts> {
736 self.ensure_not_cancelled("duplication detection")?;
737 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
738 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
739 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
740 self.find_duplicates_touching_files_with_defaults(
741 duplicates_config,
742 &changed_files,
743 cache_dir,
744 )
745 .report
746 } else {
747 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
748 .report
749 };
750 self.ensure_not_cancelled("the dead-code half of project analysis")?;
754 let source_fingerprints = options
755 .collect_source_fingerprints
756 .then(|| self.source_fingerprints());
757 Ok(ProjectAnalysisArtifacts {
758 dead_code: self.analyze_dead_code_with_artifacts(
759 options.retain_complexity_artifacts,
760 options.retain_graph,
761 )?,
762 duplication,
763 changed_files: options.changed_files,
764 source_fingerprints,
765 })
766 }
767
768 #[must_use]
770 pub fn find_duplicates_with_defaults(
771 &self,
772 config: &DuplicatesConfig,
773 cache_dir: Option<&Path>,
774 ) -> DuplicationAnalysis {
775 duplicates::find_duplicates_with_defaults(
776 &self.config.root,
777 self.files(),
778 config,
779 cache_dir,
780 )
781 }
782
783 #[must_use]
785 pub fn find_duplicates_touching_files_with_defaults(
786 &self,
787 config: &DuplicatesConfig,
788 changed_files: &[PathBuf],
789 cache_dir: Option<&Path>,
790 ) -> DuplicationAnalysis {
791 duplicates::find_duplicates_touching_files_with_defaults(
792 &self.config.root,
793 self.files(),
794 config,
795 changed_files,
796 cache_dir,
797 )
798 }
799
800 fn parse_modules(
806 &self,
807 need_complexity: bool,
808 cancellation: Option<&AtomicBool>,
809 ) -> SharedParsedModules {
810 let fingerprints = source_fingerprints_for_files(self.files());
811 if let Some(fingerprints) = fingerprints.as_ref()
812 && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
813 {
814 return SharedParsedModules {
815 modules,
816 metrics: core_backend::ParseMetrics {
817 parse_ms: 0.0,
818 cache_ms: 0.0,
819 cache_hits: 0,
820 cache_misses: 0,
821 parse_cpu_ms: 0.0,
822 cache_rejection: None,
823 },
824 };
825 }
826
827 let ParsedModules {
828 modules,
829 metrics,
830 source_diagnostics: _,
831 } = parse_files_with_config(&self.config, self.files(), need_complexity, cancellation);
832 let modules: Arc<[ModuleInfo]> = modules.into();
833 if !token_is_set(cancellation)
837 && let Some(fingerprints) = fingerprints
838 && let Ok(mut cache) = self.parsed_cache.lock()
839 {
840 *cache = Some(ParsedModuleCache {
841 need_complexity,
842 fingerprints,
843 modules: Arc::clone(&modules),
844 });
845 }
846 SharedParsedModules { modules, metrics }
847 }
848
849 fn cached_modules(
850 &self,
851 need_complexity: bool,
852 fingerprints: &[SourceFingerprint],
853 ) -> Option<Arc<[ModuleInfo]>> {
854 let Ok(cache) = self.parsed_cache.lock() else {
855 return None;
856 };
857 let cache = cache.as_ref()?;
858 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
859 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
860 return Some(Arc::clone(&cache.modules));
861 }
862 None
863 }
864}
865
866struct ParsedModules {
867 modules: Vec<ModuleInfo>,
868 metrics: core_backend::ParseMetrics,
869 source_diagnostics: Vec<WorkspaceDiagnostic>,
870}
871
872struct SharedParsedModules {
873 modules: Arc<[ModuleInfo]>,
874 metrics: core_backend::ParseMetrics,
875}
876
877fn token_is_set(cancellation: Option<&AtomicBool>) -> bool {
878 cancellation.is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
879}
880
881fn parse_files_with_config(
882 config: &ResolvedConfig,
883 files: &[DiscoveredFile],
884 need_complexity: bool,
885 cancellation: Option<&AtomicBool>,
886) -> ParsedModules {
887 let parse_start = Instant::now();
888 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
889 let mut cache_rejection = None;
890 let mut cache = if config.no_cache {
891 None
892 } else {
893 match fallow_extract::cache::CacheStore::load(
894 &config.cache_dir,
895 &config.root,
896 config.cache_config_hash,
897 cache_max_size_bytes,
898 ) {
899 Ok(store) => Some(store),
900 Err(rejection) => {
901 cache_rejection = Some(rejection);
902 None
903 }
904 }
905 };
906 let parse_result =
907 crate::source::parse_all_files(files, cache.as_ref(), need_complexity, cancellation);
908 let mut source_diagnostics =
909 fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
910 source_diagnostics.extend(fallow_config::record_source_parse_degradations(
911 &config.root,
912 &parse_result.parse_degradations,
913 ));
914 let mut modules = parse_result.modules;
915 for module in &mut modules {
916 module.prepare_analysis_facts();
917 }
918 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
919 let cache_ms = if token_is_set(cancellation) {
920 0.0
921 } else {
922 update_parse_cache_if_enabled(config, &mut cache, &modules, files, need_complexity)
923 };
924 let metrics = core_backend::ParseMetrics {
925 parse_ms,
926 cache_ms,
927 cache_hits: parse_result.cache_hits,
928 cache_misses: parse_result.cache_misses,
929 parse_cpu_ms: parse_result.parse_cpu_ms,
930 cache_rejection,
931 };
932 ParsedModules {
933 modules,
934 metrics,
935 source_diagnostics,
936 }
937}
938
939fn reused_parse_metrics() -> core_backend::ParseMetrics {
940 core_backend::ParseMetrics {
941 parse_ms: 0.0,
942 cache_ms: 0.0,
943 cache_hits: 0,
944 cache_misses: 0,
945 parse_cpu_ms: 0.0,
946 cache_rejection: None,
947 }
948}
949
950fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
951 files
952 .iter()
953 .map(|file| {
954 std::fs::metadata(&file.path)
955 .ok()
956 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
957 .filter(|fingerprint| fingerprint.has_known_mtime())
958 })
959 .collect()
960}
961
962fn update_parse_cache_if_enabled(
963 config: &ResolvedConfig,
964 cache: &mut Option<fallow_extract::cache::CacheStore>,
965 modules: &[ModuleInfo],
966 files: &[DiscoveredFile],
967 need_complexity: bool,
968) -> f64 {
969 let start = Instant::now();
970 if config.no_cache {
971 return start.elapsed().as_secs_f64() * 1000.0;
972 }
973
974 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
975 let store = cache.get_or_insert_with(|| fallow_extract::cache::CacheStore::new(&config.root));
976 if update_parse_cache(store, modules, files, need_complexity)
977 && let Err(error) = store.save(
978 &config.cache_dir,
979 config.cache_config_hash,
980 cache_max_size_bytes,
981 )
982 {
983 tracing::warn!("Failed to save cache: {error}");
984 }
985 start.elapsed().as_secs_f64() * 1000.0
986}
987
988fn update_parse_cache(
993 store: &mut fallow_extract::cache::CacheStore,
994 modules: &[ModuleInfo],
995 files: &[DiscoveredFile],
996 need_complexity: bool,
997) -> bool {
998 let mut dirty = false;
999 for module in modules {
1000 if let Some(file) = files.get(module.file_id.0 as usize) {
1001 let fingerprint = source_fingerprint(&file.path);
1002 if let Some(cached) = store.get_by_path_only(&file.path)
1003 && cached.content_hash == module.content_hash
1004 {
1005 let stale_metadata = cached.source_fingerprint() != fingerprint;
1006 let adds_complexity = need_complexity && !cached.complexity_extracted;
1007 if stale_metadata || adds_complexity {
1008 let preserved_last_access = cached.last_access_secs;
1009 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
1010 .then(|| cached.complexity.clone());
1011 let mut refreshed = fallow_extract::cache::module_to_cached(
1012 module,
1013 fingerprint,
1014 need_complexity,
1015 );
1016 refreshed.last_access_secs = preserved_last_access;
1017 if let Some(complexity) = preserved_complexity {
1018 refreshed.complexity = complexity;
1019 refreshed.complexity_extracted = true;
1020 }
1021 store.insert(&file.path, refreshed);
1022 dirty = true;
1023 }
1024 continue;
1025 }
1026 store.insert(
1027 &file.path,
1028 fallow_extract::cache::module_to_cached(module, fingerprint, need_complexity),
1029 );
1030 dirty = true;
1031 }
1032 }
1033 store.retain_paths(files) || dirty
1034}
1035
1036fn source_fingerprint(path: &Path) -> SourceFingerprint {
1037 std::fs::metadata(path).map_or_else(
1038 |_| SourceFingerprint::new(0, 0),
1039 |metadata| SourceFingerprint::from_metadata(&metadata),
1040 )
1041}
1042
1043struct EngineDeadCodePipelineInput<'a> {
1044 config: &'a ResolvedConfig,
1045 discovery: &'a crate::discover::AnalysisDiscovery,
1046 modules: Arc<[ModuleInfo]>,
1047 metrics: core_backend::ParseMetrics,
1048 collect_usages: bool,
1049 retain_graph: bool,
1050 retain_modules: bool,
1051 retain_files: bool,
1052 cancellation: Option<&'a AtomicBool>,
1053}
1054
1055fn run_engine_owned_dead_code_pipeline(
1056 input: EngineDeadCodePipelineInput<'_>,
1057) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
1058 let EngineDeadCodePipelineInput {
1059 config,
1060 discovery,
1061 modules,
1062 metrics,
1063 collect_usages,
1064 retain_graph,
1065 retain_modules,
1066 retain_files,
1067 cancellation,
1068 } = input;
1069 let stopped = |stage: &str| -> EngineResult<()> {
1070 if token_is_set(cancellation) {
1071 return Err(crate::EngineError::cancelled(stage));
1072 }
1073 Ok(())
1074 };
1075 stopped("the dead-code prelude")?;
1076 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
1077 let prelude_timings = prelude.timings();
1078 stopped("dead-code entry-point discovery")?;
1079 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
1080 stopped("import resolution and graph construction")?;
1081 let (resolved, graph, graph_cache_rejection) =
1082 resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
1083 stopped("the dead-code detectors")?;
1084
1085 let mut detector = core_backend::run_dead_code_detectors(
1086 &prelude,
1087 &graph.graph,
1088 &resolved.project.modules,
1089 &modules,
1090 collect_usages,
1091 &entry_points,
1092 );
1093 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1094 stopped("assembling the dead-code report")?;
1099 let profile =
1100 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1101 retain_timings: retain_graph,
1102 prelude: &prelude,
1103 prelude_timings,
1104 parse_metrics: metrics,
1105 module_count: modules.len(),
1106 entry_points: &entry_points,
1107 resolved: &resolved,
1108 graph: &graph,
1109 detector: &detector,
1110 file_count: discovery.files().len(),
1111 workspace_count: discovery.workspaces().len(),
1112 graph_cache_rejection,
1113 });
1114 let script_used_packages = prelude.script_used_packages();
1115 let trace_provenance = prelude.trace_provenance(&modules);
1116 prelude.finish();
1117 let file_hashes = collect_file_hashes(&modules, discovery.files());
1118
1119 Ok(SharedDeadCodeAnalysisArtifacts {
1120 results: detector.results,
1121 timings: profile.timings,
1122 graph: retain_graph.then_some(graph.graph),
1123 modules: retain_modules.then_some(modules),
1124 files: retain_files.then(|| discovery.files().to_vec()),
1125 script_used_packages,
1126 trace_provenance,
1127 file_hashes,
1128 })
1129}
1130
1131fn resolve_or_build_dead_code_graph(
1139 prelude: &core_backend::DeadCodeBackendPrelude,
1140 entry_points: &core_backend::DeadCodeEntryPoints,
1141 modules: &[ModuleInfo],
1142) -> (
1143 core_backend::DeadCodeResolvedModules,
1144 core_backend::DeadCodeGraphRun,
1145 Option<CacheRejection>,
1146) {
1147 let rejection =
1148 match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1149 Ok((resolved, graph)) => return (resolved, graph, None),
1150 Err(rejection) => rejection,
1151 };
1152
1153 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1154 let graph =
1155 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1156 (resolved, graph, rejection)
1157}
1158
1159fn collect_file_hashes(
1160 modules: &[ModuleInfo],
1161 files: &[DiscoveredFile],
1162) -> FxHashMap<PathBuf, u64> {
1163 modules
1164 .iter()
1165 .filter_map(|module| {
1166 files
1167 .get(module.file_id.0 as usize)
1168 .map(|file| (file.path.clone(), module.content_hash))
1169 })
1170 .collect()
1171}
1172
1173pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1174 config: &ResolvedConfig,
1175 modules: &[ModuleInfo],
1176) -> EngineResult<DeadCodeAnalysisArtifacts> {
1177 let (workspaces, _diagnostics, workspaces_ms) =
1178 crate::project_config::collect_workspace_metadata(config)?;
1179 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1180 config,
1181 &workspaces,
1182 workspaces_ms,
1183 );
1184 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1185 config,
1186 discovery: &discovery,
1187 modules: Arc::from(modules),
1188 metrics: reused_parse_metrics(),
1189 collect_usages: true,
1190 retain_graph: true,
1191 retain_modules: false,
1192 retain_files: false,
1193 cancellation: None,
1194 })
1195 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200 use std::fmt::Write as _;
1201 use std::time::Duration;
1202
1203 use super::*;
1204
1205 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1206 let project = tempfile::tempdir().expect("project");
1207 let root = project.path();
1208 std::fs::create_dir(root.join("src")).expect("create source directory");
1209 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1210 let session = AnalysisSession::load_default(root);
1211 (project, session)
1212 }
1213
1214 #[test]
1217 fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
1218 let project = tempfile::tempdir().expect("project");
1219 let root = project.path();
1220 std::fs::create_dir(root.join("src")).expect("create source directory");
1221 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1222 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
1223
1224 let baseline = AnalysisSession::load_default(root)
1225 .analyze_dead_code()
1226 .expect("an uncancelled session analyzes");
1227 assert!(
1228 !baseline.results.unused_files.is_empty(),
1229 "the fixture must have findings, so an empty result would be a plausible wrong answer"
1230 );
1231
1232 let error = AnalysisSession::load_default(root)
1233 .with_cancellation(Arc::new(AtomicBool::new(true)))
1234 .analyze_dead_code()
1235 .expect_err("a cancelled session must not return results");
1236 assert!(error.is_cancelled(), "unexpected error: {error}");
1237 assert!(error.message().contains("cancelled"));
1238 }
1239
1240 #[test]
1246 fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
1247 let project = tempfile::tempdir().expect("project");
1248 let root = project.path();
1249 std::fs::create_dir(root.join("src")).expect("create source directory");
1250 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1251
1252 let cold = AnalysisSession::load_default(root)
1253 .analyze_dead_code_with_artifacts(false, true)
1254 .expect("cold run analyzes");
1255 assert_eq!(
1256 cold.timings
1257 .expect("cold timings retained")
1258 .graph_cache_rejection,
1259 Some(CacheRejection::Absent),
1260 "a first run has no persisted graph to refuse"
1261 );
1262
1263 std::fs::write(
1264 root.join("src/index.ts"),
1265 "export const entry = 1;\nexport const added = 2;\n",
1266 )
1267 .expect("edit the entry");
1268
1269 let warm = AnalysisSession::load_default(root)
1270 .analyze_dead_code_with_artifacts(false, true)
1271 .expect("warm run analyzes");
1272 assert_eq!(
1273 warm.timings
1274 .expect("warm timings retained")
1275 .graph_cache_rejection,
1276 Some(CacheRejection::FingerprintChanged),
1277 "the decoded graph was refused because a file changed, and the run must say so"
1278 );
1279 }
1280
1281 #[test]
1284 fn a_session_without_a_token_is_never_cancelled() {
1285 let (_project, session) = session_with_source("export const unused = 1;\n");
1286 assert!(!session.is_cancelled());
1287 session
1288 .analyze_dead_code()
1289 .expect("a session without a token analyzes");
1290 }
1291
1292 const PARSE_LOOP_FILES: usize = 400;
1296
1297 fn parse_loop_project() -> tempfile::TempDir {
1298 let project = tempfile::tempdir().expect("project");
1299 let src = project.path().join("src");
1300 std::fs::create_dir_all(&src).expect("src dir");
1301 for module in 0..PARSE_LOOP_FILES {
1302 let mut source = String::new();
1303 for symbol in 0..20 {
1304 let _ = writeln!(
1305 source,
1306 "export const helper{symbol} = (input: number): number => {{\n \
1307 if (input > {symbol}) {{\n return input * {symbol};\n }}\n \
1308 return input - {symbol};\n}};"
1309 );
1310 }
1311 std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
1312 }
1313 project
1314 }
1315
1316 fn uncached_session(root: &Path) -> AnalysisSession {
1319 let mut project_config = crate::project_config::default_project_config(root);
1320 project_config.config.no_cache = true;
1321 AnalysisSession::from_config(project_config)
1322 }
1323
1324 #[test]
1333 fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
1334 let project = parse_loop_project();
1335 let root = project.path();
1336
1337 drop(uncached_session(root).parse_modules(false, None));
1339
1340 let started = Instant::now();
1341 let full = uncached_session(root).parse_modules(false, None);
1342 let full_parse = started.elapsed();
1343 let full_count = full.modules.len();
1344 assert_eq!(
1345 full_count, PARSE_LOOP_FILES,
1346 "the fixture must parse every generated module"
1347 );
1348 assert!(
1349 full_parse >= Duration::from_millis(20),
1350 "the fixture is too small to cancel part way through: {full_parse:?}"
1351 );
1352
1353 let mut partial = None;
1358 let mut cancelled_session = None;
1359 for attempt in 1..=6_u32 {
1360 let token = Arc::new(AtomicBool::new(false));
1361 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
1365 let watchdog = {
1366 let token = Arc::clone(&token);
1367 let delay = full_parse * attempt / 6;
1368 std::thread::spawn(move || {
1369 std::thread::sleep(delay);
1370 token.store(true, Ordering::SeqCst);
1371 })
1372 };
1373 let cancelled = session.parse_modules(false, Some(&token));
1374 watchdog.join().expect("watchdog thread");
1375
1376 let parsed = cancelled.modules.len();
1377 assert!(
1378 parsed < full_count,
1379 "the parse returned all {full_count} modules, so the loop never read the token"
1380 );
1381 cancelled_session = Some(session);
1382 if parsed > 0 {
1383 partial = Some(parsed);
1384 break;
1385 }
1386 }
1387 let parsed = partial.expect(
1388 "no attempt flipped the token while the loop was running, so this never measured a \
1389 stop part way through",
1390 );
1391 assert!(parsed < full_count);
1392
1393 assert!(
1394 cancelled_session
1395 .expect("a cancelled session")
1396 .parsed_cache
1397 .lock()
1398 .expect("parse cache")
1399 .is_none(),
1400 "a truncated parse must not be retained as this session's warm cache"
1401 );
1402 let recovered = uncached_session(root).parse_modules(false, None);
1403 assert_eq!(
1404 recovered.modules.len(),
1405 full_count,
1406 "a later uncancelled parse must still see the whole project"
1407 );
1408 }
1409
1410 #[test]
1411 fn session_retains_workspace_metadata_from_config_load() {
1412 let project = tempfile::tempdir().expect("project");
1413 let root = project.path();
1414 std::fs::write(
1415 root.join("package.json"),
1416 r#"{"name":"root","workspaces":["packages/*"]}"#,
1417 )
1418 .expect("write root package");
1419 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1420 std::fs::write(
1421 root.join("packages/a/package.json"),
1422 r#"{"name":"pkg-a","type":"module"}"#,
1423 )
1424 .expect("write workspace package");
1425
1426 let session = AnalysisSession::load(root, None).expect("session loads");
1427
1428 assert!(
1429 session
1430 .workspaces()
1431 .iter()
1432 .any(|workspace| workspace.name == "pkg-a"),
1433 "session must retain workspace metadata discovered during config load"
1434 );
1435 }
1436
1437 #[test]
1438 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1439 let project = tempfile::tempdir().expect("project");
1440 let root = project.path();
1441 std::fs::create_dir(root.join("src")).expect("create source directory");
1442 std::fs::write(
1443 root.join("package.json"),
1444 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1445 )
1446 .expect("write package manifest");
1447 std::fs::write(
1448 root.join("vitest.config.ts"),
1449 "import './src/feature';\nexport default {};\n",
1450 )
1451 .expect("write vitest config");
1452 std::fs::write(
1453 root.join("src/feature.ts"),
1454 "export const feature = true;\n",
1455 )
1456 .expect("write reachable source");
1457 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
1458 .expect("write hidden source");
1459
1460 let unfiltered = AnalysisSession::load(root, None)
1461 .expect("unfiltered session loads")
1462 .analyze_dead_code()
1463 .expect("unfiltered analysis succeeds");
1464 assert!(
1465 unfiltered
1466 .results
1467 .unused_files
1468 .iter()
1469 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
1470 );
1471
1472 std::fs::write(
1473 root.join(".fallowrc.json"),
1474 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1475 )
1476 .expect("write fallow config");
1477 let session = AnalysisSession::load(root, None).expect("filtered session loads");
1478 let hidden_path = root.join("src/hidden.ts");
1479 assert!(session.files().iter().any(|file| file.path == hidden_path));
1480
1481 let filtered = session
1482 .analyze_dead_code_with_artifacts(false, true)
1483 .expect("filtered analysis succeeds");
1484 assert!(
1485 filtered
1486 .results
1487 .unused_files
1488 .iter()
1489 .all(|finding| finding.file.path != hidden_path)
1490 );
1491 assert!(
1492 filtered
1493 .graph
1494 .as_ref()
1495 .is_some_and(|graph| graph.module_count() == session.files().len())
1496 );
1497 }
1498
1499 #[test]
1500 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
1501 use fallow_types::output_dead_code::UnusedFileFinding;
1502 use fallow_types::results::UnusedFile;
1503
1504 let project = tempfile::tempdir().expect("project");
1505 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1506 r#"{"ignoreFindings":["**/*.ts"]}"#,
1507 )
1508 .expect("config parses")
1509 .resolve(
1510 project.path().to_path_buf(),
1511 fallow_config::OutputFormat::Human,
1512 1,
1513 true,
1514 true,
1515 None,
1516 );
1517 let outside = project
1518 .path()
1519 .parent()
1520 .expect("project has parent")
1521 .join("outside.ts");
1522 let mut results = AnalysisResults {
1523 unused_files: vec![
1524 UnusedFileFinding::with_actions(UnusedFile {
1525 path: PathBuf::from(r"src\hidden.ts"),
1526 }),
1527 UnusedFileFinding::with_actions(UnusedFile {
1528 path: outside.clone(),
1529 }),
1530 ],
1531 ..AnalysisResults::default()
1532 };
1533
1534 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
1535
1536 assert_eq!(results.unused_files.len(), 1);
1537 assert_eq!(results.unused_files[0].file.path, outside);
1538 }
1539
1540 #[test]
1541 fn warm_parse_cache_reuses_module_storage() {
1542 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1543 let first = session.parse_modules(true, None);
1544 let second = session.parse_modules(false, None);
1545
1546 assert!(
1547 Arc::ptr_eq(&first.modules, &second.modules),
1548 "warm session queries must share parsed module storage"
1549 );
1550 }
1551
1552 #[test]
1553 fn warm_styling_cache_reuses_artifact_allocation() {
1554 let project = tempfile::tempdir().expect("project");
1555 let root = project.path();
1556 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
1557 .expect("write stylesheet");
1558 let session = AnalysisSession::load_default(root);
1559
1560 let first = session.styling_analysis_artifacts();
1561 let second = session.styling_analysis_artifacts();
1562
1563 assert!(
1564 Arc::ptr_eq(&first, &second),
1565 "warm styling queries must share the cached artifact allocation"
1566 );
1567 }
1568
1569 #[test]
1570 fn shared_parsed_modules_reuse_public_session_storage() {
1571 let (_project, session) = session_with_source("export const value = 1;\n");
1572 let first = session.shared_parsed_modules(true);
1573 let second = session.shared_parsed_modules(false);
1574
1575 assert!(Arc::ptr_eq(&first, &second));
1576 }
1577
1578 #[test]
1579 fn shared_parsed_parts_reuse_public_session_storage() {
1580 let (_project, session) = session_with_source("export const value = 1;\n");
1581 let cached = session.shared_parsed_modules(true);
1582 let parts = session.shared_parsed_parts(false);
1583
1584 assert!(Arc::ptr_eq(&cached, &parts.modules));
1585 }
1586
1587 #[test]
1588 fn warm_complexity_artifacts_reuse_cached_module_storage() {
1589 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1590 let cached = session.parse_modules(true, None);
1591 let artifacts = session
1592 .analyze_dead_code_with_reuse_artifacts(true, true, false)
1593 .expect("analysis succeeds");
1594 let retained = artifacts.modules.expect("complexity modules retained");
1595
1596 assert!(
1597 Arc::ptr_eq(&cached.modules, &retained),
1598 "warm complexity artifacts must share parsed module storage"
1599 );
1600 }
1601
1602 #[test]
1603 fn shared_and_owned_artifacts_preserve_output_bytes() {
1604 let (_project, session) = session_with_source(
1605 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1606 );
1607 let owned = session
1608 .analyze_dead_code_with_artifacts(true, true)
1609 .expect("owned analysis succeeds");
1610 let shared = session
1611 .analyze_dead_code_with_shared_artifacts(true, true)
1612 .expect("shared analysis succeeds");
1613
1614 assert_eq!(
1615 serde_json::to_vec(&owned.results).expect("serialize owned results"),
1616 serde_json::to_vec(&shared.results).expect("serialize shared results")
1617 );
1618 assert_eq!(owned.file_hashes, shared.file_hashes);
1619 assert_eq!(
1620 owned
1621 .modules
1622 .as_deref()
1623 .unwrap_or_default()
1624 .iter()
1625 .map(|module| module.content_hash)
1626 .collect::<Vec<_>>(),
1627 shared
1628 .modules
1629 .as_deref()
1630 .unwrap_or_default()
1631 .iter()
1632 .map(|module| module.content_hash)
1633 .collect::<Vec<_>>()
1634 );
1635 }
1636
1637 #[test]
1638 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1639 let project = tempfile::tempdir().expect("project");
1640 let root = project.path();
1641 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1642 std::fs::write(
1643 root.join("package.json"),
1644 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1645 )
1646 .expect("write package manifest");
1647 std::fs::write(
1648 root.join("app/routes/home.tsx"),
1649 r#"
1650import { useLoaderData } from "react-router";
1651export function loader() { return { opaque: "value" }; }
1652export default function Home() {
1653 const data = useLoaderData<typeof loader>();
1654 const copy = { ...data };
1655 return JSON.stringify(copy);
1656}
1657"#,
1658 )
1659 .expect("write route module");
1660
1661 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1662 let cold_parse = cold_session.parsed_parts(false);
1663 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1664 let cold = cold_session
1665 .analyze_dead_code()
1666 .expect("cold analysis succeeds");
1667
1668 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1669 let warm_parse = warm_session.parsed_parts(false);
1670 assert!(
1671 warm_parse.cache_hits > 0,
1672 "second session must use disk cache"
1673 );
1674 let warm = warm_session
1675 .analyze_dead_code()
1676 .expect("warm analysis succeeds");
1677
1678 assert!(
1679 cold.results.unused_load_data_keys.is_empty(),
1680 "cold analysis must abstain for an opaque route-loader use"
1681 );
1682 assert_eq!(
1683 serde_json::to_vec(&cold.results).expect("serialize cold results"),
1684 serde_json::to_vec(&warm.results).expect("serialize warm results"),
1685 "warm route-loader analysis must match cold analysis"
1686 );
1687 }
1688
1689 #[test]
1690 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
1691 let project = tempfile::tempdir().expect("project");
1692 let root = project.path();
1693 std::fs::create_dir(root.join("src")).expect("create source directory");
1694 std::fs::write(
1695 root.join("package.json"),
1696 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
1697 )
1698 .expect("write package manifest");
1699 std::fs::write(
1700 root.join("src/dependency.ts"),
1701 "export function dependency() { return 'real'; }\n",
1702 )
1703 .expect("write dependency");
1704 std::fs::write(
1705 root.join("src/wrapper.ts"),
1706 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
1707 )
1708 .expect("write wrapper");
1709 std::fs::write(
1710 root.join("src/index.ts"),
1711 "export { wrapper } from './wrapper';\n",
1712 )
1713 .expect("write entry point");
1714 std::fs::write(
1715 root.join("src/wrapper.test.ts"),
1716 r#"
1717import { vi } from "vitest";
1718vi.mock("./dependency", () => ({ dependency: () => "mock" }));
1719import { wrapper } from "./wrapper";
1720wrapper();
1721"#,
1722 )
1723 .expect("write test");
1724
1725 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1726 let dependency_id = cold_session
1727 .files()
1728 .iter()
1729 .find(|file| file.path == root.join("src/dependency.ts"))
1730 .expect("dependency discovered")
1731 .id;
1732 let cold = cold_session
1733 .analyze_dead_code_with_artifacts(false, true)
1734 .expect("cold analysis succeeds");
1735 let cold_exports = crate::module_graph::module_value_exports(
1736 cold.graph.as_ref().expect("cold graph retained"),
1737 );
1738 assert!(
1739 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
1740 "cold analysis must persist the graph cache"
1741 );
1742
1743 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1744 let warm = warm_session
1745 .analyze_dead_code_with_artifacts(false, true)
1746 .expect("warm analysis succeeds");
1747 let warm_exports = crate::module_graph::module_value_exports(
1748 warm.graph.as_ref().expect("warm graph retained"),
1749 );
1750
1751 let dependency = cold_exports
1752 .iter()
1753 .find(|export| export.file_id == dependency_id && export.name == "dependency")
1754 .expect("dependency export retained");
1755 assert!(!dependency.test_referenced);
1756 assert_eq!(warm_exports, cold_exports);
1757 }
1758
1759 #[test]
1760 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1761 let project = tempfile::tempdir().expect("project");
1762 let root = project.path();
1763 std::fs::create_dir(root.join("src")).expect("create source directory");
1764 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1765 .expect("write package manifest");
1766 for name in ["a.ts", "b.ts", "c.ts"] {
1767 std::fs::write(
1768 root.join("src").join(name),
1769 format!("export const {} = 1;\n", name.replace('.', "_")),
1770 )
1771 .expect("write source");
1772 }
1773 let session = AnalysisSession::load(root, None).expect("session loads");
1774 let removed_path = root.join("src/b.ts");
1775 let removed_id = session
1776 .files()
1777 .iter()
1778 .find(|file| file.path == removed_path)
1779 .expect("removed source discovered")
1780 .id;
1781 std::fs::remove_file(&removed_path).expect("remove source after discovery");
1782
1783 let parts = session.parsed_parts(false);
1784
1785 assert!(
1786 parts
1787 .modules
1788 .iter()
1789 .all(|module| module.file_id != removed_id),
1790 "unreadable file must not receive a placeholder module"
1791 );
1792 let diagnostic = parts
1793 .workspace_diagnostics
1794 .iter()
1795 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1796 .expect("parsed session parts carry source read failure");
1797 assert_eq!(diagnostic.path, removed_path);
1798 assert!(
1799 session
1800 .current_workspace_diagnostics()
1801 .iter()
1802 .any(|diagnostic| {
1803 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1804 }),
1805 "session output carries parse-time source diagnostics"
1806 );
1807 }
1808
1809 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
1810 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
1811 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
1812
1813 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
1814 diagnostics
1815 .iter()
1816 .any(|diagnostic| diagnostic.kind.id() == id)
1817 }
1818
1819 fn write_single_source_project(root: &Path, manifest: &str) {
1820 std::fs::create_dir(root.join("src")).expect("create source directory");
1821 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
1822 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
1823 .expect("write source");
1824 }
1825
1826 #[test]
1832 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
1833 let project = tempfile::tempdir().expect("project");
1834 let root = project.path();
1835 write_single_source_project(
1836 root,
1837 r#"{"name":"issue-2366-engine-session","private":true}"#,
1838 );
1839 std::fs::write(
1840 root.join("pnpm-workspace.yaml"),
1841 MALFORMED_PNPM_WORKSPACE_YAML,
1842 )
1843 .expect("write malformed workspace yaml");
1844
1845 let broken = AnalysisSession::load(root, None).expect("session loads");
1846 broken
1847 .analyze_dead_code()
1848 .expect("analysis on the malformed yaml succeeds");
1849 assert!(
1850 has_diagnostic_kind(
1851 &broken.current_workspace_diagnostics(),
1852 "malformed-pnpm-workspace-yaml"
1853 ),
1854 "the first session surfaces the malformed yaml: {:?}",
1855 broken.current_workspace_diagnostics()
1856 );
1857
1858 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
1859 .expect("fix workspace yaml");
1860
1861 let fixed = AnalysisSession::load(root, None).expect("session loads");
1862 fixed
1863 .analyze_dead_code()
1864 .expect("analysis on the fixed yaml succeeds");
1865 let current = fixed.current_workspace_diagnostics();
1866 assert!(
1867 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
1868 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
1869 );
1870 }
1871
1872 #[test]
1879 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
1880 let project = tempfile::tempdir().expect("project");
1881 let root = project.path();
1882 write_single_source_project(
1883 root,
1884 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
1885 );
1886 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
1887 .expect("write bun.lockb placeholder");
1888 let config = fallow_config::FallowConfig::default().resolve(
1889 root.to_path_buf(),
1890 fallow_config::OutputFormat::Json,
1891 1,
1892 true,
1893 true,
1894 None,
1895 );
1896 let reload_config = || {
1897 let (_, diagnostics) =
1898 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
1899 .expect("workspace discovery succeeds");
1900 fallow_config::stash_workspace_diagnostics(root, diagnostics);
1901 };
1902
1903 reload_config();
1904 let first =
1905 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
1906 first
1907 .analyze_dead_code()
1908 .expect("analysis with bun.lockb only succeeds");
1909 assert!(
1910 has_diagnostic_kind(
1911 &first.current_workspace_diagnostics(),
1912 "bun-lockb-override-resolution-skipped"
1913 ),
1914 "the first run surfaces the bun.lockb skip: {:?}",
1915 first.current_workspace_diagnostics()
1916 );
1917
1918 std::fs::write(
1919 root.join("bun.lock"),
1920 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
1921 )
1922 .expect("write text bun.lock");
1923
1924 reload_config();
1925 let rerun =
1926 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
1927 rerun
1928 .analyze_dead_code()
1929 .expect("analysis with the text bun.lock succeeds");
1930 let current = rerun.current_workspace_diagnostics();
1931 assert!(
1932 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
1933 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
1934 );
1935 }
1936
1937 #[test]
1950 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
1951 let project = tempfile::tempdir().expect("project");
1952 let root = project.path();
1953 write_single_source_project(
1954 root,
1955 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
1956 );
1957 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
1958 .expect("write oversized source");
1959 let mut config = fallow_config::FallowConfig::default().resolve(
1960 root.to_path_buf(),
1961 fallow_config::OutputFormat::Json,
1962 1,
1963 true,
1964 true,
1965 None,
1966 );
1967 config.max_file_size_bytes = Some(1024);
1968
1969 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
1970
1971 fallow_config::append_workspace_diagnostics(
1976 root,
1977 vec![WorkspaceDiagnostic::new(
1978 root,
1979 root.join("src/other-walk-only.ts"),
1980 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
1981 size_bytes: 4096,
1982 },
1983 )],
1984 );
1985
1986 let current = session.current_workspace_diagnostics();
1987 let skipped: Vec<&Path> = current
1988 .iter()
1989 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
1990 .map(|diagnostic| diagnostic.path.as_path())
1991 .collect();
1992 assert_eq!(
1993 skipped.len(),
1994 1,
1995 "the session reports its own walk's skips only: {skipped:?}"
1996 );
1997 assert!(
1998 skipped[0].ends_with("src/huge.ts"),
1999 "the surviving skip is this walk's own: {skipped:?}"
2000 );
2001 }
2002
2003 #[test]
2011 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
2012 let project = tempfile::tempdir().expect("project");
2013 let root = project.path();
2014 write_single_source_project(
2015 root,
2016 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2017 );
2018 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2019 .expect("write bun.lockb placeholder");
2020 let config = fallow_config::FallowConfig::default().resolve(
2021 root.to_path_buf(),
2022 fallow_config::OutputFormat::Json,
2023 1,
2024 true,
2025 true,
2026 None,
2027 );
2028 let reload_config = || {
2029 let (_, diagnostics) =
2030 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2031 .expect("workspace discovery succeeds");
2032 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2033 };
2034
2035 reload_config();
2036 let analyzing =
2037 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2038 analyzing
2039 .analyze_dead_code()
2040 .expect("analysis with bun.lockb only succeeds");
2041
2042 reload_config();
2045
2046 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2047 let current = later.current_workspace_diagnostics();
2048 assert!(
2049 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2050 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
2051 {current:?}"
2052 );
2053 }
2054}