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 #[must_use]
351 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
352 &self.workspace_diagnostics
353 }
354
355 #[must_use]
368 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
369 merge_workspace_diagnostics(
370 self.workspace_diagnostics.clone(),
371 fallow_config::registry_diagnostics_to_fold(&self.config.root),
372 )
373 }
374
375 pub(crate) fn styling_analysis_artifacts(
376 &self,
377 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
378 if let Ok(cache) = self.styling_cache.lock()
379 && let Some(artifacts) = cache.as_ref()
380 {
381 return Arc::clone(artifacts);
382 }
383
384 let modules = self.shared_parsed_modules(false);
385 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
386 self.files(),
387 &modules,
388 self.config(),
389 ));
390 if let Ok(mut cache) = self.styling_cache.lock() {
391 *cache = Some(Arc::clone(&artifacts));
392 }
393 artifacts
394 }
395
396 #[must_use]
398 pub fn into_parts(self) -> AnalysisSessionParts {
399 let workspace_diagnostics = self.current_workspace_diagnostics();
400 AnalysisSessionParts {
401 config: self.config,
402 config_path: self.config_path,
403 files: self.discovery.into_files(),
404 workspaces: self.workspaces,
405 workspace_diagnostics,
406 }
407 }
408
409 #[must_use]
411 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
412 let AnalysisSessionParts {
413 config,
414 config_path,
415 files,
416 workspaces,
417 workspace_diagnostics,
418 } = self.into_parts();
419 let ParsedModules {
420 modules,
421 metrics,
422 source_diagnostics,
423 } = parse_files_with_config(&config, &files, need_complexity, None);
424 ParsedAnalysisSessionParts {
425 config,
426 config_path,
427 files,
428 modules,
429 workspaces,
430 workspace_diagnostics: merge_workspace_diagnostics(
431 workspace_diagnostics,
432 source_diagnostics,
433 ),
434 parse_ms: metrics.parse_ms,
435 cache_update_ms: metrics.cache_ms,
436 cache_hits: metrics.cache_hits,
437 cache_misses: metrics.cache_misses,
438 parse_cpu_ms: metrics.parse_cpu_ms,
439 }
440 }
441
442 #[must_use]
444 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
445 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
446 self.parsed_parts_from_modules(modules.to_vec(), metrics)
447 }
448
449 #[must_use]
451 pub(crate) fn shared_parsed_parts(
452 &self,
453 need_complexity: bool,
454 ) -> SharedParsedAnalysisSessionParts {
455 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
456 SharedParsedAnalysisSessionParts {
457 config: self.config.clone(),
458 files: self.discovery.files().to_vec(),
459 modules,
460 workspaces: self.workspaces.clone(),
461 workspace_diagnostics: self.current_workspace_diagnostics(),
462 parse_ms: metrics.parse_ms,
463 parse_cpu_ms: metrics.parse_cpu_ms,
464 }
465 }
466
467 #[doc(hidden)]
473 #[must_use]
474 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
475 self.parse_modules(need_complexity, None).modules
476 }
477
478 pub(crate) fn shared_parsed_modules_cancellable(
489 &self,
490 need_complexity: bool,
491 stage: &str,
492 ) -> EngineResult<Arc<[ModuleInfo]>> {
493 self.ensure_not_cancelled("parsing")?;
494 let modules = self
495 .parse_modules(need_complexity, self.cancellation.as_deref())
496 .modules;
497 self.ensure_not_cancelled(stage)?;
498 Ok(modules)
499 }
500
501 #[must_use]
504 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
505 let ParsedModules {
506 modules,
507 metrics,
508 source_diagnostics: _,
509 } = parse_files_with_config(&self.config, self.files(), need_complexity, None);
510 self.parsed_parts_from_modules(modules, metrics)
511 }
512
513 fn parsed_parts_from_modules(
514 &self,
515 modules: Vec<ModuleInfo>,
516 metrics: core_backend::ParseMetrics,
517 ) -> ParsedAnalysisSessionParts {
518 ParsedAnalysisSessionParts {
519 config: self.config.clone(),
520 config_path: self.config_path.clone(),
521 files: self.discovery.files().to_vec(),
522 modules,
523 workspaces: self.workspaces.clone(),
524 workspace_diagnostics: self.current_workspace_diagnostics(),
525 parse_ms: metrics.parse_ms,
526 cache_update_ms: metrics.cache_ms,
527 cache_hits: metrics.cache_hits,
528 cache_misses: metrics.cache_misses,
529 parse_cpu_ms: metrics.parse_cpu_ms,
530 }
531 }
532
533 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
539 self.analyze_dead_code_with_artifacts(false, false)
540 .map(|output| DeadCodeAnalysis {
541 results: output.results,
542 })
543 }
544
545 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
551 self.analyze_dead_code_with_artifacts(true, false)
552 .map(|output| DeadCodeAnalysisOutput {
553 results: output.results,
554 modules: output.modules,
555 files: output.files,
556 })
557 }
558
559 pub fn analyze_dead_code_with_artifacts(
565 &self,
566 need_complexity: bool,
567 retain_graph: bool,
568 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
569 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
570 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
571 }
572
573 #[doc(hidden)]
583 pub fn analyze_dead_code_with_shared_artifacts(
584 &self,
585 need_complexity: bool,
586 retain_graph: bool,
587 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
588 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
589 }
590
591 pub fn analyze_dead_code_retaining_files(
598 &self,
599 need_complexity: bool,
600 retain_graph: bool,
601 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
602 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
603 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
604 }
605
606 pub fn analyze_dead_code_with_parsed_modules(
615 &self,
616 modules: &[ModuleInfo],
617 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
618 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
619 }
620
621 #[doc(hidden)]
627 pub(crate) fn analyze_dead_code_with_shared_modules(
628 &self,
629 modules: Arc<[ModuleInfo]>,
630 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
631 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
632 config: &self.config,
633 discovery: &self.discovery,
634 modules,
635 metrics: reused_parse_metrics(),
636 collect_usages: true,
637 retain_graph: true,
638 retain_modules: false,
639 retain_files: false,
640 cancellation: self.cancellation.as_deref(),
641 })
642 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
643 }
644
645 fn analyze_dead_code_with_reuse_artifacts(
646 &self,
647 need_complexity: bool,
648 retain_graph: bool,
649 retain_files: bool,
650 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
651 self.ensure_not_cancelled("parsing")?;
652 let SharedParsedModules { modules, metrics } =
653 self.parse_modules(need_complexity, self.cancellation.as_deref());
654 self.ensure_not_cancelled("the dead-code pipeline")?;
658 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
659 config: &self.config,
660 discovery: &self.discovery,
661 modules,
662 metrics,
663 collect_usages: true,
664 retain_graph,
665 retain_modules: need_complexity,
666 retain_files,
667 cancellation: self.cancellation.as_deref(),
668 })
669 }
670
671 pub fn analyze_dead_code_with_session_artifacts(
682 &self,
683 need_complexity: bool,
684 retain_graph: bool,
685 changed_files: Option<FxHashSet<PathBuf>>,
686 ) -> EngineResult<AnalysisSessionArtifacts> {
687 Ok(AnalysisSessionArtifacts {
688 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
689 changed_files,
690 source_fingerprints: self.source_fingerprints(),
691 })
692 }
693
694 #[must_use]
696 pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
697 duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
698 }
699
700 #[must_use]
702 pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
703 duplicates::find_duplicates(&self.config.root, self.files(), config)
704 }
705
706 pub fn analyze_project_with(
715 &self,
716 duplicates_config: &DuplicatesConfig,
717 retain_complexity_artifacts: bool,
718 ) -> EngineResult<ProjectAnalysisOutput> {
719 self.analyze_project_with_artifacts(
720 duplicates_config,
721 ProjectAnalysisArtifactOptions {
722 retain_complexity_artifacts,
723 ..ProjectAnalysisArtifactOptions::default()
724 },
725 )
726 .map(ProjectAnalysisArtifacts::into_output)
727 }
728
729 pub fn analyze_project_with_artifacts(
739 &self,
740 duplicates_config: &DuplicatesConfig,
741 options: ProjectAnalysisArtifactOptions,
742 ) -> EngineResult<ProjectAnalysisArtifacts> {
743 self.ensure_not_cancelled("duplication detection")?;
744 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
745 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
746 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
747 self.find_duplicates_touching_files_with_defaults(
748 duplicates_config,
749 &changed_files,
750 cache_dir,
751 )
752 .report
753 } else {
754 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
755 .report
756 };
757 self.ensure_not_cancelled("the dead-code half of project analysis")?;
761 let source_fingerprints = options
762 .collect_source_fingerprints
763 .then(|| self.source_fingerprints());
764 Ok(ProjectAnalysisArtifacts {
765 dead_code: self.analyze_dead_code_with_artifacts(
766 options.retain_complexity_artifacts,
767 options.retain_graph,
768 )?,
769 duplication,
770 changed_files: options.changed_files,
771 source_fingerprints,
772 })
773 }
774
775 #[must_use]
777 pub fn find_duplicates_with_defaults(
778 &self,
779 config: &DuplicatesConfig,
780 cache_dir: Option<&Path>,
781 ) -> DuplicationAnalysis {
782 duplicates::find_duplicates_with_defaults(
783 &self.config.root,
784 self.files(),
785 config,
786 cache_dir,
787 )
788 }
789
790 #[must_use]
792 pub fn find_duplicates_touching_files_with_defaults(
793 &self,
794 config: &DuplicatesConfig,
795 changed_files: &[PathBuf],
796 cache_dir: Option<&Path>,
797 ) -> DuplicationAnalysis {
798 duplicates::find_duplicates_touching_files_with_defaults(
799 &self.config.root,
800 self.files(),
801 config,
802 changed_files,
803 cache_dir,
804 )
805 }
806
807 fn parse_modules(
813 &self,
814 need_complexity: bool,
815 cancellation: Option<&AtomicBool>,
816 ) -> SharedParsedModules {
817 let fingerprints = source_fingerprints_for_files(self.files());
818 if let Some(fingerprints) = fingerprints.as_ref()
819 && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
820 {
821 return SharedParsedModules {
822 modules,
823 metrics: core_backend::ParseMetrics {
824 parse_ms: 0.0,
825 cache_ms: 0.0,
826 cache_hits: 0,
827 cache_misses: 0,
828 parse_cpu_ms: 0.0,
829 cache_rejection: None,
830 },
831 };
832 }
833
834 let ParsedModules {
835 modules,
836 metrics,
837 source_diagnostics: _,
838 } = parse_files_with_config(&self.config, self.files(), need_complexity, cancellation);
839 let modules: Arc<[ModuleInfo]> = modules.into();
840 if !token_is_set(cancellation)
844 && let Some(fingerprints) = fingerprints
845 && let Ok(mut cache) = self.parsed_cache.lock()
846 {
847 *cache = Some(ParsedModuleCache {
848 need_complexity,
849 fingerprints,
850 modules: Arc::clone(&modules),
851 });
852 }
853 SharedParsedModules { modules, metrics }
854 }
855
856 fn cached_modules(
857 &self,
858 need_complexity: bool,
859 fingerprints: &[SourceFingerprint],
860 ) -> Option<Arc<[ModuleInfo]>> {
861 let Ok(cache) = self.parsed_cache.lock() else {
862 return None;
863 };
864 let cache = cache.as_ref()?;
865 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
866 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
867 return Some(Arc::clone(&cache.modules));
868 }
869 None
870 }
871}
872
873struct ParsedModules {
874 modules: Vec<ModuleInfo>,
875 metrics: core_backend::ParseMetrics,
876 source_diagnostics: Vec<WorkspaceDiagnostic>,
877}
878
879struct SharedParsedModules {
880 modules: Arc<[ModuleInfo]>,
881 metrics: core_backend::ParseMetrics,
882}
883
884fn token_is_set(cancellation: Option<&AtomicBool>) -> bool {
885 cancellation.is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
886}
887
888fn parse_files_with_config(
889 config: &ResolvedConfig,
890 files: &[DiscoveredFile],
891 need_complexity: bool,
892 cancellation: Option<&AtomicBool>,
893) -> ParsedModules {
894 let parse_start = Instant::now();
895 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
896 let mut cache_rejection = None;
897 let mut cache = if config.no_cache {
898 None
899 } else {
900 match fallow_extract::cache::CacheStore::load(
901 &config.cache_dir,
902 &config.root,
903 config.cache_config_hash,
904 cache_max_size_bytes,
905 ) {
906 Ok(store) => Some(store),
907 Err(rejection) => {
908 cache_rejection = Some(rejection);
909 None
910 }
911 }
912 };
913 let parse_result =
914 crate::source::parse_all_files(files, cache.as_ref(), need_complexity, cancellation);
915 let mut source_diagnostics =
916 fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
917 source_diagnostics.extend(fallow_config::record_source_parse_degradations(
918 &config.root,
919 &parse_result.parse_degradations,
920 ));
921 let mut modules = parse_result.modules;
922 for module in &mut modules {
923 module.prepare_analysis_facts();
924 }
925 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
926 let cache_ms = if token_is_set(cancellation) {
927 0.0
928 } else {
929 update_parse_cache_if_enabled(config, &mut cache, &modules, files, need_complexity)
930 };
931 let metrics = core_backend::ParseMetrics {
932 parse_ms,
933 cache_ms,
934 cache_hits: parse_result.cache_hits,
935 cache_misses: parse_result.cache_misses,
936 parse_cpu_ms: parse_result.parse_cpu_ms,
937 cache_rejection,
938 };
939 ParsedModules {
940 modules,
941 metrics,
942 source_diagnostics,
943 }
944}
945
946fn reused_parse_metrics() -> core_backend::ParseMetrics {
947 core_backend::ParseMetrics {
948 parse_ms: 0.0,
949 cache_ms: 0.0,
950 cache_hits: 0,
951 cache_misses: 0,
952 parse_cpu_ms: 0.0,
953 cache_rejection: None,
954 }
955}
956
957fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
958 files
959 .iter()
960 .map(|file| {
961 std::fs::metadata(&file.path)
962 .ok()
963 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
964 .filter(|fingerprint| fingerprint.has_known_mtime())
965 })
966 .collect()
967}
968
969fn update_parse_cache_if_enabled(
970 config: &ResolvedConfig,
971 cache: &mut Option<fallow_extract::cache::CacheStore>,
972 modules: &[ModuleInfo],
973 files: &[DiscoveredFile],
974 need_complexity: bool,
975) -> f64 {
976 let start = Instant::now();
977 if config.no_cache {
978 return start.elapsed().as_secs_f64() * 1000.0;
979 }
980
981 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
982 let store = cache.get_or_insert_with(|| fallow_extract::cache::CacheStore::new(&config.root));
983 if update_parse_cache(store, modules, files, need_complexity)
984 && let Err(error) = store.save(
985 &config.cache_dir,
986 config.cache_config_hash,
987 cache_max_size_bytes,
988 )
989 {
990 tracing::warn!("Failed to save cache: {error}");
991 }
992 start.elapsed().as_secs_f64() * 1000.0
993}
994
995fn update_parse_cache(
1000 store: &mut fallow_extract::cache::CacheStore,
1001 modules: &[ModuleInfo],
1002 files: &[DiscoveredFile],
1003 need_complexity: bool,
1004) -> bool {
1005 let mut dirty = false;
1006 for module in modules {
1007 if let Some(file) = files.get(module.file_id.0 as usize) {
1008 let fingerprint = source_fingerprint(&file.path);
1009 if let Some(cached) = store.get_by_path_only(&file.path)
1010 && cached.content_hash == module.content_hash
1011 {
1012 let stale_metadata = cached.source_fingerprint() != fingerprint;
1013 let adds_complexity = need_complexity && !cached.complexity_extracted;
1014 if stale_metadata || adds_complexity {
1015 let preserved_last_access = cached.last_access_secs;
1016 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
1017 .then(|| cached.complexity.clone());
1018 let mut refreshed = fallow_extract::cache::module_to_cached(
1019 module,
1020 fingerprint,
1021 need_complexity,
1022 );
1023 refreshed.last_access_secs = preserved_last_access;
1024 if let Some(complexity) = preserved_complexity {
1025 refreshed.complexity = complexity;
1026 refreshed.complexity_extracted = true;
1027 }
1028 store.insert(&file.path, refreshed);
1029 dirty = true;
1030 }
1031 continue;
1032 }
1033 store.insert(
1034 &file.path,
1035 fallow_extract::cache::module_to_cached(module, fingerprint, need_complexity),
1036 );
1037 dirty = true;
1038 }
1039 }
1040 store.retain_paths(files) || dirty
1041}
1042
1043fn source_fingerprint(path: &Path) -> SourceFingerprint {
1044 std::fs::metadata(path).map_or_else(
1045 |_| SourceFingerprint::new(0, 0),
1046 |metadata| SourceFingerprint::from_metadata(&metadata),
1047 )
1048}
1049
1050struct EngineDeadCodePipelineInput<'a> {
1051 config: &'a ResolvedConfig,
1052 discovery: &'a crate::discover::AnalysisDiscovery,
1053 modules: Arc<[ModuleInfo]>,
1054 metrics: core_backend::ParseMetrics,
1055 collect_usages: bool,
1056 retain_graph: bool,
1057 retain_modules: bool,
1058 retain_files: bool,
1059 cancellation: Option<&'a AtomicBool>,
1060}
1061
1062fn run_engine_owned_dead_code_pipeline(
1063 input: EngineDeadCodePipelineInput<'_>,
1064) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
1065 let EngineDeadCodePipelineInput {
1066 config,
1067 discovery,
1068 modules,
1069 metrics,
1070 collect_usages,
1071 retain_graph,
1072 retain_modules,
1073 retain_files,
1074 cancellation,
1075 } = input;
1076 let stopped = |stage: &str| -> EngineResult<()> {
1077 if token_is_set(cancellation) {
1078 return Err(crate::EngineError::cancelled(stage));
1079 }
1080 Ok(())
1081 };
1082 stopped("the dead-code prelude")?;
1083 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
1084 let prelude_timings = prelude.timings();
1085 stopped("dead-code entry-point discovery")?;
1086 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
1087 stopped("import resolution and graph construction")?;
1088 let (resolved, graph, graph_cache_rejection) =
1089 resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
1090 stopped("the dead-code detectors")?;
1091
1092 let mut detector = core_backend::run_dead_code_detectors(
1093 &prelude,
1094 &graph.graph,
1095 &resolved.project.modules,
1096 &modules,
1097 collect_usages,
1098 &entry_points,
1099 );
1100 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1101 stopped("assembling the dead-code report")?;
1106 let profile =
1107 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1108 retain_timings: retain_graph,
1109 prelude: &prelude,
1110 prelude_timings,
1111 parse_metrics: metrics,
1112 module_count: modules.len(),
1113 entry_points: &entry_points,
1114 resolved: &resolved,
1115 graph: &graph,
1116 detector: &detector,
1117 file_count: discovery.files().len(),
1118 workspace_count: discovery.workspaces().len(),
1119 graph_cache_rejection,
1120 });
1121 let script_used_packages = prelude.script_used_packages();
1122 prelude.finish();
1123 let file_hashes = collect_file_hashes(&modules, discovery.files());
1124
1125 Ok(SharedDeadCodeAnalysisArtifacts {
1126 results: detector.results,
1127 timings: profile.timings,
1128 graph: retain_graph.then_some(graph.graph),
1129 modules: retain_modules.then_some(modules),
1130 files: retain_files.then(|| discovery.files().to_vec()),
1131 script_used_packages,
1132 file_hashes,
1133 })
1134}
1135
1136fn resolve_or_build_dead_code_graph(
1144 prelude: &core_backend::DeadCodeBackendPrelude,
1145 entry_points: &core_backend::DeadCodeEntryPoints,
1146 modules: &[ModuleInfo],
1147) -> (
1148 core_backend::DeadCodeResolvedModules,
1149 core_backend::DeadCodeGraphRun,
1150 Option<CacheRejection>,
1151) {
1152 let rejection =
1153 match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1154 Ok((resolved, graph)) => return (resolved, graph, None),
1155 Err(rejection) => rejection,
1156 };
1157
1158 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1159 let graph =
1160 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1161 (resolved, graph, rejection)
1162}
1163
1164fn collect_file_hashes(
1165 modules: &[ModuleInfo],
1166 files: &[DiscoveredFile],
1167) -> FxHashMap<PathBuf, u64> {
1168 modules
1169 .iter()
1170 .filter_map(|module| {
1171 files
1172 .get(module.file_id.0 as usize)
1173 .map(|file| (file.path.clone(), module.content_hash))
1174 })
1175 .collect()
1176}
1177
1178pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1179 config: &ResolvedConfig,
1180 modules: &[ModuleInfo],
1181) -> EngineResult<DeadCodeAnalysisArtifacts> {
1182 let (workspaces, _diagnostics, workspaces_ms) =
1183 crate::project_config::collect_workspace_metadata(config)?;
1184 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1185 config,
1186 &workspaces,
1187 workspaces_ms,
1188 );
1189 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1190 config,
1191 discovery: &discovery,
1192 modules: Arc::from(modules),
1193 metrics: reused_parse_metrics(),
1194 collect_usages: true,
1195 retain_graph: true,
1196 retain_modules: false,
1197 retain_files: false,
1198 cancellation: None,
1199 })
1200 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use std::fmt::Write as _;
1206 use std::time::Duration;
1207
1208 use super::*;
1209
1210 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1211 let project = tempfile::tempdir().expect("project");
1212 let root = project.path();
1213 std::fs::create_dir(root.join("src")).expect("create source directory");
1214 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1215 let session = AnalysisSession::load_default(root);
1216 (project, session)
1217 }
1218
1219 #[test]
1222 fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
1223 let project = tempfile::tempdir().expect("project");
1224 let root = project.path();
1225 std::fs::create_dir(root.join("src")).expect("create source directory");
1226 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1227 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
1228
1229 let baseline = AnalysisSession::load_default(root)
1230 .analyze_dead_code()
1231 .expect("an uncancelled session analyzes");
1232 assert!(
1233 !baseline.results.unused_files.is_empty(),
1234 "the fixture must have findings, so an empty result would be a plausible wrong answer"
1235 );
1236
1237 let error = AnalysisSession::load_default(root)
1238 .with_cancellation(Arc::new(AtomicBool::new(true)))
1239 .analyze_dead_code()
1240 .expect_err("a cancelled session must not return results");
1241 assert!(error.is_cancelled(), "unexpected error: {error}");
1242 assert!(error.message().contains("cancelled"));
1243 }
1244
1245 #[test]
1251 fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
1252 let project = tempfile::tempdir().expect("project");
1253 let root = project.path();
1254 std::fs::create_dir(root.join("src")).expect("create source directory");
1255 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1256
1257 let cold = AnalysisSession::load_default(root)
1258 .analyze_dead_code_with_artifacts(false, true)
1259 .expect("cold run analyzes");
1260 assert_eq!(
1261 cold.timings
1262 .expect("cold timings retained")
1263 .graph_cache_rejection,
1264 Some(CacheRejection::Absent),
1265 "a first run has no persisted graph to refuse"
1266 );
1267
1268 std::fs::write(
1269 root.join("src/index.ts"),
1270 "export const entry = 1;\nexport const added = 2;\n",
1271 )
1272 .expect("edit the entry");
1273
1274 let warm = AnalysisSession::load_default(root)
1275 .analyze_dead_code_with_artifacts(false, true)
1276 .expect("warm run analyzes");
1277 assert_eq!(
1278 warm.timings
1279 .expect("warm timings retained")
1280 .graph_cache_rejection,
1281 Some(CacheRejection::FingerprintChanged),
1282 "the decoded graph was refused because a file changed, and the run must say so"
1283 );
1284 }
1285
1286 #[test]
1289 fn a_session_without_a_token_is_never_cancelled() {
1290 let (_project, session) = session_with_source("export const unused = 1;\n");
1291 assert!(!session.is_cancelled());
1292 session
1293 .analyze_dead_code()
1294 .expect("a session without a token analyzes");
1295 }
1296
1297 const PARSE_LOOP_FILES: usize = 400;
1301
1302 fn parse_loop_project() -> tempfile::TempDir {
1303 let project = tempfile::tempdir().expect("project");
1304 let src = project.path().join("src");
1305 std::fs::create_dir_all(&src).expect("src dir");
1306 for module in 0..PARSE_LOOP_FILES {
1307 let mut source = String::new();
1308 for symbol in 0..20 {
1309 let _ = writeln!(
1310 source,
1311 "export const helper{symbol} = (input: number): number => {{\n \
1312 if (input > {symbol}) {{\n return input * {symbol};\n }}\n \
1313 return input - {symbol};\n}};"
1314 );
1315 }
1316 std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
1317 }
1318 project
1319 }
1320
1321 fn uncached_session(root: &Path) -> AnalysisSession {
1324 let mut project_config = crate::project_config::default_project_config(root);
1325 project_config.config.no_cache = true;
1326 AnalysisSession::from_config(project_config)
1327 }
1328
1329 #[test]
1338 fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
1339 let project = parse_loop_project();
1340 let root = project.path();
1341
1342 drop(uncached_session(root).parse_modules(false, None));
1344
1345 let started = Instant::now();
1346 let full = uncached_session(root).parse_modules(false, None);
1347 let full_parse = started.elapsed();
1348 let full_count = full.modules.len();
1349 assert_eq!(
1350 full_count, PARSE_LOOP_FILES,
1351 "the fixture must parse every generated module"
1352 );
1353 assert!(
1354 full_parse >= Duration::from_millis(20),
1355 "the fixture is too small to cancel part way through: {full_parse:?}"
1356 );
1357
1358 let mut partial = None;
1363 let mut cancelled_session = None;
1364 for attempt in 1..=6_u32 {
1365 let token = Arc::new(AtomicBool::new(false));
1366 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
1370 let watchdog = {
1371 let token = Arc::clone(&token);
1372 let delay = full_parse * attempt / 6;
1373 std::thread::spawn(move || {
1374 std::thread::sleep(delay);
1375 token.store(true, Ordering::SeqCst);
1376 })
1377 };
1378 let cancelled = session.parse_modules(false, Some(&token));
1379 watchdog.join().expect("watchdog thread");
1380
1381 let parsed = cancelled.modules.len();
1382 assert!(
1383 parsed < full_count,
1384 "the parse returned all {full_count} modules, so the loop never read the token"
1385 );
1386 cancelled_session = Some(session);
1387 if parsed > 0 {
1388 partial = Some(parsed);
1389 break;
1390 }
1391 }
1392 let parsed = partial.expect(
1393 "no attempt flipped the token while the loop was running, so this never measured a \
1394 stop part way through",
1395 );
1396 assert!(parsed < full_count);
1397
1398 assert!(
1399 cancelled_session
1400 .expect("a cancelled session")
1401 .parsed_cache
1402 .lock()
1403 .expect("parse cache")
1404 .is_none(),
1405 "a truncated parse must not be retained as this session's warm cache"
1406 );
1407 let recovered = uncached_session(root).parse_modules(false, None);
1408 assert_eq!(
1409 recovered.modules.len(),
1410 full_count,
1411 "a later uncancelled parse must still see the whole project"
1412 );
1413 }
1414
1415 #[test]
1416 fn session_retains_workspace_metadata_from_config_load() {
1417 let project = tempfile::tempdir().expect("project");
1418 let root = project.path();
1419 std::fs::write(
1420 root.join("package.json"),
1421 r#"{"name":"root","workspaces":["packages/*"]}"#,
1422 )
1423 .expect("write root package");
1424 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1425 std::fs::write(
1426 root.join("packages/a/package.json"),
1427 r#"{"name":"pkg-a","type":"module"}"#,
1428 )
1429 .expect("write workspace package");
1430
1431 let session = AnalysisSession::load(root, None).expect("session loads");
1432
1433 assert!(
1434 session
1435 .workspaces()
1436 .iter()
1437 .any(|workspace| workspace.name == "pkg-a"),
1438 "session must retain workspace metadata discovered during config load"
1439 );
1440 }
1441
1442 #[test]
1443 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1444 let project = tempfile::tempdir().expect("project");
1445 let root = project.path();
1446 std::fs::create_dir(root.join("src")).expect("create source directory");
1447 std::fs::write(
1448 root.join("package.json"),
1449 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1450 )
1451 .expect("write package manifest");
1452 std::fs::write(
1453 root.join("vitest.config.ts"),
1454 "import './src/feature';\nexport default {};\n",
1455 )
1456 .expect("write vitest config");
1457 std::fs::write(
1458 root.join("src/feature.ts"),
1459 "export const feature = true;\n",
1460 )
1461 .expect("write reachable source");
1462 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
1463 .expect("write hidden source");
1464
1465 let unfiltered = AnalysisSession::load(root, None)
1466 .expect("unfiltered session loads")
1467 .analyze_dead_code()
1468 .expect("unfiltered analysis succeeds");
1469 assert!(
1470 unfiltered
1471 .results
1472 .unused_files
1473 .iter()
1474 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
1475 );
1476
1477 std::fs::write(
1478 root.join(".fallowrc.json"),
1479 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1480 )
1481 .expect("write fallow config");
1482 let session = AnalysisSession::load(root, None).expect("filtered session loads");
1483 let hidden_path = root.join("src/hidden.ts");
1484 assert!(session.files().iter().any(|file| file.path == hidden_path));
1485
1486 let filtered = session
1487 .analyze_dead_code_with_artifacts(false, true)
1488 .expect("filtered analysis succeeds");
1489 assert!(
1490 filtered
1491 .results
1492 .unused_files
1493 .iter()
1494 .all(|finding| finding.file.path != hidden_path)
1495 );
1496 assert!(
1497 filtered
1498 .graph
1499 .as_ref()
1500 .is_some_and(|graph| graph.module_count() == session.files().len())
1501 );
1502 }
1503
1504 #[test]
1505 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
1506 use fallow_types::output_dead_code::UnusedFileFinding;
1507 use fallow_types::results::UnusedFile;
1508
1509 let project = tempfile::tempdir().expect("project");
1510 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1511 r#"{"ignoreFindings":["**/*.ts"]}"#,
1512 )
1513 .expect("config parses")
1514 .resolve(
1515 project.path().to_path_buf(),
1516 fallow_config::OutputFormat::Human,
1517 1,
1518 true,
1519 true,
1520 None,
1521 );
1522 let outside = project
1523 .path()
1524 .parent()
1525 .expect("project has parent")
1526 .join("outside.ts");
1527 let mut results = AnalysisResults {
1528 unused_files: vec![
1529 UnusedFileFinding::with_actions(UnusedFile {
1530 path: PathBuf::from(r"src\hidden.ts"),
1531 }),
1532 UnusedFileFinding::with_actions(UnusedFile {
1533 path: outside.clone(),
1534 }),
1535 ],
1536 ..AnalysisResults::default()
1537 };
1538
1539 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
1540
1541 assert_eq!(results.unused_files.len(), 1);
1542 assert_eq!(results.unused_files[0].file.path, outside);
1543 }
1544
1545 #[test]
1546 fn warm_parse_cache_reuses_module_storage() {
1547 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1548 let first = session.parse_modules(true, None);
1549 let second = session.parse_modules(false, None);
1550
1551 assert!(
1552 Arc::ptr_eq(&first.modules, &second.modules),
1553 "warm session queries must share parsed module storage"
1554 );
1555 }
1556
1557 #[test]
1558 fn warm_styling_cache_reuses_artifact_allocation() {
1559 let project = tempfile::tempdir().expect("project");
1560 let root = project.path();
1561 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
1562 .expect("write stylesheet");
1563 let session = AnalysisSession::load_default(root);
1564
1565 let first = session.styling_analysis_artifacts();
1566 let second = session.styling_analysis_artifacts();
1567
1568 assert!(
1569 Arc::ptr_eq(&first, &second),
1570 "warm styling queries must share the cached artifact allocation"
1571 );
1572 }
1573
1574 #[test]
1575 fn shared_parsed_modules_reuse_public_session_storage() {
1576 let (_project, session) = session_with_source("export const value = 1;\n");
1577 let first = session.shared_parsed_modules(true);
1578 let second = session.shared_parsed_modules(false);
1579
1580 assert!(Arc::ptr_eq(&first, &second));
1581 }
1582
1583 #[test]
1584 fn parsed_parts_keep_owned_module_compatibility() {
1585 let (_project, session) = session_with_source("export const value = 1;\n");
1586 let parts: ParsedAnalysisSessionParts = session.parsed_parts(false);
1587
1588 let _: Vec<ModuleInfo> = parts.modules;
1589 }
1590
1591 #[test]
1592 fn shared_parsed_parts_reuse_public_session_storage() {
1593 let (_project, session) = session_with_source("export const value = 1;\n");
1594 let cached = session.shared_parsed_modules(true);
1595 let parts = session.shared_parsed_parts(false);
1596
1597 assert!(Arc::ptr_eq(&cached, &parts.modules));
1598 }
1599
1600 #[test]
1601 fn warm_complexity_artifacts_reuse_cached_module_storage() {
1602 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1603 let cached = session.parse_modules(true, None);
1604 let artifacts = session
1605 .analyze_dead_code_with_reuse_artifacts(true, true, false)
1606 .expect("analysis succeeds");
1607 let retained = artifacts.modules.expect("complexity modules retained");
1608
1609 assert!(
1610 Arc::ptr_eq(&cached.modules, &retained),
1611 "warm complexity artifacts must share parsed module storage"
1612 );
1613 }
1614
1615 #[test]
1616 fn shared_and_owned_artifacts_preserve_output_bytes() {
1617 let (_project, session) = session_with_source(
1618 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1619 );
1620 let owned = session
1621 .analyze_dead_code_with_artifacts(true, true)
1622 .expect("owned analysis succeeds");
1623 let shared = session
1624 .analyze_dead_code_with_shared_artifacts(true, true)
1625 .expect("shared analysis succeeds");
1626
1627 assert_eq!(
1628 serde_json::to_vec(&owned.results).expect("serialize owned results"),
1629 serde_json::to_vec(&shared.results).expect("serialize shared results")
1630 );
1631 assert_eq!(owned.file_hashes, shared.file_hashes);
1632 assert_eq!(
1633 owned
1634 .modules
1635 .as_deref()
1636 .unwrap_or_default()
1637 .iter()
1638 .map(|module| module.content_hash)
1639 .collect::<Vec<_>>(),
1640 shared
1641 .modules
1642 .as_deref()
1643 .unwrap_or_default()
1644 .iter()
1645 .map(|module| module.content_hash)
1646 .collect::<Vec<_>>()
1647 );
1648 }
1649
1650 #[test]
1651 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1652 let project = tempfile::tempdir().expect("project");
1653 let root = project.path();
1654 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1655 std::fs::write(
1656 root.join("package.json"),
1657 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1658 )
1659 .expect("write package manifest");
1660 std::fs::write(
1661 root.join("app/routes/home.tsx"),
1662 r#"
1663import { useLoaderData } from "react-router";
1664export function loader() { return { opaque: "value" }; }
1665export default function Home() {
1666 const data = useLoaderData<typeof loader>();
1667 const copy = { ...data };
1668 return JSON.stringify(copy);
1669}
1670"#,
1671 )
1672 .expect("write route module");
1673
1674 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1675 let cold_parse = cold_session.parsed_parts(false);
1676 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1677 let cold = cold_session
1678 .analyze_dead_code()
1679 .expect("cold analysis succeeds");
1680
1681 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1682 let warm_parse = warm_session.parsed_parts(false);
1683 assert!(
1684 warm_parse.cache_hits > 0,
1685 "second session must use disk cache"
1686 );
1687 let warm = warm_session
1688 .analyze_dead_code()
1689 .expect("warm analysis succeeds");
1690
1691 assert!(
1692 cold.results.unused_load_data_keys.is_empty(),
1693 "cold analysis must abstain for an opaque route-loader use"
1694 );
1695 assert_eq!(
1696 serde_json::to_vec(&cold.results).expect("serialize cold results"),
1697 serde_json::to_vec(&warm.results).expect("serialize warm results"),
1698 "warm route-loader analysis must match cold analysis"
1699 );
1700 }
1701
1702 #[test]
1703 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
1704 let project = tempfile::tempdir().expect("project");
1705 let root = project.path();
1706 std::fs::create_dir(root.join("src")).expect("create source directory");
1707 std::fs::write(
1708 root.join("package.json"),
1709 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
1710 )
1711 .expect("write package manifest");
1712 std::fs::write(
1713 root.join("src/dependency.ts"),
1714 "export function dependency() { return 'real'; }\n",
1715 )
1716 .expect("write dependency");
1717 std::fs::write(
1718 root.join("src/wrapper.ts"),
1719 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
1720 )
1721 .expect("write wrapper");
1722 std::fs::write(
1723 root.join("src/index.ts"),
1724 "export { wrapper } from './wrapper';\n",
1725 )
1726 .expect("write entry point");
1727 std::fs::write(
1728 root.join("src/wrapper.test.ts"),
1729 r#"
1730import { vi } from "vitest";
1731vi.mock("./dependency", () => ({ dependency: () => "mock" }));
1732import { wrapper } from "./wrapper";
1733wrapper();
1734"#,
1735 )
1736 .expect("write test");
1737
1738 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1739 let dependency_id = cold_session
1740 .files()
1741 .iter()
1742 .find(|file| file.path == root.join("src/dependency.ts"))
1743 .expect("dependency discovered")
1744 .id;
1745 let cold = cold_session
1746 .analyze_dead_code_with_artifacts(false, true)
1747 .expect("cold analysis succeeds");
1748 let cold_exports = crate::module_graph::module_value_exports(
1749 cold.graph.as_ref().expect("cold graph retained"),
1750 );
1751 assert!(
1752 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
1753 "cold analysis must persist the graph cache"
1754 );
1755
1756 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1757 let warm = warm_session
1758 .analyze_dead_code_with_artifacts(false, true)
1759 .expect("warm analysis succeeds");
1760 let warm_exports = crate::module_graph::module_value_exports(
1761 warm.graph.as_ref().expect("warm graph retained"),
1762 );
1763
1764 let dependency = cold_exports
1765 .iter()
1766 .find(|export| export.file_id == dependency_id && export.name == "dependency")
1767 .expect("dependency export retained");
1768 assert!(!dependency.test_referenced);
1769 assert_eq!(warm_exports, cold_exports);
1770 }
1771
1772 #[test]
1773 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1774 let project = tempfile::tempdir().expect("project");
1775 let root = project.path();
1776 std::fs::create_dir(root.join("src")).expect("create source directory");
1777 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1778 .expect("write package manifest");
1779 for name in ["a.ts", "b.ts", "c.ts"] {
1780 std::fs::write(
1781 root.join("src").join(name),
1782 format!("export const {} = 1;\n", name.replace('.', "_")),
1783 )
1784 .expect("write source");
1785 }
1786 let session = AnalysisSession::load(root, None).expect("session loads");
1787 let removed_path = root.join("src/b.ts");
1788 let removed_id = session
1789 .files()
1790 .iter()
1791 .find(|file| file.path == removed_path)
1792 .expect("removed source discovered")
1793 .id;
1794 std::fs::remove_file(&removed_path).expect("remove source after discovery");
1795
1796 let parts = session.parsed_parts(false);
1797
1798 assert!(
1799 parts
1800 .modules
1801 .iter()
1802 .all(|module| module.file_id != removed_id),
1803 "unreadable file must not receive a placeholder module"
1804 );
1805 let diagnostic = parts
1806 .workspace_diagnostics
1807 .iter()
1808 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1809 .expect("parsed session parts carry source read failure");
1810 assert_eq!(diagnostic.path, removed_path);
1811 assert!(
1812 session
1813 .current_workspace_diagnostics()
1814 .iter()
1815 .any(|diagnostic| {
1816 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1817 }),
1818 "session output carries parse-time source diagnostics"
1819 );
1820 }
1821
1822 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
1823 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
1824 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
1825
1826 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
1827 diagnostics
1828 .iter()
1829 .any(|diagnostic| diagnostic.kind.id() == id)
1830 }
1831
1832 fn write_single_source_project(root: &Path, manifest: &str) {
1833 std::fs::create_dir(root.join("src")).expect("create source directory");
1834 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
1835 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
1836 .expect("write source");
1837 }
1838
1839 #[test]
1845 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
1846 let project = tempfile::tempdir().expect("project");
1847 let root = project.path();
1848 write_single_source_project(
1849 root,
1850 r#"{"name":"issue-2366-engine-session","private":true}"#,
1851 );
1852 std::fs::write(
1853 root.join("pnpm-workspace.yaml"),
1854 MALFORMED_PNPM_WORKSPACE_YAML,
1855 )
1856 .expect("write malformed workspace yaml");
1857
1858 let broken = AnalysisSession::load(root, None).expect("session loads");
1859 broken
1860 .analyze_dead_code()
1861 .expect("analysis on the malformed yaml succeeds");
1862 assert!(
1863 has_diagnostic_kind(
1864 &broken.current_workspace_diagnostics(),
1865 "malformed-pnpm-workspace-yaml"
1866 ),
1867 "the first session surfaces the malformed yaml: {:?}",
1868 broken.current_workspace_diagnostics()
1869 );
1870
1871 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
1872 .expect("fix workspace yaml");
1873
1874 let fixed = AnalysisSession::load(root, None).expect("session loads");
1875 fixed
1876 .analyze_dead_code()
1877 .expect("analysis on the fixed yaml succeeds");
1878 let current = fixed.current_workspace_diagnostics();
1879 assert!(
1880 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
1881 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
1882 );
1883 }
1884
1885 #[test]
1892 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
1893 let project = tempfile::tempdir().expect("project");
1894 let root = project.path();
1895 write_single_source_project(
1896 root,
1897 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
1898 );
1899 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
1900 .expect("write bun.lockb placeholder");
1901 let config = fallow_config::FallowConfig::default().resolve(
1902 root.to_path_buf(),
1903 fallow_config::OutputFormat::Json,
1904 1,
1905 true,
1906 true,
1907 None,
1908 );
1909 let reload_config = || {
1910 let (_, diagnostics) =
1911 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
1912 .expect("workspace discovery succeeds");
1913 fallow_config::stash_workspace_diagnostics(root, diagnostics);
1914 };
1915
1916 reload_config();
1917 let first =
1918 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
1919 first
1920 .analyze_dead_code()
1921 .expect("analysis with bun.lockb only succeeds");
1922 assert!(
1923 has_diagnostic_kind(
1924 &first.current_workspace_diagnostics(),
1925 "bun-lockb-override-resolution-skipped"
1926 ),
1927 "the first run surfaces the bun.lockb skip: {:?}",
1928 first.current_workspace_diagnostics()
1929 );
1930
1931 std::fs::write(
1932 root.join("bun.lock"),
1933 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
1934 )
1935 .expect("write text bun.lock");
1936
1937 reload_config();
1938 let rerun =
1939 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
1940 rerun
1941 .analyze_dead_code()
1942 .expect("analysis with the text bun.lock succeeds");
1943 let current = rerun.current_workspace_diagnostics();
1944 assert!(
1945 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
1946 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
1947 );
1948 }
1949
1950 #[test]
1963 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
1964 let project = tempfile::tempdir().expect("project");
1965 let root = project.path();
1966 write_single_source_project(
1967 root,
1968 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
1969 );
1970 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
1971 .expect("write oversized source");
1972 let mut config = fallow_config::FallowConfig::default().resolve(
1973 root.to_path_buf(),
1974 fallow_config::OutputFormat::Json,
1975 1,
1976 true,
1977 true,
1978 None,
1979 );
1980 config.max_file_size_bytes = Some(1024);
1981
1982 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
1983
1984 fallow_config::append_workspace_diagnostics(
1989 root,
1990 vec![WorkspaceDiagnostic::new(
1991 root,
1992 root.join("src/other-walk-only.ts"),
1993 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
1994 size_bytes: 4096,
1995 },
1996 )],
1997 );
1998
1999 let current = session.current_workspace_diagnostics();
2000 let skipped: Vec<&Path> = current
2001 .iter()
2002 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
2003 .map(|diagnostic| diagnostic.path.as_path())
2004 .collect();
2005 assert_eq!(
2006 skipped.len(),
2007 1,
2008 "the session reports its own walk's skips only: {skipped:?}"
2009 );
2010 assert!(
2011 skipped[0].ends_with("src/huge.ts"),
2012 "the surviving skip is this walk's own: {skipped:?}"
2013 );
2014 }
2015
2016 #[test]
2024 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
2025 let project = tempfile::tempdir().expect("project");
2026 let root = project.path();
2027 write_single_source_project(
2028 root,
2029 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2030 );
2031 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2032 .expect("write bun.lockb placeholder");
2033 let config = fallow_config::FallowConfig::default().resolve(
2034 root.to_path_buf(),
2035 fallow_config::OutputFormat::Json,
2036 1,
2037 true,
2038 true,
2039 None,
2040 );
2041 let reload_config = || {
2042 let (_, diagnostics) =
2043 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2044 .expect("workspace discovery succeeds");
2045 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2046 };
2047
2048 reload_config();
2049 let analyzing =
2050 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2051 analyzing
2052 .analyze_dead_code()
2053 .expect("analysis with bun.lockb only succeeds");
2054
2055 reload_config();
2058
2059 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2060 let current = later.current_workspace_diagnostics();
2061 assert!(
2062 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2063 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
2064 {current:?}"
2065 );
2066 }
2067}