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::from_config(default_project_config(root))
199 }
200
201 #[must_use]
203 pub fn from_config(project_config: ProjectConfig) -> Self {
204 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
205 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
206 {
207 crate::discover::prepare_analysis_discovery_with_workspaces(
208 &project_config.config,
209 &project_config.workspaces,
210 workspace_discovery_ms,
211 )
212 } else {
213 crate::discover::prepare_analysis_discovery(&project_config.config)
214 };
215 let workspaces = if uses_preloaded_workspaces {
216 project_config.workspaces
217 } else {
218 discovery.workspaces().to_vec()
219 };
220 let workspace_diagnostics = merge_workspace_diagnostics(
232 merge_workspace_diagnostics(
233 project_config.workspace_diagnostics,
234 fallow_config::workspace_diagnostics_for(&project_config.config.root)
235 .into_iter()
236 .filter(|diagnostic| {
237 !diagnostic.kind.is_analysis_stage()
238 && !diagnostic.kind.is_source_discovery()
239 })
240 .collect(),
241 ),
242 discovery.source_diagnostics().to_vec(),
243 );
244 Self {
245 config: project_config.config,
246 config_path: project_config.path,
247 config_inputs: project_config.inputs,
248 config_inputs_before_resolve: project_config.inputs_before_resolve,
249 discovery,
250 workspaces,
251 workspace_diagnostics,
252 parsed_cache: Mutex::new(None),
253 styling_cache: Mutex::new(None),
254 cancellation: None,
255 warm_parse: crate::warm_parse::installed(),
256 preloaded_workspaces: uses_preloaded_workspaces,
257 parse_counts: ParseCountCells::default(),
258 disk_cache_stale: AtomicBool::new(false),
259 }
260 }
261
262 #[must_use]
277 pub fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
278 self.cancellation = Some(cancellation);
279 self
280 }
281
282 #[must_use]
286 pub fn with_warm_parse(mut self, store: Option<Arc<WarmParseStore>>) -> Self {
287 self.warm_parse = store;
288 self
289 }
290
291 pub fn set_cancellation(&mut self, cancellation: Arc<AtomicBool>) {
296 self.cancellation = Some(cancellation);
297 }
298
299 #[must_use]
301 pub fn parse_counts(&self) -> SessionParseCounts {
302 self.parse_counts.snapshot()
303 }
304
305 pub fn refresh_discovery(&mut self) -> bool {
320 let discovery = if self.preloaded_workspaces {
321 crate::discover::prepare_analysis_discovery_with_workspaces(
322 &self.config,
323 &self.workspaces,
324 0.0,
325 )
326 } else {
327 crate::discover::prepare_analysis_discovery(&self.config)
328 };
329 let same_files = discovery.files().len() == self.files().len()
330 && discovery
331 .files()
332 .iter()
333 .zip(self.files())
334 .all(|(fresh, known)| fresh.path == known.path);
335 if !same_files || !self.cached_fingerprints_are_trustworthy() {
336 self.flush_parse_cache();
337 *self
338 .parsed_cache
339 .get_mut()
340 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
341 self.clear_styling_cache();
342 }
343 if !self.preloaded_workspaces {
344 self.workspaces = discovery.workspaces().to_vec();
345 }
346 self.discovery = discovery;
347 !same_files
348 }
349
350 fn cached_fingerprints_are_trustworthy(&mut self) -> bool {
353 self.parsed_cache
354 .get_mut()
355 .unwrap_or_else(std::sync::PoisonError::into_inner)
356 .as_ref()
357 .is_none_or(|cache| {
358 cache
359 .fingerprints
360 .iter()
361 .all(|fingerprint| fingerprint.is_trustworthy_without_content())
362 })
363 }
364
365 pub fn flush_parse_cache(&self) {
379 if self.config.no_cache || !self.disk_cache_stale.swap(false, Ordering::SeqCst) {
380 return;
381 }
382 let Ok(cache) = self.parsed_cache.lock() else {
383 return;
384 };
385 let Some(cache) = cache.as_ref() else {
386 return;
387 };
388 let cache_max_size_bytes =
389 crate::project_config::resolve_cache_max_size_bytes(&self.config);
390 let mut store = fallow_extract::cache::CacheStore::load(
391 &self.config.cache_dir,
392 &self.config.root,
393 self.config.cache_config_hash,
394 cache_max_size_bytes,
395 )
396 .ok();
397 let files = self.files();
398 write_parse_cache(
399 &self.config,
400 &mut store,
401 &ParseCacheWrite {
402 modules: &cache.modules,
403 files,
404 need_complexity: cache.need_complexity,
405 fingerprint_of: &|file: &DiscoveredFile| {
406 cache
407 .fingerprints
408 .get(file.id.0 as usize)
409 .copied()
410 .unwrap_or_else(|| SourceFingerprint::new(0, 0))
411 },
412 },
413 );
414 }
415
416 pub fn prewarm_parsed_modules(&self, need_complexity: bool) -> EngineResult<()> {
425 self.shared_parsed_modules_cancellable(need_complexity, "prewarm")
426 .map(drop)
427 }
428
429 fn clear_styling_cache(&self) {
430 if let Ok(mut cache) = self.styling_cache.lock() {
431 *cache = None;
432 }
433 }
434
435 #[must_use]
437 pub fn is_cancelled(&self) -> bool {
438 self.cancellation
439 .as_ref()
440 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
441 }
442
443 fn ensure_not_cancelled(&self, stage: &str) -> EngineResult<()> {
444 if self.is_cancelled() {
445 return Err(crate::EngineError::cancelled(stage));
446 }
447 Ok(())
448 }
449
450 pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
458 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
459 crate::project_config::collect_workspace_metadata(&config)?;
460 Ok(Self::from_config(ProjectConfig {
461 inputs: fallow_config::ConfigInputs::default(),
462 inputs_before_resolve: fallow_config::ConfigInputsSnapshot::default(),
463 config,
464 path: None,
465 workspaces,
466 workspace_diagnostics,
467 workspace_discovery_ms: Some(workspace_discovery_ms),
468 }))
469 }
470
471 #[must_use]
473 pub fn root(&self) -> &Path {
474 &self.config.root
475 }
476
477 #[must_use]
479 pub fn config(&self) -> &ResolvedConfig {
480 &self.config
481 }
482
483 #[must_use]
485 pub fn config_path(&self) -> Option<&Path> {
486 self.config_path.as_deref()
487 }
488
489 #[must_use]
493 pub const fn config_inputs(&self) -> &fallow_config::ConfigInputs {
494 &self.config_inputs
495 }
496
497 #[must_use]
501 pub const fn config_inputs_before_resolve(&self) -> &fallow_config::ConfigInputsSnapshot {
502 &self.config_inputs_before_resolve
503 }
504
505 #[must_use]
510 pub fn retained_bytes_estimate(&self) -> u64 {
511 self.parsed_cache
512 .lock()
513 .ok()
514 .and_then(|cache| {
515 cache
516 .as_ref()
517 .map(|cache| crate::warm_parse::estimated_retained_bytes(&cache.fingerprints))
518 })
519 .unwrap_or(0)
520 }
521
522 #[must_use]
524 pub fn files(&self) -> &[DiscoveredFile] {
525 self.discovery.files()
526 }
527
528 #[must_use]
530 pub fn workspaces(&self) -> &[WorkspaceInfo] {
531 &self.workspaces
532 }
533
534 #[must_use]
536 fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
537 self.discovery
538 .files()
539 .iter()
540 .map(|file| {
541 let fingerprint = std::fs::metadata(&file.path).map_or_else(
542 |_| SourceFingerprint::new(0, file.size_bytes),
543 |metadata| SourceFingerprint::from_metadata(&metadata),
544 );
545 (file.path.clone(), fingerprint)
546 })
547 .collect()
548 }
549
550 pub(crate) fn changed_files_since(
557 &self,
558 git_ref: &str,
559 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
560 crate::changed_files::changed_files(&self.config.root, git_ref)
561 }
562
563 pub(crate) const fn discovery(&self) -> &crate::discover::AnalysisDiscovery {
565 &self.discovery
566 }
567
568 #[must_use]
570 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
571 &self.workspace_diagnostics
572 }
573
574 #[must_use]
587 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
588 merge_workspace_diagnostics(
589 self.workspace_diagnostics.clone(),
590 fallow_config::registry_diagnostics_to_fold(&self.config.root),
591 )
592 }
593
594 pub(crate) fn styling_analysis_artifacts(
595 &self,
596 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
597 if let Ok(cache) = self.styling_cache.lock()
598 && let Some(artifacts) = cache.as_ref()
599 {
600 return Arc::clone(artifacts);
601 }
602
603 let modules = self.shared_parsed_modules(false);
604 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
605 self.files(),
606 &modules,
607 self.config(),
608 ));
609 if let Ok(mut cache) = self.styling_cache.lock() {
610 *cache = Some(Arc::clone(&artifacts));
611 }
612 artifacts
613 }
614
615 #[must_use]
617 pub fn into_parts(self) -> AnalysisSessionParts {
618 let workspace_diagnostics = self.current_workspace_diagnostics();
619 AnalysisSessionParts {
620 config: self.config,
621 config_path: self.config_path,
622 files: self.discovery.into_files(),
623 workspaces: self.workspaces,
624 workspace_diagnostics,
625 }
626 }
627
628 #[must_use]
630 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
631 let AnalysisSessionParts {
632 config,
633 config_path,
634 files,
635 workspaces,
636 workspace_diagnostics,
637 } = self.into_parts();
638 let ParsedModules {
639 modules,
640 metrics,
641 source_diagnostics,
642 ..
643 } = parse_files_with_config(&config, &files, need_complexity, None);
644 ParsedAnalysisSessionParts {
645 config,
646 config_path,
647 files,
648 modules,
649 workspaces,
650 workspace_diagnostics: merge_workspace_diagnostics(
651 workspace_diagnostics,
652 source_diagnostics,
653 ),
654 parse_ms: metrics.parse_ms,
655 cache_update_ms: metrics.cache_ms,
656 cache_hits: metrics.cache_hits,
657 cache_misses: metrics.cache_misses,
658 parse_cpu_ms: metrics.parse_cpu_ms,
659 }
660 }
661
662 #[must_use]
664 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
665 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
666 self.parsed_parts_from_modules(modules.to_vec(), metrics)
667 }
668
669 #[must_use]
671 pub(crate) fn shared_parsed_parts(
672 &self,
673 need_complexity: bool,
674 ) -> SharedParsedAnalysisSessionParts {
675 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
676 SharedParsedAnalysisSessionParts {
677 config: self.config.clone(),
678 files: self.discovery.files().to_vec(),
679 modules,
680 workspaces: self.workspaces.clone(),
681 workspace_diagnostics: self.current_workspace_diagnostics(),
682 parse_ms: metrics.parse_ms,
683 parse_cpu_ms: metrics.parse_cpu_ms,
684 }
685 }
686
687 #[doc(hidden)]
693 #[must_use]
694 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
695 self.parse_modules(need_complexity, None).modules
696 }
697
698 pub(crate) fn shared_parsed_modules_cancellable(
709 &self,
710 need_complexity: bool,
711 stage: &str,
712 ) -> EngineResult<Arc<[ModuleInfo]>> {
713 self.ensure_not_cancelled("parsing")?;
714 let modules = self
715 .parse_modules(need_complexity, self.cancellation.as_deref())
716 .modules;
717 self.ensure_not_cancelled(stage)?;
718 Ok(modules)
719 }
720
721 #[must_use]
724 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
725 let fingerprints = self
726 .warm_parse
727 .as_ref()
728 .and_then(|_| source_fingerprints_for_files(self.files()));
729 if let Some(warm) = self.warm_parse(need_complexity, fingerprints.as_deref(), None) {
730 return self.parsed_parts_from_modules(warm.modules.to_vec(), warm.metrics);
731 }
732 let ParsedModules {
733 modules, metrics, ..
734 } = parse_files_with_config(&self.config, self.files(), need_complexity, None);
735 self.parsed_parts_from_modules(modules, metrics)
736 }
737
738 fn parsed_parts_from_modules(
739 &self,
740 modules: Vec<ModuleInfo>,
741 metrics: core_backend::ParseMetrics,
742 ) -> ParsedAnalysisSessionParts {
743 ParsedAnalysisSessionParts {
744 config: self.config.clone(),
745 config_path: self.config_path.clone(),
746 files: self.discovery.files().to_vec(),
747 modules,
748 workspaces: self.workspaces.clone(),
749 workspace_diagnostics: self.current_workspace_diagnostics(),
750 parse_ms: metrics.parse_ms,
751 cache_update_ms: metrics.cache_ms,
752 cache_hits: metrics.cache_hits,
753 cache_misses: metrics.cache_misses,
754 parse_cpu_ms: metrics.parse_cpu_ms,
755 }
756 }
757
758 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
764 self.analyze_dead_code_with_artifacts(false, false)
765 .map(|output| DeadCodeAnalysis {
766 results: output.results,
767 })
768 }
769
770 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
776 self.analyze_dead_code_with_artifacts(true, false)
777 .map(|output| DeadCodeAnalysisOutput {
778 results: output.results,
779 modules: output.modules,
780 files: output.files,
781 })
782 }
783
784 pub fn analyze_dead_code_with_artifacts(
790 &self,
791 need_complexity: bool,
792 retain_graph: bool,
793 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
794 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
795 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
796 }
797
798 #[doc(hidden)]
808 pub fn analyze_dead_code_with_shared_artifacts(
809 &self,
810 need_complexity: bool,
811 retain_graph: bool,
812 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
813 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
814 }
815
816 pub fn analyze_dead_code_retaining_files(
823 &self,
824 need_complexity: bool,
825 retain_graph: bool,
826 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
827 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
828 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
829 }
830
831 pub fn analyze_dead_code_with_parsed_modules(
840 &self,
841 modules: &[ModuleInfo],
842 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
843 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
844 }
845
846 #[doc(hidden)]
852 pub(crate) fn analyze_dead_code_with_shared_modules(
853 &self,
854 modules: Arc<[ModuleInfo]>,
855 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
856 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
857 config: &self.config,
858 discovery: &self.discovery,
859 modules,
860 metrics: reused_parse_metrics(),
861 collect_usages: true,
862 retain_graph: true,
863 retain_modules: false,
864 retain_files: false,
865 cancellation: self.cancellation.as_deref(),
866 })
867 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
868 }
869
870 fn analyze_dead_code_with_reuse_artifacts(
871 &self,
872 need_complexity: bool,
873 retain_graph: bool,
874 retain_files: bool,
875 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
876 self.ensure_not_cancelled("parsing")?;
877 let SharedParsedModules { modules, metrics } =
878 self.parse_modules(need_complexity, self.cancellation.as_deref());
879 self.ensure_not_cancelled("the dead-code pipeline")?;
883 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
884 config: &self.config,
885 discovery: &self.discovery,
886 modules,
887 metrics,
888 collect_usages: true,
889 retain_graph,
890 retain_modules: need_complexity,
891 retain_files,
892 cancellation: self.cancellation.as_deref(),
893 })
894 }
895
896 pub fn analyze_dead_code_with_session_artifacts(
907 &self,
908 need_complexity: bool,
909 retain_graph: bool,
910 changed_files: Option<FxHashSet<PathBuf>>,
911 ) -> EngineResult<AnalysisSessionArtifacts> {
912 Ok(AnalysisSessionArtifacts {
913 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
914 changed_files,
915 source_fingerprints: self.source_fingerprints(),
916 })
917 }
918
919 pub fn analyze_project_with(
928 &self,
929 duplicates_config: &DuplicatesConfig,
930 retain_complexity_artifacts: bool,
931 ) -> EngineResult<ProjectAnalysisOutput> {
932 self.analyze_project_with_artifacts(
933 duplicates_config,
934 ProjectAnalysisArtifactOptions {
935 retain_complexity_artifacts,
936 ..ProjectAnalysisArtifactOptions::default()
937 },
938 )
939 .map(ProjectAnalysisArtifacts::into_output)
940 }
941
942 pub fn analyze_project_with_artifacts(
952 &self,
953 duplicates_config: &DuplicatesConfig,
954 options: ProjectAnalysisArtifactOptions,
955 ) -> EngineResult<ProjectAnalysisArtifacts> {
956 self.ensure_not_cancelled("duplication detection")?;
957 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
958 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
959 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
960 self.find_duplicates_touching_files_with_defaults(
961 duplicates_config,
962 &changed_files,
963 cache_dir,
964 )
965 .report
966 } else {
967 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
968 .report
969 };
970 self.ensure_not_cancelled("the dead-code half of project analysis")?;
974 let source_fingerprints = options
975 .collect_source_fingerprints
976 .then(|| self.source_fingerprints());
977 Ok(ProjectAnalysisArtifacts {
978 dead_code: self.analyze_dead_code_with_artifacts(
979 options.retain_complexity_artifacts,
980 options.retain_graph,
981 )?,
982 duplication,
983 changed_files: options.changed_files,
984 source_fingerprints,
985 })
986 }
987
988 #[must_use]
990 pub fn find_duplicates_with_defaults(
991 &self,
992 config: &DuplicatesConfig,
993 cache_dir: Option<&Path>,
994 ) -> DuplicationAnalysis {
995 duplicates::find_duplicates_with_defaults(
996 &self.config.root,
997 self.files(),
998 config,
999 cache_dir,
1000 )
1001 }
1002
1003 #[must_use]
1005 pub fn find_duplicates_touching_files_with_defaults(
1006 &self,
1007 config: &DuplicatesConfig,
1008 changed_files: &[PathBuf],
1009 cache_dir: Option<&Path>,
1010 ) -> DuplicationAnalysis {
1011 duplicates::find_duplicates_touching_files_with_defaults(
1012 &self.config.root,
1013 self.files(),
1014 config,
1015 changed_files,
1016 cache_dir,
1017 )
1018 }
1019
1020 fn parse_modules(
1026 &self,
1027 need_complexity: bool,
1028 cancellation: Option<&AtomicBool>,
1029 ) -> SharedParsedModules {
1030 let fingerprints = source_fingerprints_for_files(self.files());
1031 if let Some(fingerprints) = fingerprints.as_ref() {
1032 if let Some(modules) = self.cached_modules(need_complexity, fingerprints) {
1033 self.parse_counts.record(SessionParseCounts {
1034 modules_reused: modules.len(),
1035 ..SessionParseCounts::default()
1036 });
1037 return SharedParsedModules {
1038 modules,
1039 metrics: reused_parse_metrics(),
1040 };
1041 }
1042 if let Some(parsed) =
1043 self.reparse_changed_modules(need_complexity, fingerprints, cancellation)
1044 {
1045 return parsed;
1046 }
1047 }
1048
1049 let (modules, metrics, has_complexity, modules_reused, problems) = if let Some(warm) =
1050 self.warm_parse(need_complexity, fingerprints.as_deref(), cancellation)
1051 {
1052 let reused = if warm.reused { warm.modules.len() } else { 0 };
1053 (warm.modules, warm.metrics, true, reused, warm.problems)
1054 } else {
1055 let ParsedModules {
1056 modules,
1057 metrics,
1058 read_failures,
1059 parse_degradations,
1060 ..
1061 } = parse_files_with_config(&self.config, self.files(), need_complexity, cancellation);
1062 let problems = SourceProblems {
1063 read_failures,
1064 parse_degradations,
1065 };
1066 (modules.into(), metrics, need_complexity, 0, problems)
1067 };
1068 self.parse_counts.record(SessionParseCounts {
1069 modules_parsed: metrics.cache_misses,
1070 disk_cache_hits: metrics.cache_hits,
1071 modules_reused,
1072 });
1073 if !token_is_set(cancellation)
1077 && let Some(fingerprints) = fingerprints
1078 && let Ok(mut cache) = self.parsed_cache.lock()
1079 {
1080 *cache = Some(ParsedModuleCache {
1081 need_complexity: has_complexity,
1082 fingerprints,
1083 modules: Arc::clone(&modules),
1084 read_failures: problems.read_failures,
1085 parse_degradations: problems.parse_degradations,
1086 });
1087 self.disk_cache_stale.store(false, Ordering::SeqCst);
1089 self.clear_styling_cache();
1090 }
1091 SharedParsedModules { modules, metrics }
1092 }
1093
1094 fn warm_parse(
1107 &self,
1108 need_complexity: bool,
1109 fingerprints: Option<&[SourceFingerprint]>,
1110 cancellation: Option<&AtomicBool>,
1111 ) -> Option<WarmParsedModules> {
1112 let store = self.warm_parse.as_deref()?;
1113 if self.config.no_cache {
1114 return None;
1115 }
1116 let key = WarmParseKey {
1117 root: &self.config.root,
1118 cache_config_hash: self.config.cache_config_hash,
1119 files: self.files(),
1120 fingerprints: fingerprints?,
1121 };
1122 if !key.is_reusable() {
1123 return None;
1124 }
1125 if let Some(parse) = store.get(&key, need_complexity) {
1126 record_source_diagnostics(
1131 &self.config.root,
1132 &parse.read_failures,
1133 &parse.parse_degradations,
1134 );
1135 return Some(WarmParsedModules {
1136 modules: parse.modules,
1137 metrics: reused_parse_metrics(),
1138 reused: true,
1139 problems: SourceProblems {
1140 read_failures: parse.read_failures.to_vec(),
1141 parse_degradations: parse.parse_degradations.to_vec(),
1142 },
1143 });
1144 }
1145
1146 let parsed = parse_files_with_config(&self.config, self.files(), true, cancellation);
1147 store.record_parse(parsed.metrics.cache_misses, parsed.metrics.cache_hits);
1148 let modules: Arc<[ModuleInfo]> = parsed.modules.into();
1149 if !token_is_set(cancellation) {
1150 store.put(
1151 &key,
1152 true,
1153 WarmParse {
1154 modules: Arc::clone(&modules),
1155 read_failures: parsed.read_failures.clone().into(),
1156 parse_degradations: parsed.parse_degradations.clone().into(),
1157 },
1158 );
1159 }
1160 Some(WarmParsedModules {
1161 modules,
1162 metrics: parsed.metrics,
1163 reused: false,
1164 problems: SourceProblems {
1165 read_failures: parsed.read_failures,
1166 parse_degradations: parsed.parse_degradations,
1167 },
1168 })
1169 }
1170
1171 fn reparse_changed_modules(
1179 &self,
1180 need_complexity: bool,
1181 fingerprints: &[SourceFingerprint],
1182 cancellation: Option<&AtomicBool>,
1183 ) -> Option<SharedParsedModules> {
1184 let mut guard = self.parsed_cache.lock().ok()?;
1185 let cache = guard.as_mut()?;
1186 if need_complexity && !cache.need_complexity {
1187 return None;
1188 }
1189 let changed = changed_file_indices(&cache.fingerprints, fingerprints)?;
1190 if changed.is_empty() || changed.len() > MAX_INCREMENTAL_REPARSE_FILES {
1191 return None;
1192 }
1193 let files: Vec<DiscoveredFile> = changed
1194 .iter()
1195 .filter_map(|&index| self.files().get(index).cloned())
1196 .collect();
1197 let parse_start = Instant::now();
1198 let parsed = crate::source::parse_all_files(
1199 &files,
1200 None,
1201 cache.need_complexity,
1202 cancellation,
1203 &self.config.flags.patterns(),
1204 );
1205 if token_is_set(cancellation) {
1208 return Some(SharedParsedModules {
1209 modules: Arc::clone(&cache.modules),
1210 metrics: reused_parse_metrics(),
1211 });
1212 }
1213 let mut fresh = parsed.modules;
1214 for module in &mut fresh {
1215 module.prepare_analysis_facts();
1216 }
1217 let fresh_count = fresh.len();
1218 let reparsed: Vec<_> = files.iter().map(|file| file.id).collect();
1219 merge_reparsed_modules(&mut cache.modules, &reparsed, fresh);
1220 cache.fingerprints = fingerprints.to_vec();
1221 cache
1222 .read_failures
1223 .retain(|failure| !reparsed.contains(&failure.file_id));
1224 cache.read_failures.extend(parsed.read_failures);
1225 cache
1226 .parse_degradations
1227 .retain(|degradation| !reparsed.contains(°radation.file_id));
1228 cache.parse_degradations.extend(parsed.parse_degradations);
1229 record_source_diagnostics(
1230 &self.config.root,
1231 &cache.read_failures,
1232 &cache.parse_degradations,
1233 );
1234 self.parse_counts.record(SessionParseCounts {
1235 modules_parsed: parsed.cache_misses,
1236 disk_cache_hits: 0,
1237 modules_reused: cache.modules.len().saturating_sub(fresh_count),
1238 });
1239 let modules = Arc::clone(&cache.modules);
1240 drop(guard);
1241 self.disk_cache_stale.store(true, Ordering::SeqCst);
1242 self.clear_styling_cache();
1243 Some(SharedParsedModules {
1244 modules,
1245 metrics: core_backend::ParseMetrics {
1246 parse_ms: parse_start.elapsed().as_secs_f64() * 1000.0,
1247 cache_ms: 0.0,
1248 cache_hits: 0,
1249 cache_misses: parsed.cache_misses,
1250 parse_cpu_ms: parsed.parse_cpu_ms,
1251 cache_rejection: None,
1252 files_read: parsed.files_read,
1253 source_bytes_read: parsed.source_bytes_read,
1254 parse_cache_bytes_read: 0,
1255 css_masked_bytes: parsed.css_masked_bytes,
1256 parse_cache_load_ms: 0.0,
1257 },
1258 })
1259 }
1260
1261 fn cached_modules(
1262 &self,
1263 need_complexity: bool,
1264 fingerprints: &[SourceFingerprint],
1265 ) -> Option<Arc<[ModuleInfo]>> {
1266 let Ok(cache) = self.parsed_cache.lock() else {
1267 return None;
1268 };
1269 let cache = cache.as_ref()?;
1270 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
1271 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
1272 return Some(Arc::clone(&cache.modules));
1273 }
1274 None
1275 }
1276}
1277
1278struct ParsedModules {
1279 modules: Vec<ModuleInfo>,
1280 metrics: core_backend::ParseMetrics,
1281 source_diagnostics: Vec<WorkspaceDiagnostic>,
1282 read_failures: Vec<SourceReadFailure>,
1283 parse_degradations: Vec<SourceParseDegradation>,
1284}
1285
1286struct SharedParsedModules {
1287 modules: Arc<[ModuleInfo]>,
1288 metrics: core_backend::ParseMetrics,
1289}
1290
1291struct WarmParsedModules {
1293 modules: Arc<[ModuleInfo]>,
1294 metrics: core_backend::ParseMetrics,
1295 reused: bool,
1297 problems: SourceProblems,
1298}
1299
1300struct SourceProblems {
1302 read_failures: Vec<SourceReadFailure>,
1303 parse_degradations: Vec<SourceParseDegradation>,
1304}
1305
1306fn token_is_set(cancellation: Option<&AtomicBool>) -> bool {
1307 cancellation.is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
1308}
1309
1310fn parse_files_with_config(
1311 config: &ResolvedConfig,
1312 files: &[DiscoveredFile],
1313 need_complexity: bool,
1314 cancellation: Option<&AtomicBool>,
1315) -> ParsedModules {
1316 let parse_start = Instant::now();
1317 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1318 let mut cache_rejection = None;
1319 let mut parse_cache_bytes_read = 0;
1320 let mut parse_cache_load_ms = 0.0;
1321 let mut cache = if config.no_cache {
1322 None
1323 } else {
1324 let load_start = Instant::now();
1325 let (loaded, bytes_read) = fallow_extract::cache::CacheStore::load_counting_bytes(
1326 &config.cache_dir,
1327 &config.root,
1328 config.cache_config_hash,
1329 cache_max_size_bytes,
1330 );
1331 parse_cache_bytes_read = bytes_read;
1332 parse_cache_load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
1333 match loaded {
1334 Ok(store) => Some(store),
1335 Err(rejection) => {
1336 cache_rejection = Some(rejection);
1337 None
1338 }
1339 }
1340 };
1341 let parse_result = crate::source::parse_all_files(
1342 files,
1343 cache.as_ref(),
1344 need_complexity,
1345 cancellation,
1346 &config.flags.patterns(),
1347 );
1348 let source_diagnostics = record_source_diagnostics(
1349 &config.root,
1350 &parse_result.read_failures,
1351 &parse_result.parse_degradations,
1352 );
1353 let mut modules = parse_result.modules;
1354 for module in &mut modules {
1355 module.prepare_analysis_facts();
1356 }
1357 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
1358 let cache_ms = if token_is_set(cancellation) {
1359 0.0
1360 } else {
1361 update_parse_cache_if_enabled(config, &mut cache, &modules, files, need_complexity)
1362 };
1363 let metrics = core_backend::ParseMetrics {
1364 parse_ms,
1365 cache_ms,
1366 cache_hits: parse_result.cache_hits,
1367 cache_misses: parse_result.cache_misses,
1368 parse_cpu_ms: parse_result.parse_cpu_ms,
1369 cache_rejection,
1370 files_read: parse_result.files_read,
1371 source_bytes_read: parse_result.source_bytes_read,
1372 parse_cache_bytes_read,
1373 css_masked_bytes: parse_result.css_masked_bytes,
1374 parse_cache_load_ms,
1375 };
1376 ParsedModules {
1377 modules,
1378 metrics,
1379 source_diagnostics,
1380 read_failures: parse_result.read_failures,
1381 parse_degradations: parse_result.parse_degradations,
1382 }
1383}
1384
1385fn record_source_diagnostics(
1388 root: &Path,
1389 read_failures: &[SourceReadFailure],
1390 parse_degradations: &[SourceParseDegradation],
1391) -> Vec<WorkspaceDiagnostic> {
1392 let mut diagnostics = fallow_config::record_source_read_failures(root, read_failures);
1393 diagnostics.extend(fallow_config::record_source_parse_degradations(
1394 root,
1395 parse_degradations,
1396 ));
1397 diagnostics
1398}
1399
1400fn reused_parse_metrics() -> core_backend::ParseMetrics {
1401 core_backend::ParseMetrics {
1402 parse_ms: 0.0,
1403 cache_ms: 0.0,
1404 cache_hits: 0,
1405 cache_misses: 0,
1406 parse_cpu_ms: 0.0,
1407 cache_rejection: None,
1408 files_read: 0,
1409 source_bytes_read: 0,
1410 parse_cache_bytes_read: 0,
1411 css_masked_bytes: 0,
1412 parse_cache_load_ms: 0.0,
1413 }
1414}
1415
1416fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
1417 files
1418 .iter()
1419 .map(|file| {
1420 std::fs::metadata(&file.path)
1421 .ok()
1422 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
1423 .filter(|fingerprint| fingerprint.has_known_mtime())
1424 })
1425 .collect()
1426}
1427
1428fn update_parse_cache_if_enabled(
1429 config: &ResolvedConfig,
1430 cache: &mut Option<fallow_extract::cache::CacheStore>,
1431 modules: &[ModuleInfo],
1432 files: &[DiscoveredFile],
1433 need_complexity: bool,
1434) -> f64 {
1435 let start = Instant::now();
1436 write_parse_cache(
1437 config,
1438 cache,
1439 &ParseCacheWrite {
1440 modules,
1441 files,
1442 need_complexity,
1443 fingerprint_of: &|file: &DiscoveredFile| source_fingerprint(&file.path),
1444 },
1445 );
1446 start.elapsed().as_secs_f64() * 1000.0
1447}
1448
1449struct ParseCacheWrite<'a> {
1452 modules: &'a [ModuleInfo],
1453 files: &'a [DiscoveredFile],
1454 need_complexity: bool,
1455 fingerprint_of: &'a dyn Fn(&DiscoveredFile) -> SourceFingerprint,
1456}
1457
1458fn write_parse_cache(
1459 config: &ResolvedConfig,
1460 cache: &mut Option<fallow_extract::cache::CacheStore>,
1461 write: &ParseCacheWrite<'_>,
1462) {
1463 if config.no_cache {
1464 return;
1465 }
1466
1467 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1468 let store = cache.get_or_insert_with(|| fallow_extract::cache::CacheStore::new(&config.root));
1469 if update_parse_cache(store, write)
1470 && let Err(error) = store.save(
1471 &config.cache_dir,
1472 config.cache_config_hash,
1473 cache_max_size_bytes,
1474 )
1475 {
1476 tracing::warn!("Failed to save cache: {error}");
1477 }
1478}
1479
1480fn update_parse_cache(
1485 store: &mut fallow_extract::cache::CacheStore,
1486 write: &ParseCacheWrite<'_>,
1487) -> bool {
1488 let ParseCacheWrite {
1489 modules,
1490 files,
1491 need_complexity,
1492 fingerprint_of,
1493 } = *write;
1494 let mut dirty = false;
1495 for module in modules {
1496 if let Some(file) = files.get(module.file_id.0 as usize) {
1497 let fingerprint = fingerprint_of(file);
1498 if let Some(cached) = store.get_by_path_only(&file.path)
1499 && cached.content_hash == module.content_hash
1500 {
1501 let stale_metadata = cached.source_fingerprint() != fingerprint;
1502 let adds_complexity = need_complexity && !cached.complexity_extracted;
1503 if stale_metadata || adds_complexity {
1504 let preserved_last_access = cached.last_access_secs;
1505 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
1506 .then(|| cached.complexity.clone());
1507 let mut refreshed = fallow_extract::cache::module_to_cached(
1508 module,
1509 fingerprint,
1510 need_complexity,
1511 );
1512 refreshed.last_access_secs = preserved_last_access;
1513 if let Some(complexity) = preserved_complexity {
1514 refreshed.complexity = complexity;
1515 refreshed.complexity_extracted = true;
1516 }
1517 store.insert(&file.path, refreshed);
1518 dirty = true;
1519 }
1520 continue;
1521 }
1522 store.insert(
1523 &file.path,
1524 fallow_extract::cache::module_to_cached(module, fingerprint, need_complexity),
1525 );
1526 dirty = true;
1527 }
1528 }
1529 store.retain_paths(files) || dirty
1530}
1531
1532fn source_fingerprint(path: &Path) -> SourceFingerprint {
1533 std::fs::metadata(path).map_or_else(
1534 |_| SourceFingerprint::new(0, 0),
1535 |metadata| SourceFingerprint::from_metadata(&metadata),
1536 )
1537}
1538
1539struct EngineDeadCodePipelineInput<'a> {
1540 config: &'a ResolvedConfig,
1541 discovery: &'a crate::discover::AnalysisDiscovery,
1542 modules: Arc<[ModuleInfo]>,
1543 metrics: core_backend::ParseMetrics,
1544 collect_usages: bool,
1545 retain_graph: bool,
1546 retain_modules: bool,
1547 retain_files: bool,
1548 cancellation: Option<&'a AtomicBool>,
1549}
1550
1551fn run_engine_owned_dead_code_pipeline(
1552 input: EngineDeadCodePipelineInput<'_>,
1553) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
1554 let EngineDeadCodePipelineInput {
1555 config,
1556 discovery,
1557 modules,
1558 metrics,
1559 collect_usages,
1560 retain_graph,
1561 retain_modules,
1562 retain_files,
1563 cancellation,
1564 } = input;
1565 let stopped = |stage: &str| -> EngineResult<()> {
1566 if token_is_set(cancellation) {
1567 return Err(crate::EngineError::cancelled(stage));
1568 }
1569 Ok(())
1570 };
1571 stopped("the dead-code prelude")?;
1572 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
1573 let prelude_timings = prelude.timings();
1574 stopped("dead-code entry-point discovery")?;
1575 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
1576 stopped("import resolution and graph construction")?;
1577 let (resolved, graph, graph_cache_rejection) =
1578 resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
1579 stopped("the dead-code detectors")?;
1580
1581 let mut detector = core_backend::run_dead_code_detectors(
1582 &prelude,
1583 &graph.graph,
1584 &resolved.project.modules,
1585 &modules,
1586 collect_usages,
1587 &entry_points,
1588 );
1589 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1590 stopped("assembling the dead-code report")?;
1595 let profile =
1596 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1597 retain_timings: retain_graph,
1598 prelude: &prelude,
1599 prelude_timings,
1600 parse_metrics: metrics,
1601 module_count: modules.len(),
1602 entry_points: &entry_points,
1603 resolved: &resolved,
1604 graph: &graph,
1605 detector: &detector,
1606 file_count: discovery.files().len(),
1607 workspace_count: discovery.workspaces().len(),
1608 graph_cache_rejection,
1609 });
1610 let script_used_packages = prelude.script_used_packages();
1611 let trace_provenance = prelude.trace_provenance(&modules);
1612 prelude.finish();
1613 let file_hashes = collect_file_hashes(&modules, discovery.files());
1614
1615 Ok(SharedDeadCodeAnalysisArtifacts {
1616 results: detector.results,
1617 timings: profile.timings,
1618 graph: retain_graph.then_some(graph.graph),
1619 modules: retain_modules.then_some(modules),
1620 files: retain_files.then(|| discovery.files().to_vec()),
1621 script_used_packages,
1622 trace_provenance,
1623 file_hashes,
1624 })
1625}
1626
1627fn resolve_or_build_dead_code_graph(
1635 prelude: &core_backend::DeadCodeBackendPrelude,
1636 entry_points: &core_backend::DeadCodeEntryPoints,
1637 modules: &[ModuleInfo],
1638) -> (
1639 core_backend::DeadCodeResolvedModules,
1640 core_backend::DeadCodeGraphRun,
1641 Option<CacheRejection>,
1642) {
1643 let rejection =
1644 match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1645 Ok((resolved, graph)) => return (resolved, graph, None),
1646 Err(rejection) => rejection,
1647 };
1648
1649 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1650 let graph =
1651 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1652 (resolved, graph, rejection)
1653}
1654
1655fn collect_file_hashes(
1656 modules: &[ModuleInfo],
1657 files: &[DiscoveredFile],
1658) -> FxHashMap<PathBuf, u64> {
1659 modules
1660 .iter()
1661 .filter_map(|module| {
1662 files
1663 .get(module.file_id.0 as usize)
1664 .map(|file| (file.path.clone(), module.content_hash))
1665 })
1666 .collect()
1667}
1668
1669pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1670 config: &ResolvedConfig,
1671 modules: &[ModuleInfo],
1672) -> EngineResult<DeadCodeAnalysisArtifacts> {
1673 let (workspaces, _diagnostics, workspaces_ms) =
1674 crate::project_config::collect_workspace_metadata(config)?;
1675 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1676 config,
1677 &workspaces,
1678 workspaces_ms,
1679 );
1680 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1681 config,
1682 discovery: &discovery,
1683 modules: Arc::from(modules),
1684 metrics: reused_parse_metrics(),
1685 collect_usages: true,
1686 retain_graph: true,
1687 retain_modules: false,
1688 retain_files: false,
1689 cancellation: None,
1690 })
1691 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1692}
1693
1694#[cfg(test)]
1695mod tests {
1696 use std::fmt::Write as _;
1697 use std::time::Duration;
1698
1699 use super::*;
1700
1701 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1702 let project = tempfile::tempdir().expect("project");
1703 let root = project.path();
1704 std::fs::create_dir(root.join("src")).expect("create source directory");
1705 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1706 let session = AnalysisSession::load_default(root);
1707 (project, session)
1708 }
1709
1710 #[test]
1713 fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
1714 let project = tempfile::tempdir().expect("project");
1715 let root = project.path();
1716 std::fs::create_dir(root.join("src")).expect("create source directory");
1717 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1718 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
1719
1720 let baseline = AnalysisSession::load_default(root)
1721 .analyze_dead_code()
1722 .expect("an uncancelled session analyzes");
1723 assert!(
1724 !baseline.results.unused_files.is_empty(),
1725 "the fixture must have findings, so an empty result would be a plausible wrong answer"
1726 );
1727
1728 let error = AnalysisSession::load_default(root)
1729 .with_cancellation(Arc::new(AtomicBool::new(true)))
1730 .analyze_dead_code()
1731 .expect_err("a cancelled session must not return results");
1732 assert!(error.is_cancelled(), "unexpected error: {error}");
1733 assert!(error.message().contains("cancelled"));
1734 }
1735
1736 #[test]
1742 fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
1743 let project = tempfile::tempdir().expect("project");
1744 let root = project.path();
1745 std::fs::create_dir(root.join("src")).expect("create source directory");
1746 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1747
1748 let cold = AnalysisSession::load_default(root)
1749 .analyze_dead_code_with_artifacts(false, true)
1750 .expect("cold run analyzes");
1751 assert_eq!(
1752 cold.timings
1753 .expect("cold timings retained")
1754 .graph_cache_rejection,
1755 Some(CacheRejection::Absent),
1756 "a first run has no persisted graph to refuse"
1757 );
1758
1759 std::fs::write(
1760 root.join("src/index.ts"),
1761 "export const entry = 1;\nexport const added = 2;\n",
1762 )
1763 .expect("edit the entry");
1764
1765 let warm = AnalysisSession::load_default(root)
1766 .analyze_dead_code_with_artifacts(false, true)
1767 .expect("warm run analyzes");
1768 assert_eq!(
1769 warm.timings
1770 .expect("warm timings retained")
1771 .graph_cache_rejection,
1772 Some(CacheRejection::FingerprintChanged),
1773 "the decoded graph was refused because a file changed, and the run must say so"
1774 );
1775 }
1776
1777 #[test]
1780 fn a_session_without_a_token_is_never_cancelled() {
1781 let (_project, session) = session_with_source("export const unused = 1;\n");
1782 assert!(!session.is_cancelled());
1783 session
1784 .analyze_dead_code()
1785 .expect("a session without a token analyzes");
1786 }
1787
1788 const PARSE_LOOP_FILES: usize = 400;
1792
1793 fn parse_loop_project() -> tempfile::TempDir {
1794 let project = tempfile::tempdir().expect("project");
1795 let src = project.path().join("src");
1796 std::fs::create_dir_all(&src).expect("src dir");
1797 for module in 0..PARSE_LOOP_FILES {
1798 let mut source = String::new();
1799 for symbol in 0..20 {
1800 let _ = writeln!(
1801 source,
1802 "export const helper{symbol} = (input: number): number => {{\n \
1803 if (input > {symbol}) {{\n return input * {symbol};\n }}\n \
1804 return input - {symbol};\n}};"
1805 );
1806 }
1807 std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
1808 }
1809 project
1810 }
1811
1812 #[cfg(unix)]
1819 #[test]
1820 fn a_refresh_drops_modules_whose_fingerprints_need_a_content_check() {
1821 let (_project, mut session) = session_with_source("export const kept = 1;\n");
1822 drop(session.parse_modules(false, None));
1823
1824 assert!(!session.refresh_discovery(), "the file set is the same");
1825 assert!(
1826 session.parsed_cache.lock().expect("parse cache").is_some(),
1827 "fingerprints with a known ctime keep the modules for the next run"
1828 );
1829
1830 if let Some(cache) = session
1831 .parsed_cache
1832 .get_mut()
1833 .expect("parse cache")
1834 .as_mut()
1835 {
1836 for fingerprint in &mut cache.fingerprints {
1837 fingerprint.ctime_ns = 0;
1838 }
1839 }
1840 assert!(!session.refresh_discovery(), "the file set is the same");
1841 assert!(
1842 session.parsed_cache.lock().expect("parse cache").is_none(),
1843 "the next run parses through the persisted cache, which checks the content"
1844 );
1845 }
1846
1847 fn uncached_session(root: &Path) -> AnalysisSession {
1850 let mut project_config = crate::project_config::default_project_config(root);
1851 project_config.config.no_cache = true;
1852 AnalysisSession::from_config(project_config)
1853 }
1854
1855 #[test]
1864 fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
1865 let project = parse_loop_project();
1866 let root = project.path();
1867
1868 drop(uncached_session(root).parse_modules(false, None));
1870
1871 let started = Instant::now();
1872 let full = uncached_session(root).parse_modules(false, None);
1873 let full_parse = started.elapsed();
1874 let full_count = full.modules.len();
1875 assert_eq!(
1876 full_count, PARSE_LOOP_FILES,
1877 "the fixture must parse every generated module"
1878 );
1879 assert!(
1880 full_parse >= Duration::from_millis(20),
1881 "the fixture is too small to cancel part way through: {full_parse:?}"
1882 );
1883
1884 let mut partial = None;
1889 let mut cancelled_session = None;
1890 for attempt in 1..=6_u32 {
1891 let token = Arc::new(AtomicBool::new(false));
1892 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
1896 let watchdog = {
1897 let token = Arc::clone(&token);
1898 let delay = full_parse * attempt / 6;
1899 std::thread::spawn(move || {
1900 std::thread::sleep(delay);
1901 token.store(true, Ordering::SeqCst);
1902 })
1903 };
1904 let cancelled = session.parse_modules(false, Some(&token));
1905 watchdog.join().expect("watchdog thread");
1906
1907 let parsed = cancelled.modules.len();
1908 assert!(
1909 parsed < full_count,
1910 "the parse returned all {full_count} modules, so the loop never read the token"
1911 );
1912 cancelled_session = Some(session);
1913 if parsed > 0 {
1914 partial = Some(parsed);
1915 break;
1916 }
1917 }
1918 let parsed = partial.expect(
1919 "no attempt flipped the token while the loop was running, so this never measured a \
1920 stop part way through",
1921 );
1922 assert!(parsed < full_count);
1923
1924 assert!(
1925 cancelled_session
1926 .expect("a cancelled session")
1927 .parsed_cache
1928 .lock()
1929 .expect("parse cache")
1930 .is_none(),
1931 "a truncated parse must not be retained as this session's warm cache"
1932 );
1933 let recovered = uncached_session(root).parse_modules(false, None);
1934 assert_eq!(
1935 recovered.modules.len(),
1936 full_count,
1937 "a later uncancelled parse must still see the whole project"
1938 );
1939 }
1940
1941 #[test]
1942 fn session_retains_workspace_metadata_from_config_load() {
1943 let project = tempfile::tempdir().expect("project");
1944 let root = project.path();
1945 std::fs::write(
1946 root.join("package.json"),
1947 r#"{"name":"root","workspaces":["packages/*"]}"#,
1948 )
1949 .expect("write root package");
1950 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1951 std::fs::write(
1952 root.join("packages/a/package.json"),
1953 r#"{"name":"pkg-a","type":"module"}"#,
1954 )
1955 .expect("write workspace package");
1956
1957 let session = AnalysisSession::load(root, None).expect("session loads");
1958
1959 assert!(
1960 session
1961 .workspaces()
1962 .iter()
1963 .any(|workspace| workspace.name == "pkg-a"),
1964 "session must retain workspace metadata discovered during config load"
1965 );
1966 }
1967
1968 #[test]
1969 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1970 let project = tempfile::tempdir().expect("project");
1971 let root = project.path();
1972 std::fs::create_dir(root.join("src")).expect("create source directory");
1973 std::fs::write(
1974 root.join("package.json"),
1975 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1976 )
1977 .expect("write package manifest");
1978 std::fs::write(
1979 root.join("vitest.config.ts"),
1980 "import './src/feature';\nexport default {};\n",
1981 )
1982 .expect("write vitest config");
1983 std::fs::write(
1984 root.join("src/feature.ts"),
1985 "export const feature = true;\n",
1986 )
1987 .expect("write reachable source");
1988 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
1989 .expect("write hidden source");
1990
1991 let unfiltered = AnalysisSession::load(root, None)
1992 .expect("unfiltered session loads")
1993 .analyze_dead_code()
1994 .expect("unfiltered analysis succeeds");
1995 assert!(
1996 unfiltered
1997 .results
1998 .unused_files
1999 .iter()
2000 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
2001 );
2002
2003 std::fs::write(
2004 root.join(".fallowrc.json"),
2005 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
2006 )
2007 .expect("write fallow config");
2008 let session = AnalysisSession::load(root, None).expect("filtered session loads");
2009 let hidden_path = root.join("src/hidden.ts");
2010 assert!(session.files().iter().any(|file| file.path == hidden_path));
2011
2012 let filtered = session
2013 .analyze_dead_code_with_artifacts(false, true)
2014 .expect("filtered analysis succeeds");
2015 assert!(
2016 filtered
2017 .results
2018 .unused_files
2019 .iter()
2020 .all(|finding| finding.file.path != hidden_path)
2021 );
2022 assert!(
2023 filtered
2024 .graph
2025 .as_ref()
2026 .is_some_and(|graph| graph.module_count() == session.files().len())
2027 );
2028 }
2029
2030 #[test]
2031 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
2032 use fallow_types::output_dead_code::UnusedFileFinding;
2033 use fallow_types::results::UnusedFile;
2034
2035 let project = tempfile::tempdir().expect("project");
2036 let config = serde_json::from_str::<fallow_config::FallowConfig>(
2037 r#"{"ignoreFindings":["**/*.ts"]}"#,
2038 )
2039 .expect("config parses")
2040 .resolve(
2041 project.path().to_path_buf(),
2042 fallow_config::OutputFormat::Human,
2043 1,
2044 true,
2045 true,
2046 None,
2047 );
2048 let outside = project
2049 .path()
2050 .parent()
2051 .expect("project has parent")
2052 .join("outside.ts");
2053 let mut results = AnalysisResults {
2054 unused_files: vec![
2055 UnusedFileFinding::with_actions(UnusedFile {
2056 path: PathBuf::from(r"src\hidden.ts"),
2057 }),
2058 UnusedFileFinding::with_actions(UnusedFile {
2059 path: outside.clone(),
2060 }),
2061 ],
2062 ..AnalysisResults::default()
2063 };
2064
2065 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
2066
2067 assert_eq!(results.unused_files.len(), 1);
2068 assert_eq!(results.unused_files[0].file.path, outside);
2069 }
2070
2071 #[cfg(unix)]
2074 fn warm_store_project() -> tempfile::TempDir {
2075 let project = tempfile::tempdir().expect("project");
2076 let root = project.path();
2077 std::fs::create_dir(root.join("src")).expect("create source directory");
2078 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2079 std::fs::write(root.join("src/other.ts"), "export const other = 2;\n").expect("other");
2080 project
2081 }
2082
2083 #[cfg(unix)]
2084 fn warm_session(root: &Path, store: &Arc<WarmParseStore>) -> AnalysisSession {
2085 AnalysisSession::load_default(root).with_warm_parse(Some(Arc::clone(store)))
2086 }
2087
2088 #[cfg(unix)]
2089 fn exported_names(modules: &[ModuleInfo]) -> Vec<String> {
2090 let mut names: Vec<String> = modules
2091 .iter()
2092 .flat_map(|module| module.exports.iter().map(|export| export.name.to_string()))
2093 .collect();
2094 names.sort();
2095 names
2096 }
2097
2098 #[cfg(unix)]
2099 #[test]
2100 fn a_warm_store_serves_a_new_session_without_parse_work() {
2101 let project = warm_store_project();
2102 let store = Arc::new(WarmParseStore::new(
2103 crate::warm_parse::WarmParseLimits::default(),
2104 ));
2105
2106 let first = warm_session(project.path(), &store).parse_modules(false, None);
2107 let second = warm_session(project.path(), &store).parse_modules(false, None);
2108
2109 assert!(Arc::ptr_eq(&first.modules, &second.modules));
2110 assert_eq!(second.metrics.cache_hits + second.metrics.cache_misses, 0);
2111 let counts = store.counts();
2112 assert_eq!(counts.parse_runs, 1);
2113 assert_eq!(counts.modules_parsed, 2);
2114 assert_eq!(counts.modules_reused, 2);
2115 }
2116
2117 #[cfg(unix)]
2118 #[test]
2119 fn a_warm_store_serves_the_health_parse_of_a_new_session() {
2120 let project = warm_store_project();
2121 let store = Arc::new(WarmParseStore::new(
2122 crate::warm_parse::WarmParseLimits::default(),
2123 ));
2124
2125 drop(warm_session(project.path(), &store).parse_modules(false, None));
2126 let health = warm_session(project.path(), &store).parsed_parts_uncached(true);
2127
2128 assert_eq!(health.modules.len(), 2);
2129 assert_eq!(health.cache_hits + health.cache_misses, 0);
2130 assert_eq!(
2131 store.counts().parse_runs,
2132 1,
2133 "the first parse computed complexity"
2134 );
2135 }
2136
2137 #[cfg(unix)]
2138 #[test]
2139 fn an_edit_or_a_new_file_makes_the_next_session_parse_again() {
2140 let project = warm_store_project();
2141 let root = project.path();
2142 let store = Arc::new(WarmParseStore::new(
2143 crate::warm_parse::WarmParseLimits::default(),
2144 ));
2145 drop(warm_session(root, &store).parse_modules(false, None));
2146
2147 std::fs::write(root.join("src/other.ts"), "export const renamed = 22;\n").expect("edit");
2148 let edited = warm_session(root, &store).parse_modules(false, None);
2149 assert_eq!(exported_names(&edited.modules), ["entry", "renamed"]);
2150 assert_eq!(
2151 edited.metrics.cache_misses, 1,
2152 "only the edited file is parsed"
2153 );
2154
2155 std::fs::write(root.join("src/added.ts"), "export const added = 3;\n").expect("add");
2156 let added = warm_session(root, &store).parse_modules(false, None);
2157 assert_eq!(
2158 exported_names(&added.modules),
2159 ["added", "entry", "renamed"]
2160 );
2161
2162 let counts = store.counts();
2163 assert_eq!(counts.parse_runs, 3);
2164 assert_eq!(counts.modules_reused, 0);
2165 assert_eq!(store.len(), 2, "each file list keeps its latest parse only");
2166 }
2167
2168 #[cfg(unix)]
2169 #[test]
2170 fn a_session_without_the_persisted_cache_does_not_use_the_store() {
2171 let project = warm_store_project();
2172 let store = Arc::new(WarmParseStore::new(
2173 crate::warm_parse::WarmParseLimits::default(),
2174 ));
2175
2176 drop(
2177 uncached_session(project.path())
2178 .with_warm_parse(Some(Arc::clone(&store)))
2179 .parse_modules(false, None),
2180 );
2181
2182 assert_eq!(
2183 store.counts(),
2184 crate::warm_parse::WarmParseCounts::default()
2185 );
2186 assert!(store.is_empty());
2187 }
2188
2189 #[cfg(unix)]
2193 #[test]
2194 fn a_kept_parse_records_its_read_failures_again() {
2195 let project = warm_store_project();
2196 let root = project.path();
2197 std::fs::write(root.join("src/broken.ts"), [0xff, 0xfe, 0x00]).expect("invalid UTF-8");
2198 let store = Arc::new(WarmParseStore::new(
2199 crate::warm_parse::WarmParseLimits::default(),
2200 ));
2201 let read_failures = |session: &AnalysisSession| {
2202 session
2203 .current_workspace_diagnostics()
2204 .into_iter()
2205 .filter(|diagnostic| {
2206 matches!(
2207 diagnostic.kind,
2208 fallow_types::workspace::WorkspaceDiagnosticKind::SourceReadFailure { .. }
2209 )
2210 })
2211 .count()
2212 };
2213
2214 let first = warm_session(root, &store);
2215 drop(first.parse_modules(false, None));
2216 assert_eq!(read_failures(&first), 1);
2217
2218 drop(fallow_config::record_source_read_failures(root, &[]));
2219 let second = warm_session(root, &store);
2220 drop(second.parse_modules(false, None));
2221 assert_eq!(store.counts().modules_reused, 2);
2222 assert_eq!(read_failures(&second), 1);
2223 }
2224
2225 #[test]
2226 fn warm_parse_cache_reuses_module_storage() {
2227 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2228 let first = session.parse_modules(true, None);
2229 let second = session.parse_modules(false, None);
2230
2231 assert!(
2232 Arc::ptr_eq(&first.modules, &second.modules),
2233 "warm session queries must share parsed module storage"
2234 );
2235 }
2236
2237 #[test]
2238 fn warm_styling_cache_reuses_artifact_allocation() {
2239 let project = tempfile::tempdir().expect("project");
2240 let root = project.path();
2241 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
2242 .expect("write stylesheet");
2243 let session = AnalysisSession::load_default(root);
2244
2245 let first = session.styling_analysis_artifacts();
2246 let second = session.styling_analysis_artifacts();
2247
2248 assert!(
2249 Arc::ptr_eq(&first, &second),
2250 "warm styling queries must share the cached artifact allocation"
2251 );
2252 }
2253
2254 #[test]
2255 fn shared_parsed_modules_reuse_public_session_storage() {
2256 let (_project, session) = session_with_source("export const value = 1;\n");
2257 let first = session.shared_parsed_modules(true);
2258 let second = session.shared_parsed_modules(false);
2259
2260 assert!(Arc::ptr_eq(&first, &second));
2261 }
2262
2263 #[test]
2264 fn shared_parsed_parts_reuse_public_session_storage() {
2265 let (_project, session) = session_with_source("export const value = 1;\n");
2266 let cached = session.shared_parsed_modules(true);
2267 let parts = session.shared_parsed_parts(false);
2268
2269 assert!(Arc::ptr_eq(&cached, &parts.modules));
2270 }
2271
2272 #[test]
2273 fn warm_complexity_artifacts_reuse_cached_module_storage() {
2274 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2275 let cached = session.parse_modules(true, None);
2276 let artifacts = session
2277 .analyze_dead_code_with_reuse_artifacts(true, true, false)
2278 .expect("analysis succeeds");
2279 let retained = artifacts.modules.expect("complexity modules retained");
2280
2281 assert!(
2282 Arc::ptr_eq(&cached.modules, &retained),
2283 "warm complexity artifacts must share parsed module storage"
2284 );
2285 }
2286
2287 #[test]
2288 fn shared_and_owned_artifacts_preserve_output_bytes() {
2289 let (_project, session) = session_with_source(
2290 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
2291 );
2292 let owned = session
2293 .analyze_dead_code_with_artifacts(true, true)
2294 .expect("owned analysis succeeds");
2295 let shared = session
2296 .analyze_dead_code_with_shared_artifacts(true, true)
2297 .expect("shared analysis succeeds");
2298
2299 assert_eq!(
2300 serde_json::to_vec(&owned.results).expect("serialize owned results"),
2301 serde_json::to_vec(&shared.results).expect("serialize shared results")
2302 );
2303 assert_eq!(owned.file_hashes, shared.file_hashes);
2304 assert_eq!(
2305 owned
2306 .modules
2307 .as_deref()
2308 .unwrap_or_default()
2309 .iter()
2310 .map(|module| module.content_hash)
2311 .collect::<Vec<_>>(),
2312 shared
2313 .modules
2314 .as_deref()
2315 .unwrap_or_default()
2316 .iter()
2317 .map(|module| module.content_hash)
2318 .collect::<Vec<_>>()
2319 );
2320 }
2321
2322 #[test]
2323 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
2324 let project = tempfile::tempdir().expect("project");
2325 let root = project.path();
2326 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
2327 std::fs::write(
2328 root.join("package.json"),
2329 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
2330 )
2331 .expect("write package manifest");
2332 std::fs::write(
2333 root.join("app/routes/home.tsx"),
2334 r#"
2335import { useLoaderData } from "react-router";
2336export function loader() { return { opaque: "value" }; }
2337export default function Home() {
2338 const data = useLoaderData<typeof loader>();
2339 const copy = { ...data };
2340 return JSON.stringify(copy);
2341}
2342"#,
2343 )
2344 .expect("write route module");
2345
2346 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2347 let cold_parse = cold_session.parsed_parts(false);
2348 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
2349 let cold = cold_session
2350 .analyze_dead_code()
2351 .expect("cold analysis succeeds");
2352
2353 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2354 let warm_parse = warm_session.parsed_parts(false);
2355 assert!(
2356 warm_parse.cache_hits > 0,
2357 "second session must use disk cache"
2358 );
2359 let warm = warm_session
2360 .analyze_dead_code()
2361 .expect("warm analysis succeeds");
2362
2363 assert!(
2364 cold.results.unused_load_data_keys.is_empty(),
2365 "cold analysis must abstain for an opaque route-loader use"
2366 );
2367 assert_eq!(
2368 serde_json::to_vec(&cold.results).expect("serialize cold results"),
2369 serde_json::to_vec(&warm.results).expect("serialize warm results"),
2370 "warm route-loader analysis must match cold analysis"
2371 );
2372 }
2373
2374 #[test]
2375 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
2376 let project = tempfile::tempdir().expect("project");
2377 let root = project.path();
2378 std::fs::create_dir(root.join("src")).expect("create source directory");
2379 std::fs::write(
2380 root.join("package.json"),
2381 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
2382 )
2383 .expect("write package manifest");
2384 std::fs::write(
2385 root.join("src/dependency.ts"),
2386 "export function dependency() { return 'real'; }\n",
2387 )
2388 .expect("write dependency");
2389 std::fs::write(
2390 root.join("src/wrapper.ts"),
2391 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
2392 )
2393 .expect("write wrapper");
2394 std::fs::write(
2395 root.join("src/index.ts"),
2396 "export { wrapper } from './wrapper';\n",
2397 )
2398 .expect("write entry point");
2399 std::fs::write(
2400 root.join("src/wrapper.test.ts"),
2401 r#"
2402import { vi } from "vitest";
2403vi.mock("./dependency", () => ({ dependency: () => "mock" }));
2404import { wrapper } from "./wrapper";
2405wrapper();
2406"#,
2407 )
2408 .expect("write test");
2409
2410 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2411 let dependency_id = cold_session
2412 .files()
2413 .iter()
2414 .find(|file| file.path == root.join("src/dependency.ts"))
2415 .expect("dependency discovered")
2416 .id;
2417 let cold = cold_session
2418 .analyze_dead_code_with_artifacts(false, true)
2419 .expect("cold analysis succeeds");
2420 let cold_exports = crate::module_graph::module_value_exports(
2421 cold.graph.as_ref().expect("cold graph retained"),
2422 );
2423 assert!(
2424 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
2425 "cold analysis must persist the graph cache"
2426 );
2427
2428 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2429 let warm = warm_session
2430 .analyze_dead_code_with_artifacts(false, true)
2431 .expect("warm analysis succeeds");
2432 let warm_exports = crate::module_graph::module_value_exports(
2433 warm.graph.as_ref().expect("warm graph retained"),
2434 );
2435
2436 let dependency = cold_exports
2437 .iter()
2438 .find(|export| export.file_id == dependency_id && export.name == "dependency")
2439 .expect("dependency export retained");
2440 assert!(!dependency.test_referenced);
2441 assert_eq!(warm_exports, cold_exports);
2442 }
2443
2444 #[test]
2445 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
2446 let project = tempfile::tempdir().expect("project");
2447 let root = project.path();
2448 std::fs::create_dir(root.join("src")).expect("create source directory");
2449 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
2450 .expect("write package manifest");
2451 for name in ["a.ts", "b.ts", "c.ts"] {
2452 std::fs::write(
2453 root.join("src").join(name),
2454 format!("export const {} = 1;\n", name.replace('.', "_")),
2455 )
2456 .expect("write source");
2457 }
2458 let session = AnalysisSession::load(root, None).expect("session loads");
2459 let removed_path = root.join("src/b.ts");
2460 let removed_id = session
2461 .files()
2462 .iter()
2463 .find(|file| file.path == removed_path)
2464 .expect("removed source discovered")
2465 .id;
2466 std::fs::remove_file(&removed_path).expect("remove source after discovery");
2467
2468 let parts = session.parsed_parts(false);
2469
2470 assert!(
2471 parts
2472 .modules
2473 .iter()
2474 .all(|module| module.file_id != removed_id),
2475 "unreadable file must not receive a placeholder module"
2476 );
2477 let diagnostic = parts
2478 .workspace_diagnostics
2479 .iter()
2480 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2481 .expect("parsed session parts carry source read failure");
2482 assert_eq!(diagnostic.path, removed_path);
2483 assert!(
2484 session
2485 .current_workspace_diagnostics()
2486 .iter()
2487 .any(|diagnostic| {
2488 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
2489 }),
2490 "session output carries parse-time source diagnostics"
2491 );
2492 }
2493
2494 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
2495 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
2496 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
2497
2498 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
2499 diagnostics
2500 .iter()
2501 .any(|diagnostic| diagnostic.kind.id() == id)
2502 }
2503
2504 fn write_single_source_project(root: &Path, manifest: &str) {
2505 std::fs::create_dir(root.join("src")).expect("create source directory");
2506 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
2507 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
2508 .expect("write source");
2509 }
2510
2511 #[test]
2517 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
2518 let project = tempfile::tempdir().expect("project");
2519 let root = project.path();
2520 write_single_source_project(
2521 root,
2522 r#"{"name":"issue-2366-engine-session","private":true}"#,
2523 );
2524 std::fs::write(
2525 root.join("pnpm-workspace.yaml"),
2526 MALFORMED_PNPM_WORKSPACE_YAML,
2527 )
2528 .expect("write malformed workspace yaml");
2529
2530 let broken = AnalysisSession::load(root, None).expect("session loads");
2531 broken
2532 .analyze_dead_code()
2533 .expect("analysis on the malformed yaml succeeds");
2534 assert!(
2535 has_diagnostic_kind(
2536 &broken.current_workspace_diagnostics(),
2537 "malformed-pnpm-workspace-yaml"
2538 ),
2539 "the first session surfaces the malformed yaml: {:?}",
2540 broken.current_workspace_diagnostics()
2541 );
2542
2543 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
2544 .expect("fix workspace yaml");
2545
2546 let fixed = AnalysisSession::load(root, None).expect("session loads");
2547 fixed
2548 .analyze_dead_code()
2549 .expect("analysis on the fixed yaml succeeds");
2550 let current = fixed.current_workspace_diagnostics();
2551 assert!(
2552 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
2553 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
2554 );
2555 }
2556
2557 #[test]
2564 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
2565 let project = tempfile::tempdir().expect("project");
2566 let root = project.path();
2567 write_single_source_project(
2568 root,
2569 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2570 );
2571 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2572 .expect("write bun.lockb placeholder");
2573 let config = fallow_config::FallowConfig::default().resolve(
2574 root.to_path_buf(),
2575 fallow_config::OutputFormat::Json,
2576 1,
2577 true,
2578 true,
2579 None,
2580 );
2581 let reload_config = || {
2582 let (_, diagnostics) =
2583 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2584 .expect("workspace discovery succeeds");
2585 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2586 };
2587
2588 reload_config();
2589 let first =
2590 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
2591 first
2592 .analyze_dead_code()
2593 .expect("analysis with bun.lockb only succeeds");
2594 assert!(
2595 has_diagnostic_kind(
2596 &first.current_workspace_diagnostics(),
2597 "bun-lockb-override-resolution-skipped"
2598 ),
2599 "the first run surfaces the bun.lockb skip: {:?}",
2600 first.current_workspace_diagnostics()
2601 );
2602
2603 std::fs::write(
2604 root.join("bun.lock"),
2605 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
2606 )
2607 .expect("write text bun.lock");
2608
2609 reload_config();
2610 let rerun =
2611 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
2612 rerun
2613 .analyze_dead_code()
2614 .expect("analysis with the text bun.lock succeeds");
2615 let current = rerun.current_workspace_diagnostics();
2616 assert!(
2617 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2618 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
2619 );
2620 }
2621
2622 #[test]
2635 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
2636 let project = tempfile::tempdir().expect("project");
2637 let root = project.path();
2638 write_single_source_project(
2639 root,
2640 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
2641 );
2642 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
2643 .expect("write oversized source");
2644 let mut config = fallow_config::FallowConfig::default().resolve(
2645 root.to_path_buf(),
2646 fallow_config::OutputFormat::Json,
2647 1,
2648 true,
2649 true,
2650 None,
2651 );
2652 config.max_file_size_bytes = Some(1024);
2653
2654 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
2655
2656 fallow_config::append_workspace_diagnostics(
2661 root,
2662 vec![WorkspaceDiagnostic::new(
2663 root,
2664 root.join("src/other-walk-only.ts"),
2665 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
2666 size_bytes: 4096,
2667 },
2668 )],
2669 );
2670
2671 let current = session.current_workspace_diagnostics();
2672 let skipped: Vec<&Path> = current
2673 .iter()
2674 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
2675 .map(|diagnostic| diagnostic.path.as_path())
2676 .collect();
2677 assert_eq!(
2678 skipped.len(),
2679 1,
2680 "the session reports its own walk's skips only: {skipped:?}"
2681 );
2682 assert!(
2683 skipped[0].ends_with("src/huge.ts"),
2684 "the surviving skip is this walk's own: {skipped:?}"
2685 );
2686 }
2687
2688 #[test]
2696 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
2697 let project = tempfile::tempdir().expect("project");
2698 let root = project.path();
2699 write_single_source_project(
2700 root,
2701 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2702 );
2703 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2704 .expect("write bun.lockb placeholder");
2705 let config = fallow_config::FallowConfig::default().resolve(
2706 root.to_path_buf(),
2707 fallow_config::OutputFormat::Json,
2708 1,
2709 true,
2710 true,
2711 None,
2712 );
2713 let reload_config = || {
2714 let (_, diagnostics) =
2715 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2716 .expect("workspace discovery succeeds");
2717 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2718 };
2719
2720 reload_config();
2721 let analyzing =
2722 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2723 analyzing
2724 .analyze_dead_code()
2725 .expect("analysis with bun.lockb only succeeds");
2726
2727 reload_config();
2730
2731 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2732 let current = later.current_workspace_diagnostics();
2733 assert!(
2734 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2735 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
2736 {current:?}"
2737 );
2738 }
2739}