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