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, SourceParseDegradation, SourceReadFailure};
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
18pub use crate::session_reuse::SessionParseCounts;
19use crate::session_reuse::{
20 MAX_INCREMENTAL_REPARSE_FILES, ParseCountCells, changed_file_indices, merge_reparsed_modules,
21};
22use crate::{
23 EngineResult, core_backend, duplicates,
24 project_analysis::{
25 ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
26 },
27 project_config::{ProjectConfig, config_for_project, default_project_config},
28 results::{
29 DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
30 SharedDeadCodeAnalysisArtifacts,
31 },
32 warm_parse::{WarmParse, WarmParseKey, WarmParseStore},
33};
34
35#[derive(Debug)]
41pub struct AnalysisSession {
42 config: ResolvedConfig,
43 config_path: Option<PathBuf>,
44 config_inputs: fallow_config::ConfigInputs,
45 config_inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
46 discovery: crate::discover::AnalysisDiscovery,
47 workspaces: Vec<WorkspaceInfo>,
48 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
49 parsed_cache: Mutex<Option<ParsedModuleCache>>,
50 styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
51 cancellation: Option<Arc<AtomicBool>>,
52 warm_parse: Option<Arc<WarmParseStore>>,
53 preloaded_workspaces: bool,
56 parse_counts: ParseCountCells,
57 disk_cache_stale: AtomicBool,
60}
61
62#[derive(Debug)]
63struct ParsedModuleCache {
64 need_complexity: bool,
65 fingerprints: Vec<SourceFingerprint>,
66 modules: Arc<[ModuleInfo]>,
67 read_failures: Vec<SourceReadFailure>,
70 parse_degradations: Vec<SourceParseDegradation>,
72}
73
74#[derive(Debug)]
76pub struct AnalysisSessionParts {
77 pub config: ResolvedConfig,
79 pub config_path: Option<PathBuf>,
81 pub files: Vec<DiscoveredFile>,
83 pub workspaces: Vec<WorkspaceInfo>,
85 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
87}
88
89#[derive(Debug)]
91pub struct ParsedAnalysisSessionParts {
92 pub config: ResolvedConfig,
94 pub config_path: Option<PathBuf>,
96 pub files: Vec<DiscoveredFile>,
98 pub modules: Vec<ModuleInfo>,
100 pub workspaces: Vec<WorkspaceInfo>,
102 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
104 pub parse_ms: f64,
106 pub cache_update_ms: f64,
108 pub cache_hits: usize,
110 pub cache_misses: usize,
112 pub parse_cpu_ms: f64,
114}
115
116#[derive(Debug)]
117pub(crate) struct SharedParsedAnalysisSessionParts {
118 pub(crate) config: ResolvedConfig,
119 pub(crate) files: Vec<DiscoveredFile>,
120 pub(crate) modules: Arc<[ModuleInfo]>,
121 pub workspaces: Vec<WorkspaceInfo>,
122 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
123 pub parse_ms: f64,
124 pub parse_cpu_ms: f64,
125}
126
127#[derive(Debug)]
129pub struct AnalysisSessionArtifacts {
130 pub analysis: DeadCodeAnalysisArtifacts,
132 pub changed_files: Option<FxHashSet<PathBuf>>,
134 pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
136}
137
138impl AnalysisSession {
139 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
145 let project_config = config_for_project(root, config_path)?;
146 Ok(Self::from_config(project_config))
147 }
148
149 pub fn load_with_config(
156 root: &Path,
157 config_path: Option<&Path>,
158 configure: impl FnOnce(&mut ResolvedConfig),
159 ) -> EngineResult<Self> {
160 Self::load_with_config_options(
161 root,
162 config_path,
163 fallow_config::ConfigLoadOptions::default(),
164 configure,
165 )
166 }
167
168 pub fn load_with_config_options(
175 root: &Path,
176 config_path: Option<&Path>,
177 load_options: fallow_config::ConfigLoadOptions,
178 configure: impl FnOnce(&mut ResolvedConfig),
179 ) -> EngineResult<Self> {
180 let mut project_config = crate::project_config::config_for_project_with_load_options(
181 root,
182 config_path,
183 load_options,
184 )?;
185 configure(&mut project_config.config);
186 project_config.workspaces.clear();
187 project_config.workspace_diagnostics.clear();
188 project_config.workspace_discovery_ms = None;
189 Ok(Self::from_config(project_config))
190 }
191
192 #[must_use]
197 pub fn load_default(root: &Path) -> Self {
198 Self::load_default_with_config(root, |_| {})
199 }
200
201 #[must_use]
204 pub fn load_default_with_config(
205 root: &Path,
206 configure: impl FnOnce(&mut ResolvedConfig),
207 ) -> Self {
208 let mut project_config = default_project_config(root);
209 configure(&mut project_config.config);
210 Self::from_config(project_config)
211 }
212
213 #[must_use]
215 pub fn from_config(project_config: ProjectConfig) -> Self {
216 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
217 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
218 {
219 crate::discover::prepare_analysis_discovery_with_workspaces(
220 &project_config.config,
221 &project_config.workspaces,
222 workspace_discovery_ms,
223 )
224 } else {
225 crate::discover::prepare_analysis_discovery(&project_config.config)
226 };
227 let workspaces = if uses_preloaded_workspaces {
228 project_config.workspaces
229 } else {
230 discovery.workspaces().to_vec()
231 };
232 let workspace_diagnostics = merge_workspace_diagnostics(
244 merge_workspace_diagnostics(
245 project_config.workspace_diagnostics,
246 fallow_config::workspace_diagnostics_for(&project_config.config.root)
247 .into_iter()
248 .filter(|diagnostic| {
249 !diagnostic.kind.is_analysis_stage()
250 && !diagnostic.kind.is_source_discovery()
251 })
252 .collect(),
253 ),
254 discovery.source_diagnostics().to_vec(),
255 );
256 Self {
257 config: project_config.config,
258 config_path: project_config.path,
259 config_inputs: project_config.inputs,
260 config_inputs_before_resolve: project_config.inputs_before_resolve,
261 discovery,
262 workspaces,
263 workspace_diagnostics,
264 parsed_cache: Mutex::new(None),
265 styling_cache: Mutex::new(None),
266 cancellation: None,
267 warm_parse: crate::warm_parse::installed(),
268 preloaded_workspaces: uses_preloaded_workspaces,
269 parse_counts: ParseCountCells::default(),
270 disk_cache_stale: AtomicBool::new(false),
271 }
272 }
273
274 #[must_use]
289 pub fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
290 self.cancellation = Some(cancellation);
291 self
292 }
293
294 #[must_use]
298 pub fn with_warm_parse(mut self, store: Option<Arc<WarmParseStore>>) -> Self {
299 self.warm_parse = store;
300 self
301 }
302
303 pub fn set_cancellation(&mut self, cancellation: Arc<AtomicBool>) {
308 self.cancellation = Some(cancellation);
309 }
310
311 #[must_use]
313 pub fn parse_counts(&self) -> SessionParseCounts {
314 self.parse_counts.snapshot()
315 }
316
317 pub fn refresh_discovery(&mut self) -> bool {
332 let discovery = if self.preloaded_workspaces {
333 crate::discover::prepare_analysis_discovery_with_workspaces(
334 &self.config,
335 &self.workspaces,
336 0.0,
337 )
338 } else {
339 crate::discover::prepare_analysis_discovery(&self.config)
340 };
341 let same_files = discovery.files().len() == self.files().len()
342 && discovery
343 .files()
344 .iter()
345 .zip(self.files())
346 .all(|(fresh, known)| fresh.path == known.path);
347 if !same_files || !self.cached_fingerprints_are_trustworthy() {
348 self.flush_parse_cache();
349 *self
350 .parsed_cache
351 .get_mut()
352 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
353 self.clear_styling_cache();
354 }
355 if !self.preloaded_workspaces {
356 self.workspaces = discovery.workspaces().to_vec();
357 }
358 self.discovery = discovery;
359 !same_files
360 }
361
362 fn cached_fingerprints_are_trustworthy(&mut self) -> bool {
365 self.parsed_cache
366 .get_mut()
367 .unwrap_or_else(std::sync::PoisonError::into_inner)
368 .as_ref()
369 .is_none_or(|cache| {
370 cache
371 .fingerprints
372 .iter()
373 .all(|fingerprint| fingerprint.is_trustworthy_without_content())
374 })
375 }
376
377 pub fn flush_parse_cache(&self) {
391 if self.config.no_cache || !self.disk_cache_stale.swap(false, Ordering::SeqCst) {
392 return;
393 }
394 let Ok(cache) = self.parsed_cache.lock() else {
395 return;
396 };
397 let Some(cache) = cache.as_ref() else {
398 return;
399 };
400 let cache_max_size_bytes =
401 crate::project_config::resolve_cache_max_size_bytes(&self.config);
402 let mut store = fallow_extract::cache::CacheStore::load(
403 &self.config.cache_dir,
404 &self.config.root,
405 self.config.cache_config_hash,
406 cache_max_size_bytes,
407 )
408 .ok();
409 let files = self.files();
410 write_parse_cache(
411 &self.config,
412 &mut store,
413 &ParseCacheWrite {
414 modules: &cache.modules,
415 files,
416 need_complexity: cache.need_complexity,
417 fingerprint_of: &|file: &DiscoveredFile| {
418 cache
419 .fingerprints
420 .get(file.id.0 as usize)
421 .copied()
422 .unwrap_or_else(|| SourceFingerprint::new(0, 0))
423 },
424 },
425 );
426 }
427
428 pub fn prewarm_parsed_modules(&self, need_complexity: bool) -> EngineResult<()> {
437 self.shared_parsed_modules_cancellable(need_complexity, "prewarm")
438 .map(drop)
439 }
440
441 fn clear_styling_cache(&self) {
442 if let Ok(mut cache) = self.styling_cache.lock() {
443 *cache = None;
444 }
445 }
446
447 #[must_use]
449 pub fn is_cancelled(&self) -> bool {
450 self.cancellation
451 .as_ref()
452 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
453 }
454
455 fn ensure_not_cancelled(&self, stage: &str) -> EngineResult<()> {
456 if self.is_cancelled() {
457 return Err(crate::EngineError::cancelled(stage));
458 }
459 Ok(())
460 }
461
462 pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
470 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
471 crate::project_config::collect_workspace_metadata(&config)?;
472 Ok(Self::from_config(ProjectConfig {
473 inputs: fallow_config::ConfigInputs::default(),
474 inputs_before_resolve: fallow_config::ConfigInputsSnapshot::default(),
475 config,
476 path: None,
477 workspaces,
478 workspace_diagnostics,
479 workspace_discovery_ms: Some(workspace_discovery_ms),
480 }))
481 }
482
483 #[must_use]
485 pub fn root(&self) -> &Path {
486 &self.config.root
487 }
488
489 #[must_use]
491 pub fn config(&self) -> &ResolvedConfig {
492 &self.config
493 }
494
495 #[must_use]
497 pub fn config_path(&self) -> Option<&Path> {
498 self.config_path.as_deref()
499 }
500
501 #[must_use]
505 pub const fn config_inputs(&self) -> &fallow_config::ConfigInputs {
506 &self.config_inputs
507 }
508
509 #[must_use]
513 pub const fn config_inputs_before_resolve(&self) -> &fallow_config::ConfigInputsSnapshot {
514 &self.config_inputs_before_resolve
515 }
516
517 #[must_use]
522 pub fn retained_bytes_estimate(&self) -> u64 {
523 self.parsed_cache
524 .lock()
525 .ok()
526 .and_then(|cache| {
527 cache
528 .as_ref()
529 .map(|cache| crate::warm_parse::estimated_retained_bytes(&cache.fingerprints))
530 })
531 .unwrap_or(0)
532 }
533
534 #[must_use]
536 pub fn files(&self) -> &[DiscoveredFile] {
537 self.discovery.files()
538 }
539
540 #[must_use]
542 pub fn workspaces(&self) -> &[WorkspaceInfo] {
543 &self.workspaces
544 }
545
546 #[must_use]
548 fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
549 self.discovery
550 .files()
551 .iter()
552 .map(|file| {
553 let fingerprint = std::fs::metadata(&file.path).map_or_else(
554 |_| SourceFingerprint::new(0, file.size_bytes),
555 |metadata| SourceFingerprint::from_metadata(&metadata),
556 );
557 (file.path.clone(), fingerprint)
558 })
559 .collect()
560 }
561
562 pub(crate) fn changed_files_since(
569 &self,
570 git_ref: &str,
571 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
572 crate::changed_files::changed_files(&self.config.root, git_ref)
573 }
574
575 pub(crate) const fn discovery(&self) -> &crate::discover::AnalysisDiscovery {
577 &self.discovery
578 }
579
580 #[must_use]
582 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
583 &self.workspace_diagnostics
584 }
585
586 #[must_use]
599 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
600 merge_workspace_diagnostics(
601 self.workspace_diagnostics.clone(),
602 fallow_config::registry_diagnostics_to_fold(&self.config.root),
603 )
604 }
605
606 pub(crate) fn styling_analysis_artifacts(
607 &self,
608 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
609 if let Ok(cache) = self.styling_cache.lock()
610 && let Some(artifacts) = cache.as_ref()
611 {
612 return Arc::clone(artifacts);
613 }
614
615 let modules = self.shared_parsed_modules(false);
616 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
617 self.files(),
618 &modules,
619 self.config(),
620 ));
621 if let Ok(mut cache) = self.styling_cache.lock() {
622 *cache = Some(Arc::clone(&artifacts));
623 }
624 artifacts
625 }
626
627 #[must_use]
629 pub fn into_parts(self) -> AnalysisSessionParts {
630 let workspace_diagnostics = self.current_workspace_diagnostics();
631 AnalysisSessionParts {
632 config: self.config,
633 config_path: self.config_path,
634 files: self.discovery.into_files(),
635 workspaces: self.workspaces,
636 workspace_diagnostics,
637 }
638 }
639
640 #[must_use]
642 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
643 let AnalysisSessionParts {
644 config,
645 config_path,
646 files,
647 workspaces,
648 workspace_diagnostics,
649 } = self.into_parts();
650 let ParsedModules {
651 modules,
652 metrics,
653 source_diagnostics,
654 ..
655 } = parse_files_with_config(&config, &files, need_complexity, None);
656 ParsedAnalysisSessionParts {
657 config,
658 config_path,
659 files,
660 modules,
661 workspaces,
662 workspace_diagnostics: merge_workspace_diagnostics(
663 workspace_diagnostics,
664 source_diagnostics,
665 ),
666 parse_ms: metrics.parse_ms,
667 cache_update_ms: metrics.cache_ms,
668 cache_hits: metrics.cache_hits,
669 cache_misses: metrics.cache_misses,
670 parse_cpu_ms: metrics.parse_cpu_ms,
671 }
672 }
673
674 #[must_use]
676 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
677 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
678 self.parsed_parts_from_modules(modules.to_vec(), metrics)
679 }
680
681 #[must_use]
683 pub(crate) fn shared_parsed_parts(
684 &self,
685 need_complexity: bool,
686 ) -> SharedParsedAnalysisSessionParts {
687 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
688 SharedParsedAnalysisSessionParts {
689 config: self.config.clone(),
690 files: self.discovery.files().to_vec(),
691 modules,
692 workspaces: self.workspaces.clone(),
693 workspace_diagnostics: self.current_workspace_diagnostics(),
694 parse_ms: metrics.parse_ms,
695 parse_cpu_ms: metrics.parse_cpu_ms,
696 }
697 }
698
699 #[doc(hidden)]
705 #[must_use]
706 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
707 self.parse_modules(need_complexity, None).modules
708 }
709
710 pub(crate) fn shared_parsed_modules_cancellable(
721 &self,
722 need_complexity: bool,
723 stage: &str,
724 ) -> EngineResult<Arc<[ModuleInfo]>> {
725 self.ensure_not_cancelled("parsing")?;
726 let modules = self
727 .parse_modules(need_complexity, self.cancellation.as_deref())
728 .modules;
729 self.ensure_not_cancelled(stage)?;
730 Ok(modules)
731 }
732
733 #[must_use]
736 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
737 let fingerprints = self
738 .warm_parse
739 .as_ref()
740 .and_then(|_| source_fingerprints_for_files(self.files()));
741 if let Some(warm) = self.warm_parse(need_complexity, fingerprints.as_deref(), None) {
742 return self.parsed_parts_from_modules(warm.modules.to_vec(), warm.metrics);
743 }
744 let ParsedModules {
745 modules, metrics, ..
746 } = parse_files_with_config(&self.config, self.files(), need_complexity, None);
747 self.parsed_parts_from_modules(modules, metrics)
748 }
749
750 fn parsed_parts_from_modules(
751 &self,
752 modules: Vec<ModuleInfo>,
753 metrics: core_backend::ParseMetrics,
754 ) -> ParsedAnalysisSessionParts {
755 ParsedAnalysisSessionParts {
756 config: self.config.clone(),
757 config_path: self.config_path.clone(),
758 files: self.discovery.files().to_vec(),
759 modules,
760 workspaces: self.workspaces.clone(),
761 workspace_diagnostics: self.current_workspace_diagnostics(),
762 parse_ms: metrics.parse_ms,
763 cache_update_ms: metrics.cache_ms,
764 cache_hits: metrics.cache_hits,
765 cache_misses: metrics.cache_misses,
766 parse_cpu_ms: metrics.parse_cpu_ms,
767 }
768 }
769
770 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
776 self.analyze_dead_code_with_artifacts(false, false)
777 .map(|output| DeadCodeAnalysis {
778 results: output.results,
779 })
780 }
781
782 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
788 self.analyze_dead_code_with_artifacts(true, false)
789 .map(|output| DeadCodeAnalysisOutput {
790 results: output.results,
791 modules: output.modules,
792 files: output.files,
793 })
794 }
795
796 pub fn analyze_dead_code_with_artifacts(
802 &self,
803 need_complexity: bool,
804 retain_graph: bool,
805 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
806 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
807 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
808 }
809
810 #[doc(hidden)]
820 pub fn analyze_dead_code_with_shared_artifacts(
821 &self,
822 need_complexity: bool,
823 retain_graph: bool,
824 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
825 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
826 }
827
828 pub fn analyze_dead_code_retaining_files(
835 &self,
836 need_complexity: bool,
837 retain_graph: bool,
838 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
839 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
840 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
841 }
842
843 pub fn analyze_dead_code_with_parsed_modules(
852 &self,
853 modules: &[ModuleInfo],
854 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
855 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
856 }
857
858 #[doc(hidden)]
864 pub(crate) fn analyze_dead_code_with_shared_modules(
865 &self,
866 modules: Arc<[ModuleInfo]>,
867 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
868 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
869 config: &self.config,
870 discovery: &self.discovery,
871 modules,
872 metrics: reused_parse_metrics(),
873 collect_usages: true,
874 retain_graph: true,
875 retain_modules: false,
876 retain_files: false,
877 cancellation: self.cancellation.as_deref(),
878 })
879 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
880 }
881
882 fn analyze_dead_code_with_reuse_artifacts(
883 &self,
884 need_complexity: bool,
885 retain_graph: bool,
886 retain_files: bool,
887 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
888 self.ensure_not_cancelled("parsing")?;
889 let SharedParsedModules { modules, metrics } =
890 self.parse_modules(need_complexity, self.cancellation.as_deref());
891 self.ensure_not_cancelled("the dead-code pipeline")?;
895 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
896 config: &self.config,
897 discovery: &self.discovery,
898 modules,
899 metrics,
900 collect_usages: true,
901 retain_graph,
902 retain_modules: need_complexity,
903 retain_files,
904 cancellation: self.cancellation.as_deref(),
905 })
906 }
907
908 pub fn analyze_dead_code_with_session_artifacts(
919 &self,
920 need_complexity: bool,
921 retain_graph: bool,
922 changed_files: Option<FxHashSet<PathBuf>>,
923 ) -> EngineResult<AnalysisSessionArtifacts> {
924 Ok(AnalysisSessionArtifacts {
925 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
926 changed_files,
927 source_fingerprints: self.source_fingerprints(),
928 })
929 }
930
931 pub fn analyze_project_with(
940 &self,
941 duplicates_config: &DuplicatesConfig,
942 retain_complexity_artifacts: bool,
943 ) -> EngineResult<ProjectAnalysisOutput> {
944 self.analyze_project_with_artifacts(
945 duplicates_config,
946 ProjectAnalysisArtifactOptions {
947 retain_complexity_artifacts,
948 ..ProjectAnalysisArtifactOptions::default()
949 },
950 )
951 .map(ProjectAnalysisArtifacts::into_output)
952 }
953
954 pub fn analyze_project_with_artifacts(
964 &self,
965 duplicates_config: &DuplicatesConfig,
966 options: ProjectAnalysisArtifactOptions,
967 ) -> EngineResult<ProjectAnalysisArtifacts> {
968 self.ensure_not_cancelled("duplication detection")?;
969 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
970 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
971 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
972 self.find_duplicates_touching_files_with_defaults(
973 duplicates_config,
974 &changed_files,
975 cache_dir,
976 )
977 .report
978 } else {
979 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
980 .report
981 };
982 self.ensure_not_cancelled("the dead-code half of project analysis")?;
986 let source_fingerprints = options
987 .collect_source_fingerprints
988 .then(|| self.source_fingerprints());
989 Ok(ProjectAnalysisArtifacts {
990 dead_code: self.analyze_dead_code_with_artifacts(
991 options.retain_complexity_artifacts,
992 options.retain_graph,
993 )?,
994 duplication,
995 changed_files: options.changed_files,
996 source_fingerprints,
997 })
998 }
999
1000 #[must_use]
1002 pub fn find_duplicates_with_defaults(
1003 &self,
1004 config: &DuplicatesConfig,
1005 cache_dir: Option<&Path>,
1006 ) -> DuplicationAnalysis {
1007 duplicates::find_duplicates_with_defaults(
1008 &self.config.root,
1009 self.files(),
1010 config,
1011 cache_dir,
1012 )
1013 }
1014
1015 #[must_use]
1017 pub fn find_duplicates_touching_files_with_defaults(
1018 &self,
1019 config: &DuplicatesConfig,
1020 changed_files: &[PathBuf],
1021 cache_dir: Option<&Path>,
1022 ) -> DuplicationAnalysis {
1023 duplicates::find_duplicates_touching_files_with_defaults(
1024 &self.config.root,
1025 self.files(),
1026 config,
1027 changed_files,
1028 cache_dir,
1029 )
1030 }
1031
1032 fn parse_modules(
1038 &self,
1039 need_complexity: bool,
1040 cancellation: Option<&AtomicBool>,
1041 ) -> SharedParsedModules {
1042 let fingerprints = source_fingerprints_for_files(self.files());
1043 if let Some(fingerprints) = fingerprints.as_ref() {
1044 if let Some(modules) = self.cached_modules(need_complexity, fingerprints) {
1045 self.parse_counts.record(SessionParseCounts {
1046 modules_reused: modules.len(),
1047 ..SessionParseCounts::default()
1048 });
1049 return SharedParsedModules {
1050 modules,
1051 metrics: reused_parse_metrics(),
1052 };
1053 }
1054 if let Some(parsed) =
1055 self.reparse_changed_modules(need_complexity, fingerprints, cancellation)
1056 {
1057 return parsed;
1058 }
1059 }
1060
1061 let (modules, metrics, has_complexity, modules_reused, problems) = if let Some(warm) =
1062 self.warm_parse(need_complexity, fingerprints.as_deref(), cancellation)
1063 {
1064 let reused = if warm.reused { warm.modules.len() } else { 0 };
1065 (warm.modules, warm.metrics, true, reused, warm.problems)
1066 } else {
1067 let ParsedModules {
1068 modules,
1069 metrics,
1070 read_failures,
1071 parse_degradations,
1072 ..
1073 } = parse_files_with_config(&self.config, self.files(), need_complexity, cancellation);
1074 let problems = SourceProblems {
1075 read_failures,
1076 parse_degradations,
1077 };
1078 (modules.into(), metrics, need_complexity, 0, problems)
1079 };
1080 self.parse_counts.record(SessionParseCounts {
1081 modules_parsed: metrics.cache_misses,
1082 disk_cache_hits: metrics.cache_hits,
1083 modules_reused,
1084 });
1085 if !token_is_set(cancellation)
1089 && let Some(fingerprints) = fingerprints
1090 && let Ok(mut cache) = self.parsed_cache.lock()
1091 {
1092 *cache = Some(ParsedModuleCache {
1093 need_complexity: has_complexity,
1094 fingerprints,
1095 modules: Arc::clone(&modules),
1096 read_failures: problems.read_failures,
1097 parse_degradations: problems.parse_degradations,
1098 });
1099 self.disk_cache_stale.store(false, Ordering::SeqCst);
1101 self.clear_styling_cache();
1102 }
1103 SharedParsedModules { modules, metrics }
1104 }
1105
1106 fn warm_parse(
1119 &self,
1120 need_complexity: bool,
1121 fingerprints: Option<&[SourceFingerprint]>,
1122 cancellation: Option<&AtomicBool>,
1123 ) -> Option<WarmParsedModules> {
1124 let store = self.warm_parse.as_deref()?;
1125 if self.config.no_cache {
1126 return None;
1127 }
1128 let key = WarmParseKey {
1129 root: &self.config.root,
1130 cache_config_hash: self.config.cache_config_hash,
1131 files: self.files(),
1132 fingerprints: fingerprints?,
1133 };
1134 if !key.is_reusable() {
1135 return None;
1136 }
1137 if let Some(parse) = store.get(&key, need_complexity) {
1138 record_source_diagnostics(
1143 &self.config.root,
1144 &parse.read_failures,
1145 &parse.parse_degradations,
1146 );
1147 return Some(WarmParsedModules {
1148 modules: parse.modules,
1149 metrics: reused_parse_metrics(),
1150 reused: true,
1151 problems: SourceProblems {
1152 read_failures: parse.read_failures.to_vec(),
1153 parse_degradations: parse.parse_degradations.to_vec(),
1154 },
1155 });
1156 }
1157
1158 let parsed = parse_files_with_config(&self.config, self.files(), true, cancellation);
1159 store.record_parse(parsed.metrics.cache_misses, parsed.metrics.cache_hits);
1160 let modules: Arc<[ModuleInfo]> = parsed.modules.into();
1161 if !token_is_set(cancellation) {
1162 store.put(
1163 &key,
1164 true,
1165 WarmParse {
1166 modules: Arc::clone(&modules),
1167 read_failures: parsed.read_failures.clone().into(),
1168 parse_degradations: parsed.parse_degradations.clone().into(),
1169 },
1170 );
1171 }
1172 Some(WarmParsedModules {
1173 modules,
1174 metrics: parsed.metrics,
1175 reused: false,
1176 problems: SourceProblems {
1177 read_failures: parsed.read_failures,
1178 parse_degradations: parsed.parse_degradations,
1179 },
1180 })
1181 }
1182
1183 fn reparse_changed_modules(
1191 &self,
1192 need_complexity: bool,
1193 fingerprints: &[SourceFingerprint],
1194 cancellation: Option<&AtomicBool>,
1195 ) -> Option<SharedParsedModules> {
1196 let mut guard = self.parsed_cache.lock().ok()?;
1197 let cache = guard.as_mut()?;
1198 if need_complexity && !cache.need_complexity {
1199 return None;
1200 }
1201 let changed = changed_file_indices(&cache.fingerprints, fingerprints)?;
1202 if changed.is_empty() || changed.len() > MAX_INCREMENTAL_REPARSE_FILES {
1203 return None;
1204 }
1205 let files: Vec<DiscoveredFile> = changed
1206 .iter()
1207 .filter_map(|&index| self.files().get(index).cloned())
1208 .collect();
1209 let parse_start = Instant::now();
1210 let parsed = crate::source::parse_all_files(
1211 &files,
1212 None,
1213 cache.need_complexity,
1214 cancellation,
1215 &self.config.flags.patterns(),
1216 );
1217 if token_is_set(cancellation) {
1220 return Some(SharedParsedModules {
1221 modules: Arc::clone(&cache.modules),
1222 metrics: reused_parse_metrics(),
1223 });
1224 }
1225 let mut fresh = parsed.modules;
1226 for module in &mut fresh {
1227 module.prepare_analysis_facts();
1228 }
1229 let fresh_count = fresh.len();
1230 let reparsed: Vec<_> = files.iter().map(|file| file.id).collect();
1231 merge_reparsed_modules(&mut cache.modules, &reparsed, fresh);
1232 cache.fingerprints = fingerprints.to_vec();
1233 cache
1234 .read_failures
1235 .retain(|failure| !reparsed.contains(&failure.file_id));
1236 cache.read_failures.extend(parsed.read_failures);
1237 cache
1238 .parse_degradations
1239 .retain(|degradation| !reparsed.contains(°radation.file_id));
1240 cache.parse_degradations.extend(parsed.parse_degradations);
1241 record_source_diagnostics(
1242 &self.config.root,
1243 &cache.read_failures,
1244 &cache.parse_degradations,
1245 );
1246 self.parse_counts.record(SessionParseCounts {
1247 modules_parsed: parsed.cache_misses,
1248 disk_cache_hits: 0,
1249 modules_reused: cache.modules.len().saturating_sub(fresh_count),
1250 });
1251 let modules = Arc::clone(&cache.modules);
1252 drop(guard);
1253 self.disk_cache_stale.store(true, Ordering::SeqCst);
1254 self.clear_styling_cache();
1255 Some(SharedParsedModules {
1256 modules,
1257 metrics: core_backend::ParseMetrics {
1258 parse_ms: parse_start.elapsed().as_secs_f64() * 1000.0,
1259 cache_ms: 0.0,
1260 cache_hits: 0,
1261 cache_misses: parsed.cache_misses,
1262 parse_cpu_ms: parsed.parse_cpu_ms,
1263 cache_rejection: None,
1264 files_read: parsed.files_read,
1265 source_bytes_read: parsed.source_bytes_read,
1266 parse_cache_bytes_read: 0,
1267 css_masked_bytes: parsed.css_masked_bytes,
1268 parse_cache_load_ms: 0.0,
1269 },
1270 })
1271 }
1272
1273 fn cached_modules(
1274 &self,
1275 need_complexity: bool,
1276 fingerprints: &[SourceFingerprint],
1277 ) -> Option<Arc<[ModuleInfo]>> {
1278 let Ok(cache) = self.parsed_cache.lock() else {
1279 return None;
1280 };
1281 let cache = cache.as_ref()?;
1282 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
1283 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
1284 return Some(Arc::clone(&cache.modules));
1285 }
1286 None
1287 }
1288}
1289
1290struct ParsedModules {
1291 modules: Vec<ModuleInfo>,
1292 metrics: core_backend::ParseMetrics,
1293 source_diagnostics: Vec<WorkspaceDiagnostic>,
1294 read_failures: Vec<SourceReadFailure>,
1295 parse_degradations: Vec<SourceParseDegradation>,
1296}
1297
1298struct SharedParsedModules {
1299 modules: Arc<[ModuleInfo]>,
1300 metrics: core_backend::ParseMetrics,
1301}
1302
1303struct WarmParsedModules {
1305 modules: Arc<[ModuleInfo]>,
1306 metrics: core_backend::ParseMetrics,
1307 reused: bool,
1309 problems: SourceProblems,
1310}
1311
1312struct SourceProblems {
1314 read_failures: Vec<SourceReadFailure>,
1315 parse_degradations: Vec<SourceParseDegradation>,
1316}
1317
1318fn token_is_set(cancellation: Option<&AtomicBool>) -> bool {
1319 cancellation.is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
1320}
1321
1322fn parse_files_with_config(
1323 config: &ResolvedConfig,
1324 files: &[DiscoveredFile],
1325 need_complexity: bool,
1326 cancellation: Option<&AtomicBool>,
1327) -> ParsedModules {
1328 let parse_start = Instant::now();
1329 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1330 let mut cache_rejection = None;
1331 let mut parse_cache_bytes_read = 0;
1332 let mut parse_cache_load_ms = 0.0;
1333 let mut cache = if config.no_cache {
1334 None
1335 } else {
1336 let load_start = Instant::now();
1337 let (loaded, bytes_read) = fallow_extract::cache::CacheStore::load_counting_bytes(
1338 &config.cache_dir,
1339 &config.root,
1340 config.cache_config_hash,
1341 cache_max_size_bytes,
1342 );
1343 parse_cache_bytes_read = bytes_read;
1344 parse_cache_load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
1345 match loaded {
1346 Ok(store) => Some(store),
1347 Err(rejection) => {
1348 cache_rejection = Some(rejection);
1349 None
1350 }
1351 }
1352 };
1353 let parse_result = crate::source::parse_all_files(
1354 files,
1355 cache.as_ref(),
1356 need_complexity,
1357 cancellation,
1358 &config.flags.patterns(),
1359 );
1360 let source_diagnostics = record_source_diagnostics(
1361 &config.root,
1362 &parse_result.read_failures,
1363 &parse_result.parse_degradations,
1364 );
1365 let mut modules = parse_result.modules;
1366 for module in &mut modules {
1367 module.prepare_analysis_facts();
1368 }
1369 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
1370 let cache_ms = if token_is_set(cancellation) {
1371 0.0
1372 } else {
1373 update_parse_cache_if_enabled(config, &mut cache, &modules, files, need_complexity)
1374 };
1375 let metrics = core_backend::ParseMetrics {
1376 parse_ms,
1377 cache_ms,
1378 cache_hits: parse_result.cache_hits,
1379 cache_misses: parse_result.cache_misses,
1380 parse_cpu_ms: parse_result.parse_cpu_ms,
1381 cache_rejection,
1382 files_read: parse_result.files_read,
1383 source_bytes_read: parse_result.source_bytes_read,
1384 parse_cache_bytes_read,
1385 css_masked_bytes: parse_result.css_masked_bytes,
1386 parse_cache_load_ms,
1387 };
1388 ParsedModules {
1389 modules,
1390 metrics,
1391 source_diagnostics,
1392 read_failures: parse_result.read_failures,
1393 parse_degradations: parse_result.parse_degradations,
1394 }
1395}
1396
1397fn record_source_diagnostics(
1400 root: &Path,
1401 read_failures: &[SourceReadFailure],
1402 parse_degradations: &[SourceParseDegradation],
1403) -> Vec<WorkspaceDiagnostic> {
1404 let mut diagnostics = fallow_config::record_source_read_failures(root, read_failures);
1405 diagnostics.extend(fallow_config::record_source_parse_degradations(
1406 root,
1407 parse_degradations,
1408 ));
1409 diagnostics
1410}
1411
1412fn reused_parse_metrics() -> core_backend::ParseMetrics {
1413 core_backend::ParseMetrics {
1414 parse_ms: 0.0,
1415 cache_ms: 0.0,
1416 cache_hits: 0,
1417 cache_misses: 0,
1418 parse_cpu_ms: 0.0,
1419 cache_rejection: None,
1420 files_read: 0,
1421 source_bytes_read: 0,
1422 parse_cache_bytes_read: 0,
1423 css_masked_bytes: 0,
1424 parse_cache_load_ms: 0.0,
1425 }
1426}
1427
1428fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
1429 files
1430 .iter()
1431 .map(|file| {
1432 std::fs::metadata(&file.path)
1433 .ok()
1434 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
1435 .filter(|fingerprint| fingerprint.has_known_mtime())
1436 })
1437 .collect()
1438}
1439
1440fn update_parse_cache_if_enabled(
1441 config: &ResolvedConfig,
1442 cache: &mut Option<fallow_extract::cache::CacheStore>,
1443 modules: &[ModuleInfo],
1444 files: &[DiscoveredFile],
1445 need_complexity: bool,
1446) -> f64 {
1447 let start = Instant::now();
1448 write_parse_cache(
1449 config,
1450 cache,
1451 &ParseCacheWrite {
1452 modules,
1453 files,
1454 need_complexity,
1455 fingerprint_of: &|file: &DiscoveredFile| source_fingerprint(&file.path),
1456 },
1457 );
1458 start.elapsed().as_secs_f64() * 1000.0
1459}
1460
1461struct ParseCacheWrite<'a> {
1464 modules: &'a [ModuleInfo],
1465 files: &'a [DiscoveredFile],
1466 need_complexity: bool,
1467 fingerprint_of: &'a dyn Fn(&DiscoveredFile) -> SourceFingerprint,
1468}
1469
1470fn write_parse_cache(
1471 config: &ResolvedConfig,
1472 cache: &mut Option<fallow_extract::cache::CacheStore>,
1473 write: &ParseCacheWrite<'_>,
1474) {
1475 if config.no_cache {
1476 return;
1477 }
1478
1479 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1480 let store = cache.get_or_insert_with(|| fallow_extract::cache::CacheStore::new(&config.root));
1481 if update_parse_cache(store, write)
1482 && let Err(error) = store.save(
1483 &config.cache_dir,
1484 config.cache_config_hash,
1485 cache_max_size_bytes,
1486 )
1487 {
1488 tracing::warn!("Failed to save cache: {error}");
1489 }
1490}
1491
1492fn update_parse_cache(
1497 store: &mut fallow_extract::cache::CacheStore,
1498 write: &ParseCacheWrite<'_>,
1499) -> bool {
1500 let ParseCacheWrite {
1501 modules,
1502 files,
1503 need_complexity,
1504 fingerprint_of,
1505 } = *write;
1506 let mut dirty = false;
1507 for module in modules {
1508 if let Some(file) = files.get(module.file_id.0 as usize) {
1509 let fingerprint = fingerprint_of(file);
1510 if let Some(cached) = store.get_by_path_only(&file.path)
1511 && cached.content_hash == module.content_hash
1512 {
1513 let stale_metadata = cached.source_fingerprint() != fingerprint;
1514 let adds_complexity = need_complexity && !cached.complexity_extracted;
1515 if stale_metadata || adds_complexity {
1516 let preserved_last_access = cached.last_access_secs;
1517 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
1518 .then(|| cached.complexity.clone());
1519 let mut refreshed = fallow_extract::cache::module_to_cached(
1520 module,
1521 fingerprint,
1522 need_complexity,
1523 );
1524 refreshed.last_access_secs = preserved_last_access;
1525 if let Some(complexity) = preserved_complexity {
1526 refreshed.complexity = complexity;
1527 refreshed.complexity_extracted = true;
1528 }
1529 store.insert(&file.path, refreshed);
1530 dirty = true;
1531 }
1532 continue;
1533 }
1534 store.insert(
1535 &file.path,
1536 fallow_extract::cache::module_to_cached(module, fingerprint, need_complexity),
1537 );
1538 dirty = true;
1539 }
1540 }
1541 store.retain_paths(files) || dirty
1542}
1543
1544fn source_fingerprint(path: &Path) -> SourceFingerprint {
1545 std::fs::metadata(path).map_or_else(
1546 |_| SourceFingerprint::new(0, 0),
1547 |metadata| SourceFingerprint::from_metadata(&metadata),
1548 )
1549}
1550
1551struct EngineDeadCodePipelineInput<'a> {
1552 config: &'a ResolvedConfig,
1553 discovery: &'a crate::discover::AnalysisDiscovery,
1554 modules: Arc<[ModuleInfo]>,
1555 metrics: core_backend::ParseMetrics,
1556 collect_usages: bool,
1557 retain_graph: bool,
1558 retain_modules: bool,
1559 retain_files: bool,
1560 cancellation: Option<&'a AtomicBool>,
1561}
1562
1563fn run_engine_owned_dead_code_pipeline(
1564 input: EngineDeadCodePipelineInput<'_>,
1565) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
1566 let EngineDeadCodePipelineInput {
1567 config,
1568 discovery,
1569 modules,
1570 metrics,
1571 collect_usages,
1572 retain_graph,
1573 retain_modules,
1574 retain_files,
1575 cancellation,
1576 } = input;
1577 let stopped = |stage: &str| -> EngineResult<()> {
1578 if token_is_set(cancellation) {
1579 return Err(crate::EngineError::cancelled(stage));
1580 }
1581 Ok(())
1582 };
1583 stopped("the dead-code prelude")?;
1584 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
1585 let prelude_timings = prelude.timings();
1586 stopped("dead-code entry-point discovery")?;
1587 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
1588 stopped("import resolution and graph construction")?;
1589 let (resolved, graph, graph_cache_rejection) =
1590 resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
1591 stopped("the dead-code detectors")?;
1592
1593 let mut detector = core_backend::run_dead_code_detectors(
1594 &prelude,
1595 &graph.graph,
1596 &resolved.project.modules,
1597 &modules,
1598 collect_usages,
1599 &entry_points,
1600 );
1601 crate::dead_code::stamp_finding_ids(&mut detector.results, &config.root);
1604 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1605 stopped("assembling the dead-code report")?;
1610 let profile =
1611 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1612 retain_timings: retain_graph,
1613 prelude: &prelude,
1614 prelude_timings,
1615 parse_metrics: metrics,
1616 module_count: modules.len(),
1617 entry_points: &entry_points,
1618 resolved: &resolved,
1619 graph: &graph,
1620 detector: &detector,
1621 file_count: discovery.files().len(),
1622 workspace_count: discovery.workspaces().len(),
1623 graph_cache_rejection,
1624 });
1625 let script_used_packages = prelude.script_used_packages();
1626 let trace_provenance = prelude.trace_provenance(&modules);
1627 prelude.finish();
1628 let file_hashes = collect_file_hashes(&modules, discovery.files());
1629
1630 Ok(SharedDeadCodeAnalysisArtifacts {
1631 results: detector.results,
1632 timings: profile.timings,
1633 graph: retain_graph.then_some(graph.graph),
1634 modules: retain_modules.then_some(modules),
1635 files: retain_files.then(|| discovery.files().to_vec()),
1636 script_used_packages,
1637 trace_provenance,
1638 file_hashes,
1639 })
1640}
1641
1642fn resolve_or_build_dead_code_graph(
1650 prelude: &core_backend::DeadCodeBackendPrelude,
1651 entry_points: &core_backend::DeadCodeEntryPoints,
1652 modules: &[ModuleInfo],
1653) -> (
1654 core_backend::DeadCodeResolvedModules,
1655 core_backend::DeadCodeGraphRun,
1656 Option<CacheRejection>,
1657) {
1658 let rejection =
1659 match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1660 Ok((resolved, graph)) => return (resolved, graph, None),
1661 Err(rejection) => rejection,
1662 };
1663
1664 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1665 let graph =
1666 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1667 (resolved, graph, rejection)
1668}
1669
1670fn collect_file_hashes(
1671 modules: &[ModuleInfo],
1672 files: &[DiscoveredFile],
1673) -> FxHashMap<PathBuf, u64> {
1674 modules
1675 .iter()
1676 .filter_map(|module| {
1677 files
1678 .get(module.file_id.0 as usize)
1679 .map(|file| (file.path.clone(), module.content_hash))
1680 })
1681 .collect()
1682}
1683
1684pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1685 config: &ResolvedConfig,
1686 modules: &[ModuleInfo],
1687) -> EngineResult<DeadCodeAnalysisArtifacts> {
1688 let (workspaces, _diagnostics, workspaces_ms) =
1689 crate::project_config::collect_workspace_metadata(config)?;
1690 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1691 config,
1692 &workspaces,
1693 workspaces_ms,
1694 );
1695 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1696 config,
1697 discovery: &discovery,
1698 modules: Arc::from(modules),
1699 metrics: reused_parse_metrics(),
1700 collect_usages: true,
1701 retain_graph: true,
1702 retain_modules: false,
1703 retain_files: false,
1704 cancellation: None,
1705 })
1706 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1707}
1708
1709#[cfg(test)]
1710mod tests {
1711 use std::fmt::Write as _;
1712 use std::time::Duration;
1713
1714 use super::*;
1715
1716 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1717 let project = tempfile::tempdir().expect("project");
1718 let root = project.path();
1719 std::fs::create_dir(root.join("src")).expect("create source directory");
1720 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1721 let session = AnalysisSession::load_default(root);
1722 (project, session)
1723 }
1724
1725 #[test]
1728 fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
1729 let project = tempfile::tempdir().expect("project");
1730 let root = project.path();
1731 std::fs::create_dir(root.join("src")).expect("create source directory");
1732 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1733 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
1734
1735 let baseline = AnalysisSession::load_default(root)
1736 .analyze_dead_code()
1737 .expect("an uncancelled session analyzes");
1738 assert!(
1739 !baseline.results.unused_files.is_empty(),
1740 "the fixture must have findings, so an empty result would be a plausible wrong answer"
1741 );
1742
1743 let error = AnalysisSession::load_default(root)
1744 .with_cancellation(Arc::new(AtomicBool::new(true)))
1745 .analyze_dead_code()
1746 .expect_err("a cancelled session must not return results");
1747 assert!(error.is_cancelled(), "unexpected error: {error}");
1748 assert!(error.message().contains("cancelled"));
1749 }
1750
1751 #[test]
1757 fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
1758 let project = tempfile::tempdir().expect("project");
1759 let root = project.path();
1760 std::fs::create_dir(root.join("src")).expect("create source directory");
1761 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1762
1763 let cold = AnalysisSession::load_default(root)
1764 .analyze_dead_code_with_artifacts(false, true)
1765 .expect("cold run analyzes");
1766 assert_eq!(
1767 cold.timings
1768 .expect("cold timings retained")
1769 .graph_cache_rejection,
1770 Some(CacheRejection::Absent),
1771 "a first run has no persisted graph to refuse"
1772 );
1773
1774 std::fs::write(
1775 root.join("src/index.ts"),
1776 "export const entry = 1;\nexport const added = 2;\n",
1777 )
1778 .expect("edit the entry");
1779
1780 let warm = AnalysisSession::load_default(root)
1781 .analyze_dead_code_with_artifacts(false, true)
1782 .expect("warm run analyzes");
1783 assert_eq!(
1784 warm.timings
1785 .expect("warm timings retained")
1786 .graph_cache_rejection,
1787 Some(CacheRejection::FingerprintChanged),
1788 "the decoded graph was refused because a file changed, and the run must say so"
1789 );
1790 }
1791
1792 #[test]
1795 fn a_session_without_a_token_is_never_cancelled() {
1796 let (_project, session) = session_with_source("export const unused = 1;\n");
1797 assert!(!session.is_cancelled());
1798 session
1799 .analyze_dead_code()
1800 .expect("a session without a token analyzes");
1801 }
1802
1803 const PARSE_LOOP_FILES: usize = 400;
1807
1808 fn parse_loop_project() -> tempfile::TempDir {
1809 let project = tempfile::tempdir().expect("project");
1810 let src = project.path().join("src");
1811 std::fs::create_dir_all(&src).expect("src dir");
1812 for module in 0..PARSE_LOOP_FILES {
1813 let mut source = String::new();
1814 for symbol in 0..20 {
1815 let _ = writeln!(
1816 source,
1817 "export const helper{symbol} = (input: number): number => {{\n \
1818 if (input > {symbol}) {{\n return input * {symbol};\n }}\n \
1819 return input - {symbol};\n}};"
1820 );
1821 }
1822 std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
1823 }
1824 project
1825 }
1826
1827 #[cfg(unix)]
1834 #[test]
1835 fn a_refresh_drops_modules_whose_fingerprints_need_a_content_check() {
1836 let (_project, mut session) = session_with_source("export const kept = 1;\n");
1837 drop(session.parse_modules(false, None));
1838
1839 assert!(!session.refresh_discovery(), "the file set is the same");
1840 assert!(
1841 session.parsed_cache.lock().expect("parse cache").is_some(),
1842 "fingerprints with a known ctime keep the modules for the next run"
1843 );
1844
1845 if let Some(cache) = session
1846 .parsed_cache
1847 .get_mut()
1848 .expect("parse cache")
1849 .as_mut()
1850 {
1851 for fingerprint in &mut cache.fingerprints {
1852 fingerprint.ctime_ns = 0;
1853 }
1854 }
1855 assert!(!session.refresh_discovery(), "the file set is the same");
1856 assert!(
1857 session.parsed_cache.lock().expect("parse cache").is_none(),
1858 "the next run parses through the persisted cache, which checks the content"
1859 );
1860 }
1861
1862 fn uncached_session(root: &Path) -> AnalysisSession {
1865 let mut project_config = crate::project_config::default_project_config(root);
1866 project_config.config.no_cache = true;
1867 AnalysisSession::from_config(project_config)
1868 }
1869
1870 #[test]
1879 fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
1880 let project = parse_loop_project();
1881 let root = project.path();
1882
1883 drop(uncached_session(root).parse_modules(false, None));
1885
1886 let started = Instant::now();
1887 let full = uncached_session(root).parse_modules(false, None);
1888 let full_parse = started.elapsed();
1889 let full_count = full.modules.len();
1890 assert_eq!(
1891 full_count, PARSE_LOOP_FILES,
1892 "the fixture must parse every generated module"
1893 );
1894 assert!(
1895 full_parse >= Duration::from_millis(20),
1896 "the fixture is too small to cancel part way through: {full_parse:?}"
1897 );
1898
1899 let mut partial = None;
1904 let mut cancelled_session = None;
1905 for attempt in 1..=6_u32 {
1906 let token = Arc::new(AtomicBool::new(false));
1907 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
1911 let watchdog = {
1912 let token = Arc::clone(&token);
1913 let delay = full_parse * attempt / 6;
1914 std::thread::spawn(move || {
1915 std::thread::sleep(delay);
1916 token.store(true, Ordering::SeqCst);
1917 })
1918 };
1919 let cancelled = session.parse_modules(false, Some(&token));
1920 watchdog.join().expect("watchdog thread");
1921
1922 let parsed = cancelled.modules.len();
1923 assert!(
1924 parsed < full_count,
1925 "the parse returned all {full_count} modules, so the loop never read the token"
1926 );
1927 cancelled_session = Some(session);
1928 if parsed > 0 {
1929 partial = Some(parsed);
1930 break;
1931 }
1932 }
1933 let parsed = partial.expect(
1934 "no attempt flipped the token while the loop was running, so this never measured a \
1935 stop part way through",
1936 );
1937 assert!(parsed < full_count);
1938
1939 assert!(
1940 cancelled_session
1941 .expect("a cancelled session")
1942 .parsed_cache
1943 .lock()
1944 .expect("parse cache")
1945 .is_none(),
1946 "a truncated parse must not be retained as this session's warm cache"
1947 );
1948 let recovered = uncached_session(root).parse_modules(false, None);
1949 assert_eq!(
1950 recovered.modules.len(),
1951 full_count,
1952 "a later uncancelled parse must still see the whole project"
1953 );
1954 }
1955
1956 #[test]
1957 fn session_retains_workspace_metadata_from_config_load() {
1958 let project = tempfile::tempdir().expect("project");
1959 let root = project.path();
1960 std::fs::write(
1961 root.join("package.json"),
1962 r#"{"name":"root","workspaces":["packages/*"]}"#,
1963 )
1964 .expect("write root package");
1965 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1966 std::fs::write(
1967 root.join("packages/a/package.json"),
1968 r#"{"name":"pkg-a","type":"module"}"#,
1969 )
1970 .expect("write workspace package");
1971
1972 let session = AnalysisSession::load(root, None).expect("session loads");
1973
1974 assert!(
1975 session
1976 .workspaces()
1977 .iter()
1978 .any(|workspace| workspace.name == "pkg-a"),
1979 "session must retain workspace metadata discovered during config load"
1980 );
1981 }
1982
1983 #[test]
1984 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1985 let project = tempfile::tempdir().expect("project");
1986 let root = project.path();
1987 std::fs::create_dir(root.join("src")).expect("create source directory");
1988 std::fs::write(
1989 root.join("package.json"),
1990 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1991 )
1992 .expect("write package manifest");
1993 std::fs::write(
1994 root.join("vitest.config.ts"),
1995 "import './src/feature';\nexport default {};\n",
1996 )
1997 .expect("write vitest config");
1998 std::fs::write(
1999 root.join("src/feature.ts"),
2000 "export const feature = true;\n",
2001 )
2002 .expect("write reachable source");
2003 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
2004 .expect("write hidden source");
2005
2006 let unfiltered = AnalysisSession::load(root, None)
2007 .expect("unfiltered session loads")
2008 .analyze_dead_code()
2009 .expect("unfiltered analysis succeeds");
2010 assert!(
2011 unfiltered
2012 .results
2013 .unused_files
2014 .iter()
2015 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
2016 );
2017
2018 std::fs::write(
2019 root.join(".fallowrc.json"),
2020 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
2021 )
2022 .expect("write fallow config");
2023 let session = AnalysisSession::load(root, None).expect("filtered session loads");
2024 let hidden_path = root.join("src/hidden.ts");
2025 assert!(session.files().iter().any(|file| file.path == hidden_path));
2026
2027 let filtered = session
2028 .analyze_dead_code_with_artifacts(false, true)
2029 .expect("filtered analysis succeeds");
2030 assert!(
2031 filtered
2032 .results
2033 .unused_files
2034 .iter()
2035 .all(|finding| finding.file.path != hidden_path)
2036 );
2037 assert!(
2038 filtered
2039 .graph
2040 .as_ref()
2041 .is_some_and(|graph| graph.module_count() == session.files().len())
2042 );
2043 }
2044
2045 #[test]
2046 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
2047 use fallow_types::output_dead_code::UnusedFileFinding;
2048 use fallow_types::results::UnusedFile;
2049
2050 let project = tempfile::tempdir().expect("project");
2051 let config = serde_json::from_str::<fallow_config::FallowConfig>(
2052 r#"{"ignoreFindings":["**/*.ts"]}"#,
2053 )
2054 .expect("config parses")
2055 .resolve(
2056 project.path().to_path_buf(),
2057 fallow_config::OutputFormat::Human,
2058 1,
2059 true,
2060 true,
2061 None,
2062 );
2063 let outside = project
2064 .path()
2065 .parent()
2066 .expect("project has parent")
2067 .join("outside.ts");
2068 let mut results = AnalysisResults {
2069 unused_files: vec![
2070 UnusedFileFinding::with_actions(UnusedFile {
2071 path: PathBuf::from(r"src\hidden.ts"),
2072 }),
2073 UnusedFileFinding::with_actions(UnusedFile {
2074 path: outside.clone(),
2075 }),
2076 ],
2077 ..AnalysisResults::default()
2078 };
2079
2080 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
2081
2082 assert_eq!(results.unused_files.len(), 1);
2083 assert_eq!(results.unused_files[0].file.path, outside);
2084 }
2085
2086 #[cfg(unix)]
2089 fn warm_store_project() -> tempfile::TempDir {
2090 let project = tempfile::tempdir().expect("project");
2091 let root = project.path();
2092 std::fs::create_dir(root.join("src")).expect("create source directory");
2093 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2094 std::fs::write(root.join("src/other.ts"), "export const other = 2;\n").expect("other");
2095 project
2096 }
2097
2098 #[cfg(unix)]
2099 fn warm_session(root: &Path, store: &Arc<WarmParseStore>) -> AnalysisSession {
2100 AnalysisSession::load_default(root).with_warm_parse(Some(Arc::clone(store)))
2101 }
2102
2103 #[cfg(unix)]
2104 fn exported_names(modules: &[ModuleInfo]) -> Vec<String> {
2105 let mut names: Vec<String> = modules
2106 .iter()
2107 .flat_map(|module| module.exports.iter().map(|export| export.name.to_string()))
2108 .collect();
2109 names.sort();
2110 names
2111 }
2112
2113 #[cfg(unix)]
2114 #[test]
2115 fn a_warm_store_serves_a_new_session_without_parse_work() {
2116 let project = warm_store_project();
2117 let store = Arc::new(WarmParseStore::new(
2118 crate::warm_parse::WarmParseLimits::default(),
2119 ));
2120
2121 let first = warm_session(project.path(), &store).parse_modules(false, None);
2122 let second = warm_session(project.path(), &store).parse_modules(false, None);
2123
2124 assert!(Arc::ptr_eq(&first.modules, &second.modules));
2125 assert_eq!(second.metrics.cache_hits + second.metrics.cache_misses, 0);
2126 let counts = store.counts();
2127 assert_eq!(counts.parse_runs, 1);
2128 assert_eq!(counts.modules_parsed, 2);
2129 assert_eq!(counts.modules_reused, 2);
2130 }
2131
2132 #[cfg(unix)]
2133 #[test]
2134 fn a_warm_store_serves_the_health_parse_of_a_new_session() {
2135 let project = warm_store_project();
2136 let store = Arc::new(WarmParseStore::new(
2137 crate::warm_parse::WarmParseLimits::default(),
2138 ));
2139
2140 drop(warm_session(project.path(), &store).parse_modules(false, None));
2141 let health = warm_session(project.path(), &store).parsed_parts_uncached(true);
2142
2143 assert_eq!(health.modules.len(), 2);
2144 assert_eq!(health.cache_hits + health.cache_misses, 0);
2145 assert_eq!(
2146 store.counts().parse_runs,
2147 1,
2148 "the first parse computed complexity"
2149 );
2150 }
2151
2152 #[cfg(unix)]
2153 #[test]
2154 fn an_edit_or_a_new_file_makes_the_next_session_parse_again() {
2155 let project = warm_store_project();
2156 let root = project.path();
2157 let store = Arc::new(WarmParseStore::new(
2158 crate::warm_parse::WarmParseLimits::default(),
2159 ));
2160 drop(warm_session(root, &store).parse_modules(false, None));
2161
2162 std::fs::write(root.join("src/other.ts"), "export const renamed = 22;\n").expect("edit");
2163 let edited = warm_session(root, &store).parse_modules(false, None);
2164 assert_eq!(exported_names(&edited.modules), ["entry", "renamed"]);
2165 assert_eq!(
2166 edited.metrics.cache_misses, 1,
2167 "only the edited file is parsed"
2168 );
2169
2170 std::fs::write(root.join("src/added.ts"), "export const added = 3;\n").expect("add");
2171 let added = warm_session(root, &store).parse_modules(false, None);
2172 assert_eq!(
2173 exported_names(&added.modules),
2174 ["added", "entry", "renamed"]
2175 );
2176
2177 let counts = store.counts();
2178 assert_eq!(counts.parse_runs, 3);
2179 assert_eq!(counts.modules_reused, 0);
2180 assert_eq!(store.len(), 2, "each file list keeps its latest parse only");
2181 }
2182
2183 #[cfg(unix)]
2184 #[test]
2185 fn a_session_without_the_persisted_cache_does_not_use_the_store() {
2186 let project = warm_store_project();
2187 let store = Arc::new(WarmParseStore::new(
2188 crate::warm_parse::WarmParseLimits::default(),
2189 ));
2190
2191 drop(
2192 uncached_session(project.path())
2193 .with_warm_parse(Some(Arc::clone(&store)))
2194 .parse_modules(false, None),
2195 );
2196
2197 assert_eq!(
2198 store.counts(),
2199 crate::warm_parse::WarmParseCounts::default()
2200 );
2201 assert!(store.is_empty());
2202 }
2203
2204 #[cfg(unix)]
2208 #[test]
2209 fn a_kept_parse_records_its_read_failures_again() {
2210 let project = warm_store_project();
2211 let root = project.path();
2212 std::fs::write(root.join("src/broken.ts"), [0xff, 0xfe, 0x00]).expect("invalid UTF-8");
2213 let store = Arc::new(WarmParseStore::new(
2214 crate::warm_parse::WarmParseLimits::default(),
2215 ));
2216 let read_failures = |session: &AnalysisSession| {
2217 session
2218 .current_workspace_diagnostics()
2219 .into_iter()
2220 .filter(|diagnostic| {
2221 matches!(
2222 diagnostic.kind,
2223 fallow_types::workspace::WorkspaceDiagnosticKind::SourceReadFailure { .. }
2224 )
2225 })
2226 .count()
2227 };
2228
2229 let first = warm_session(root, &store);
2230 drop(first.parse_modules(false, None));
2231 assert_eq!(read_failures(&first), 1);
2232
2233 drop(fallow_config::record_source_read_failures(root, &[]));
2234 let second = warm_session(root, &store);
2235 drop(second.parse_modules(false, None));
2236 assert_eq!(store.counts().modules_reused, 2);
2237 assert_eq!(read_failures(&second), 1);
2238 }
2239
2240 #[test]
2241 fn warm_parse_cache_reuses_module_storage() {
2242 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2243 let first = session.parse_modules(true, None);
2244 let second = session.parse_modules(false, None);
2245
2246 assert!(
2247 Arc::ptr_eq(&first.modules, &second.modules),
2248 "warm session queries must share parsed module storage"
2249 );
2250 }
2251
2252 #[test]
2253 fn warm_styling_cache_reuses_artifact_allocation() {
2254 let project = tempfile::tempdir().expect("project");
2255 let root = project.path();
2256 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
2257 .expect("write stylesheet");
2258 let session = AnalysisSession::load_default(root);
2259
2260 let first = session.styling_analysis_artifacts();
2261 let second = session.styling_analysis_artifacts();
2262
2263 assert!(
2264 Arc::ptr_eq(&first, &second),
2265 "warm styling queries must share the cached artifact allocation"
2266 );
2267 }
2268
2269 #[test]
2270 fn shared_parsed_modules_reuse_public_session_storage() {
2271 let (_project, session) = session_with_source("export const value = 1;\n");
2272 let first = session.shared_parsed_modules(true);
2273 let second = session.shared_parsed_modules(false);
2274
2275 assert!(Arc::ptr_eq(&first, &second));
2276 }
2277
2278 #[test]
2279 fn shared_parsed_parts_reuse_public_session_storage() {
2280 let (_project, session) = session_with_source("export const value = 1;\n");
2281 let cached = session.shared_parsed_modules(true);
2282 let parts = session.shared_parsed_parts(false);
2283
2284 assert!(Arc::ptr_eq(&cached, &parts.modules));
2285 }
2286
2287 #[test]
2288 fn warm_complexity_artifacts_reuse_cached_module_storage() {
2289 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2290 let cached = session.parse_modules(true, None);
2291 let artifacts = session
2292 .analyze_dead_code_with_reuse_artifacts(true, true, false)
2293 .expect("analysis succeeds");
2294 let retained = artifacts.modules.expect("complexity modules retained");
2295
2296 assert!(
2297 Arc::ptr_eq(&cached.modules, &retained),
2298 "warm complexity artifacts must share parsed module storage"
2299 );
2300 }
2301
2302 #[test]
2303 fn shared_and_owned_artifacts_preserve_output_bytes() {
2304 let (_project, session) = session_with_source(
2305 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
2306 );
2307 let owned = session
2308 .analyze_dead_code_with_artifacts(true, true)
2309 .expect("owned analysis succeeds");
2310 let shared = session
2311 .analyze_dead_code_with_shared_artifacts(true, true)
2312 .expect("shared analysis succeeds");
2313
2314 assert_eq!(
2315 serde_json::to_vec(&owned.results).expect("serialize owned results"),
2316 serde_json::to_vec(&shared.results).expect("serialize shared results")
2317 );
2318 assert_eq!(owned.file_hashes, shared.file_hashes);
2319 assert_eq!(
2320 owned
2321 .modules
2322 .as_deref()
2323 .unwrap_or_default()
2324 .iter()
2325 .map(|module| module.content_hash)
2326 .collect::<Vec<_>>(),
2327 shared
2328 .modules
2329 .as_deref()
2330 .unwrap_or_default()
2331 .iter()
2332 .map(|module| module.content_hash)
2333 .collect::<Vec<_>>()
2334 );
2335 }
2336
2337 #[test]
2338 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
2339 let project = tempfile::tempdir().expect("project");
2340 let root = project.path();
2341 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
2342 std::fs::write(
2343 root.join("package.json"),
2344 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
2345 )
2346 .expect("write package manifest");
2347 std::fs::write(
2348 root.join("app/routes/home.tsx"),
2349 r#"
2350import { useLoaderData } from "react-router";
2351export function loader() { return { opaque: "value" }; }
2352export default function Home() {
2353 const data = useLoaderData<typeof loader>();
2354 const copy = { ...data };
2355 return JSON.stringify(copy);
2356}
2357"#,
2358 )
2359 .expect("write route module");
2360
2361 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2362 let cold_parse = cold_session.parsed_parts(false);
2363 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
2364 let cold = cold_session
2365 .analyze_dead_code()
2366 .expect("cold analysis succeeds");
2367
2368 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2369 let warm_parse = warm_session.parsed_parts(false);
2370 assert!(
2371 warm_parse.cache_hits > 0,
2372 "second session must use disk cache"
2373 );
2374 let warm = warm_session
2375 .analyze_dead_code()
2376 .expect("warm analysis succeeds");
2377
2378 assert!(
2379 cold.results.unused_load_data_keys.is_empty(),
2380 "cold analysis must abstain for an opaque route-loader use"
2381 );
2382 assert_eq!(
2383 serde_json::to_vec(&cold.results).expect("serialize cold results"),
2384 serde_json::to_vec(&warm.results).expect("serialize warm results"),
2385 "warm route-loader analysis must match cold analysis"
2386 );
2387 }
2388
2389 #[test]
2390 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
2391 let project = tempfile::tempdir().expect("project");
2392 let root = project.path();
2393 std::fs::create_dir(root.join("src")).expect("create source directory");
2394 std::fs::write(
2395 root.join("package.json"),
2396 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
2397 )
2398 .expect("write package manifest");
2399 std::fs::write(
2400 root.join("src/dependency.ts"),
2401 "export function dependency() { return 'real'; }\n",
2402 )
2403 .expect("write dependency");
2404 std::fs::write(
2405 root.join("src/wrapper.ts"),
2406 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
2407 )
2408 .expect("write wrapper");
2409 std::fs::write(
2410 root.join("src/index.ts"),
2411 "export { wrapper } from './wrapper';\n",
2412 )
2413 .expect("write entry point");
2414 std::fs::write(
2415 root.join("src/wrapper.test.ts"),
2416 r#"
2417import { vi } from "vitest";
2418vi.mock("./dependency", () => ({ dependency: () => "mock" }));
2419import { wrapper } from "./wrapper";
2420wrapper();
2421"#,
2422 )
2423 .expect("write test");
2424
2425 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2426 let dependency_id = cold_session
2427 .files()
2428 .iter()
2429 .find(|file| file.path == root.join("src/dependency.ts"))
2430 .expect("dependency discovered")
2431 .id;
2432 let cold = cold_session
2433 .analyze_dead_code_with_artifacts(false, true)
2434 .expect("cold analysis succeeds");
2435 let cold_exports = crate::module_graph::module_value_exports(
2436 cold.graph.as_ref().expect("cold graph retained"),
2437 );
2438 assert!(
2439 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
2440 "cold analysis must persist the graph cache"
2441 );
2442
2443 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2444 let warm = warm_session
2445 .analyze_dead_code_with_artifacts(false, true)
2446 .expect("warm analysis succeeds");
2447 let warm_exports = crate::module_graph::module_value_exports(
2448 warm.graph.as_ref().expect("warm graph retained"),
2449 );
2450
2451 let dependency = cold_exports
2452 .iter()
2453 .find(|export| export.file_id == dependency_id && export.name == "dependency")
2454 .expect("dependency export retained");
2455 assert!(!dependency.test_referenced);
2456 assert_eq!(warm_exports, cold_exports);
2457 }
2458
2459 #[test]
2460 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
2461 let project = tempfile::tempdir().expect("project");
2462 let root = project.path();
2463 std::fs::create_dir(root.join("src")).expect("create source directory");
2464 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
2465 .expect("write package manifest");
2466 for name in ["a.ts", "b.ts", "c.ts"] {
2467 std::fs::write(
2468 root.join("src").join(name),
2469 format!("export const {} = 1;\n", name.replace('.', "_")),
2470 )
2471 .expect("write source");
2472 }
2473 let session = AnalysisSession::load(root, None).expect("session loads");
2474 let removed_path = root.join("src/b.ts");
2475 let removed_id = session
2476 .files()
2477 .iter()
2478 .find(|file| file.path == removed_path)
2479 .expect("removed source discovered")
2480 .id;
2481 std::fs::remove_file(&removed_path).expect("remove source after discovery");
2482
2483 let parts = session.parsed_parts(false);
2484
2485 assert!(
2486 parts
2487 .modules
2488 .iter()
2489 .all(|module| module.file_id != removed_id),
2490 "unreadable file must not receive a placeholder module"
2491 );
2492 let diagnostic = parts
2493 .workspace_diagnostics
2494 .iter()
2495 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2496 .expect("parsed session parts carry source read failure");
2497 assert_eq!(diagnostic.path, removed_path);
2498 assert!(
2499 session
2500 .current_workspace_diagnostics()
2501 .iter()
2502 .any(|diagnostic| {
2503 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
2504 }),
2505 "session output carries parse-time source diagnostics"
2506 );
2507 }
2508
2509 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
2510 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
2511 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
2512
2513 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
2514 diagnostics
2515 .iter()
2516 .any(|diagnostic| diagnostic.kind.id() == id)
2517 }
2518
2519 fn write_single_source_project(root: &Path, manifest: &str) {
2520 std::fs::create_dir(root.join("src")).expect("create source directory");
2521 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
2522 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
2523 .expect("write source");
2524 }
2525
2526 #[test]
2532 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
2533 let project = tempfile::tempdir().expect("project");
2534 let root = project.path();
2535 write_single_source_project(
2536 root,
2537 r#"{"name":"issue-2366-engine-session","private":true}"#,
2538 );
2539 std::fs::write(
2540 root.join("pnpm-workspace.yaml"),
2541 MALFORMED_PNPM_WORKSPACE_YAML,
2542 )
2543 .expect("write malformed workspace yaml");
2544
2545 let broken = AnalysisSession::load(root, None).expect("session loads");
2546 broken
2547 .analyze_dead_code()
2548 .expect("analysis on the malformed yaml succeeds");
2549 assert!(
2550 has_diagnostic_kind(
2551 &broken.current_workspace_diagnostics(),
2552 "malformed-pnpm-workspace-yaml"
2553 ),
2554 "the first session surfaces the malformed yaml: {:?}",
2555 broken.current_workspace_diagnostics()
2556 );
2557
2558 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
2559 .expect("fix workspace yaml");
2560
2561 let fixed = AnalysisSession::load(root, None).expect("session loads");
2562 fixed
2563 .analyze_dead_code()
2564 .expect("analysis on the fixed yaml succeeds");
2565 let current = fixed.current_workspace_diagnostics();
2566 assert!(
2567 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
2568 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
2569 );
2570 }
2571
2572 #[test]
2579 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
2580 let project = tempfile::tempdir().expect("project");
2581 let root = project.path();
2582 write_single_source_project(
2583 root,
2584 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2585 );
2586 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2587 .expect("write bun.lockb placeholder");
2588 let config = fallow_config::FallowConfig::default().resolve(
2589 root.to_path_buf(),
2590 fallow_config::OutputFormat::Json,
2591 1,
2592 true,
2593 true,
2594 None,
2595 );
2596 let reload_config = || {
2597 let (_, diagnostics) =
2598 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2599 .expect("workspace discovery succeeds");
2600 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2601 };
2602
2603 reload_config();
2604 let first =
2605 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
2606 first
2607 .analyze_dead_code()
2608 .expect("analysis with bun.lockb only succeeds");
2609 assert!(
2610 has_diagnostic_kind(
2611 &first.current_workspace_diagnostics(),
2612 "bun-lockb-override-resolution-skipped"
2613 ),
2614 "the first run surfaces the bun.lockb skip: {:?}",
2615 first.current_workspace_diagnostics()
2616 );
2617
2618 std::fs::write(
2619 root.join("bun.lock"),
2620 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
2621 )
2622 .expect("write text bun.lock");
2623
2624 reload_config();
2625 let rerun =
2626 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
2627 rerun
2628 .analyze_dead_code()
2629 .expect("analysis with the text bun.lock succeeds");
2630 let current = rerun.current_workspace_diagnostics();
2631 assert!(
2632 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2633 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
2634 );
2635 }
2636
2637 #[test]
2650 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
2651 let project = tempfile::tempdir().expect("project");
2652 let root = project.path();
2653 write_single_source_project(
2654 root,
2655 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
2656 );
2657 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
2658 .expect("write oversized source");
2659 let mut config = fallow_config::FallowConfig::default().resolve(
2660 root.to_path_buf(),
2661 fallow_config::OutputFormat::Json,
2662 1,
2663 true,
2664 true,
2665 None,
2666 );
2667 config.max_file_size_bytes = Some(1024);
2668
2669 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
2670
2671 fallow_config::append_workspace_diagnostics(
2676 root,
2677 vec![WorkspaceDiagnostic::new(
2678 root,
2679 root.join("src/other-walk-only.ts"),
2680 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
2681 size_bytes: 4096,
2682 },
2683 )],
2684 );
2685
2686 let current = session.current_workspace_diagnostics();
2687 let skipped: Vec<&Path> = current
2688 .iter()
2689 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
2690 .map(|diagnostic| diagnostic.path.as_path())
2691 .collect();
2692 assert_eq!(
2693 skipped.len(),
2694 1,
2695 "the session reports its own walk's skips only: {skipped:?}"
2696 );
2697 assert!(
2698 skipped[0].ends_with("src/huge.ts"),
2699 "the surviving skip is this walk's own: {skipped:?}"
2700 );
2701 }
2702
2703 #[test]
2711 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
2712 let project = tempfile::tempdir().expect("project");
2713 let root = project.path();
2714 write_single_source_project(
2715 root,
2716 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2717 );
2718 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2719 .expect("write bun.lockb placeholder");
2720 let config = fallow_config::FallowConfig::default().resolve(
2721 root.to_path_buf(),
2722 fallow_config::OutputFormat::Json,
2723 1,
2724 true,
2725 true,
2726 None,
2727 );
2728 let reload_config = || {
2729 let (_, diagnostics) =
2730 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2731 .expect("workspace discovery succeeds");
2732 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2733 };
2734
2735 reload_config();
2736 let analyzing =
2737 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2738 analyzing
2739 .analyze_dead_code()
2740 .expect("analysis with bun.lockb only succeeds");
2741
2742 reload_config();
2745
2746 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2747 let current = later.current_workspace_diagnostics();
2748 assert!(
2749 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2750 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
2751 {current:?}"
2752 );
2753 }
2754}