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::security::stamp_security_finding_ids(
1606 &mut detector.results.security_findings,
1607 &config.root,
1608 );
1609 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1610 stopped("assembling the dead-code report")?;
1615 let profile =
1616 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1617 retain_timings: retain_graph,
1618 prelude: &prelude,
1619 prelude_timings,
1620 parse_metrics: metrics,
1621 module_count: modules.len(),
1622 entry_points: &entry_points,
1623 resolved: &resolved,
1624 graph: &graph,
1625 detector: &detector,
1626 file_count: discovery.files().len(),
1627 workspace_count: discovery.workspaces().len(),
1628 graph_cache_rejection,
1629 });
1630 let script_used_packages = prelude.script_used_packages();
1631 let trace_provenance = prelude.trace_provenance(&modules);
1632 prelude.finish();
1633 let file_hashes = collect_file_hashes(&modules, discovery.files());
1634
1635 Ok(SharedDeadCodeAnalysisArtifacts {
1636 results: detector.results,
1637 timings: profile.timings,
1638 graph: retain_graph.then_some(graph.graph),
1639 modules: retain_modules.then_some(modules),
1640 files: retain_files.then(|| discovery.files().to_vec()),
1641 script_used_packages,
1642 trace_provenance,
1643 file_hashes,
1644 })
1645}
1646
1647fn resolve_or_build_dead_code_graph(
1655 prelude: &core_backend::DeadCodeBackendPrelude,
1656 entry_points: &core_backend::DeadCodeEntryPoints,
1657 modules: &[ModuleInfo],
1658) -> (
1659 core_backend::DeadCodeResolvedModules,
1660 core_backend::DeadCodeGraphRun,
1661 Option<CacheRejection>,
1662) {
1663 let rejection =
1664 match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1665 Ok((resolved, graph)) => return (resolved, graph, None),
1666 Err(rejection) => rejection,
1667 };
1668
1669 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1670 let graph =
1671 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1672 (resolved, graph, rejection)
1673}
1674
1675fn collect_file_hashes(
1676 modules: &[ModuleInfo],
1677 files: &[DiscoveredFile],
1678) -> FxHashMap<PathBuf, u64> {
1679 modules
1680 .iter()
1681 .filter_map(|module| {
1682 files
1683 .get(module.file_id.0 as usize)
1684 .map(|file| (file.path.clone(), module.content_hash))
1685 })
1686 .collect()
1687}
1688
1689pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1690 config: &ResolvedConfig,
1691 modules: &[ModuleInfo],
1692) -> EngineResult<DeadCodeAnalysisArtifacts> {
1693 let (workspaces, _diagnostics, workspaces_ms) =
1694 crate::project_config::collect_workspace_metadata(config)?;
1695 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1696 config,
1697 &workspaces,
1698 workspaces_ms,
1699 );
1700 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1701 config,
1702 discovery: &discovery,
1703 modules: Arc::from(modules),
1704 metrics: reused_parse_metrics(),
1705 collect_usages: true,
1706 retain_graph: true,
1707 retain_modules: false,
1708 retain_files: false,
1709 cancellation: None,
1710 })
1711 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716 use std::fmt::Write as _;
1717 use std::time::Duration;
1718
1719 use super::*;
1720
1721 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1722 let project = tempfile::tempdir().expect("project");
1723 let root = project.path();
1724 std::fs::create_dir(root.join("src")).expect("create source directory");
1725 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1726 let session = AnalysisSession::load_default(root);
1727 (project, session)
1728 }
1729
1730 #[test]
1733 fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
1734 let project = tempfile::tempdir().expect("project");
1735 let root = project.path();
1736 std::fs::create_dir(root.join("src")).expect("create source directory");
1737 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1738 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
1739
1740 let baseline = AnalysisSession::load_default(root)
1741 .analyze_dead_code()
1742 .expect("an uncancelled session analyzes");
1743 assert!(
1744 !baseline.results.unused_files.is_empty(),
1745 "the fixture must have findings, so an empty result would be a plausible wrong answer"
1746 );
1747
1748 let error = AnalysisSession::load_default(root)
1749 .with_cancellation(Arc::new(AtomicBool::new(true)))
1750 .analyze_dead_code()
1751 .expect_err("a cancelled session must not return results");
1752 assert!(error.is_cancelled(), "unexpected error: {error}");
1753 assert!(error.message().contains("cancelled"));
1754 }
1755
1756 #[test]
1762 fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
1763 let project = tempfile::tempdir().expect("project");
1764 let root = project.path();
1765 std::fs::create_dir(root.join("src")).expect("create source directory");
1766 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
1767
1768 let cold = AnalysisSession::load_default(root)
1769 .analyze_dead_code_with_artifacts(false, true)
1770 .expect("cold run analyzes");
1771 assert_eq!(
1772 cold.timings
1773 .expect("cold timings retained")
1774 .graph_cache_rejection,
1775 Some(CacheRejection::Absent),
1776 "a first run has no persisted graph to refuse"
1777 );
1778
1779 std::fs::write(
1780 root.join("src/index.ts"),
1781 "export const entry = 1;\nexport const added = 2;\n",
1782 )
1783 .expect("edit the entry");
1784
1785 let warm = AnalysisSession::load_default(root)
1786 .analyze_dead_code_with_artifacts(false, true)
1787 .expect("warm run analyzes");
1788 assert_eq!(
1789 warm.timings
1790 .expect("warm timings retained")
1791 .graph_cache_rejection,
1792 Some(CacheRejection::FingerprintChanged),
1793 "the decoded graph was refused because a file changed, and the run must say so"
1794 );
1795 }
1796
1797 #[test]
1800 fn a_session_without_a_token_is_never_cancelled() {
1801 let (_project, session) = session_with_source("export const unused = 1;\n");
1802 assert!(!session.is_cancelled());
1803 session
1804 .analyze_dead_code()
1805 .expect("a session without a token analyzes");
1806 }
1807
1808 const PARSE_LOOP_FILES: usize = 400;
1812
1813 fn parse_loop_project() -> tempfile::TempDir {
1814 let project = tempfile::tempdir().expect("project");
1815 let src = project.path().join("src");
1816 std::fs::create_dir_all(&src).expect("src dir");
1817 for module in 0..PARSE_LOOP_FILES {
1818 let mut source = String::new();
1819 for symbol in 0..20 {
1820 let _ = writeln!(
1821 source,
1822 "export const helper{symbol} = (input: number): number => {{\n \
1823 if (input > {symbol}) {{\n return input * {symbol};\n }}\n \
1824 return input - {symbol};\n}};"
1825 );
1826 }
1827 std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
1828 }
1829 project
1830 }
1831
1832 #[cfg(unix)]
1839 #[test]
1840 fn a_refresh_drops_modules_whose_fingerprints_need_a_content_check() {
1841 let (_project, mut session) = session_with_source("export const kept = 1;\n");
1842 drop(session.parse_modules(false, None));
1843
1844 assert!(!session.refresh_discovery(), "the file set is the same");
1845 assert!(
1846 session.parsed_cache.lock().expect("parse cache").is_some(),
1847 "fingerprints with a known ctime keep the modules for the next run"
1848 );
1849
1850 if let Some(cache) = session
1851 .parsed_cache
1852 .get_mut()
1853 .expect("parse cache")
1854 .as_mut()
1855 {
1856 for fingerprint in &mut cache.fingerprints {
1857 fingerprint.ctime_ns = 0;
1858 }
1859 }
1860 assert!(!session.refresh_discovery(), "the file set is the same");
1861 assert!(
1862 session.parsed_cache.lock().expect("parse cache").is_none(),
1863 "the next run parses through the persisted cache, which checks the content"
1864 );
1865 }
1866
1867 fn uncached_session(root: &Path) -> AnalysisSession {
1870 let mut project_config = crate::project_config::default_project_config(root);
1871 project_config.config.no_cache = true;
1872 AnalysisSession::from_config(project_config)
1873 }
1874
1875 #[test]
1884 fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
1885 let project = parse_loop_project();
1886 let root = project.path();
1887
1888 drop(uncached_session(root).parse_modules(false, None));
1890
1891 let started = Instant::now();
1892 let full = uncached_session(root).parse_modules(false, None);
1893 let full_parse = started.elapsed();
1894 let full_count = full.modules.len();
1895 assert_eq!(
1896 full_count, PARSE_LOOP_FILES,
1897 "the fixture must parse every generated module"
1898 );
1899 assert!(
1900 full_parse >= Duration::from_millis(20),
1901 "the fixture is too small to cancel part way through: {full_parse:?}"
1902 );
1903
1904 let mut partial = None;
1909 let mut cancelled_session = None;
1910 for attempt in 1..=6_u32 {
1911 let token = Arc::new(AtomicBool::new(false));
1912 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
1916 let watchdog = {
1917 let token = Arc::clone(&token);
1918 let delay = full_parse * attempt / 6;
1919 std::thread::spawn(move || {
1920 std::thread::sleep(delay);
1921 token.store(true, Ordering::SeqCst);
1922 })
1923 };
1924 let cancelled = session.parse_modules(false, Some(&token));
1925 watchdog.join().expect("watchdog thread");
1926
1927 let parsed = cancelled.modules.len();
1928 assert!(
1929 parsed < full_count,
1930 "the parse returned all {full_count} modules, so the loop never read the token"
1931 );
1932 cancelled_session = Some(session);
1933 if parsed > 0 {
1934 partial = Some(parsed);
1935 break;
1936 }
1937 }
1938 let parsed = partial.expect(
1939 "no attempt flipped the token while the loop was running, so this never measured a \
1940 stop part way through",
1941 );
1942 assert!(parsed < full_count);
1943
1944 assert!(
1945 cancelled_session
1946 .expect("a cancelled session")
1947 .parsed_cache
1948 .lock()
1949 .expect("parse cache")
1950 .is_none(),
1951 "a truncated parse must not be retained as this session's warm cache"
1952 );
1953 let recovered = uncached_session(root).parse_modules(false, None);
1954 assert_eq!(
1955 recovered.modules.len(),
1956 full_count,
1957 "a later uncancelled parse must still see the whole project"
1958 );
1959 }
1960
1961 #[test]
1962 fn session_retains_workspace_metadata_from_config_load() {
1963 let project = tempfile::tempdir().expect("project");
1964 let root = project.path();
1965 std::fs::write(
1966 root.join("package.json"),
1967 r#"{"name":"root","workspaces":["packages/*"]}"#,
1968 )
1969 .expect("write root package");
1970 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1971 std::fs::write(
1972 root.join("packages/a/package.json"),
1973 r#"{"name":"pkg-a","type":"module"}"#,
1974 )
1975 .expect("write workspace package");
1976
1977 let session = AnalysisSession::load(root, None).expect("session loads");
1978
1979 assert!(
1980 session
1981 .workspaces()
1982 .iter()
1983 .any(|workspace| workspace.name == "pkg-a"),
1984 "session must retain workspace metadata discovered during config load"
1985 );
1986 }
1987
1988 #[test]
1989 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1990 let project = tempfile::tempdir().expect("project");
1991 let root = project.path();
1992 std::fs::create_dir(root.join("src")).expect("create source directory");
1993 std::fs::write(
1994 root.join("package.json"),
1995 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1996 )
1997 .expect("write package manifest");
1998 std::fs::write(
1999 root.join("vitest.config.ts"),
2000 "import './src/feature';\nexport default {};\n",
2001 )
2002 .expect("write vitest config");
2003 std::fs::write(
2004 root.join("src/feature.ts"),
2005 "export const feature = true;\n",
2006 )
2007 .expect("write reachable source");
2008 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
2009 .expect("write hidden source");
2010
2011 let unfiltered = AnalysisSession::load(root, None)
2012 .expect("unfiltered session loads")
2013 .analyze_dead_code()
2014 .expect("unfiltered analysis succeeds");
2015 assert!(
2016 unfiltered
2017 .results
2018 .unused_files
2019 .iter()
2020 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
2021 );
2022
2023 std::fs::write(
2024 root.join(".fallowrc.json"),
2025 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
2026 )
2027 .expect("write fallow config");
2028 let session = AnalysisSession::load(root, None).expect("filtered session loads");
2029 let hidden_path = root.join("src/hidden.ts");
2030 assert!(session.files().iter().any(|file| file.path == hidden_path));
2031
2032 let filtered = session
2033 .analyze_dead_code_with_artifacts(false, true)
2034 .expect("filtered analysis succeeds");
2035 assert!(
2036 filtered
2037 .results
2038 .unused_files
2039 .iter()
2040 .all(|finding| finding.file.path != hidden_path)
2041 );
2042 assert!(
2043 filtered
2044 .graph
2045 .as_ref()
2046 .is_some_and(|graph| graph.module_count() == session.files().len())
2047 );
2048 }
2049
2050 #[test]
2051 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
2052 use fallow_types::output_dead_code::UnusedFileFinding;
2053 use fallow_types::results::UnusedFile;
2054
2055 let project = tempfile::tempdir().expect("project");
2056 let config = serde_json::from_str::<fallow_config::FallowConfig>(
2057 r#"{"ignoreFindings":["**/*.ts"]}"#,
2058 )
2059 .expect("config parses")
2060 .resolve(
2061 project.path().to_path_buf(),
2062 fallow_config::OutputFormat::Human,
2063 1,
2064 true,
2065 true,
2066 None,
2067 );
2068 let outside = project
2069 .path()
2070 .parent()
2071 .expect("project has parent")
2072 .join("outside.ts");
2073 let mut results = AnalysisResults {
2074 unused_files: vec![
2075 UnusedFileFinding::with_actions(UnusedFile {
2076 path: PathBuf::from(r"src\hidden.ts"),
2077 }),
2078 UnusedFileFinding::with_actions(UnusedFile {
2079 path: outside.clone(),
2080 }),
2081 ],
2082 ..AnalysisResults::default()
2083 };
2084
2085 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
2086
2087 assert_eq!(results.unused_files.len(), 1);
2088 assert_eq!(results.unused_files[0].file.path, outside);
2089 }
2090
2091 #[cfg(unix)]
2094 fn warm_store_project() -> tempfile::TempDir {
2095 let project = tempfile::tempdir().expect("project");
2096 let root = project.path();
2097 std::fs::create_dir(root.join("src")).expect("create source directory");
2098 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2099 std::fs::write(root.join("src/other.ts"), "export const other = 2;\n").expect("other");
2100 project
2101 }
2102
2103 #[cfg(unix)]
2104 fn warm_session(root: &Path, store: &Arc<WarmParseStore>) -> AnalysisSession {
2105 AnalysisSession::load_default(root).with_warm_parse(Some(Arc::clone(store)))
2106 }
2107
2108 #[cfg(unix)]
2109 fn exported_names(modules: &[ModuleInfo]) -> Vec<String> {
2110 let mut names: Vec<String> = modules
2111 .iter()
2112 .flat_map(|module| module.exports.iter().map(|export| export.name.to_string()))
2113 .collect();
2114 names.sort();
2115 names
2116 }
2117
2118 #[cfg(unix)]
2119 #[test]
2120 fn a_warm_store_serves_a_new_session_without_parse_work() {
2121 let project = warm_store_project();
2122 let store = Arc::new(WarmParseStore::new(
2123 crate::warm_parse::WarmParseLimits::default(),
2124 ));
2125
2126 let first = warm_session(project.path(), &store).parse_modules(false, None);
2127 let second = warm_session(project.path(), &store).parse_modules(false, None);
2128
2129 assert!(Arc::ptr_eq(&first.modules, &second.modules));
2130 assert_eq!(second.metrics.cache_hits + second.metrics.cache_misses, 0);
2131 let counts = store.counts();
2132 assert_eq!(counts.parse_runs, 1);
2133 assert_eq!(counts.modules_parsed, 2);
2134 assert_eq!(counts.modules_reused, 2);
2135 }
2136
2137 #[cfg(unix)]
2138 #[test]
2139 fn a_warm_store_serves_the_health_parse_of_a_new_session() {
2140 let project = warm_store_project();
2141 let store = Arc::new(WarmParseStore::new(
2142 crate::warm_parse::WarmParseLimits::default(),
2143 ));
2144
2145 drop(warm_session(project.path(), &store).parse_modules(false, None));
2146 let health = warm_session(project.path(), &store).parsed_parts_uncached(true);
2147
2148 assert_eq!(health.modules.len(), 2);
2149 assert_eq!(health.cache_hits + health.cache_misses, 0);
2150 assert_eq!(
2151 store.counts().parse_runs,
2152 1,
2153 "the first parse computed complexity"
2154 );
2155 }
2156
2157 #[cfg(unix)]
2158 #[test]
2159 fn an_edit_or_a_new_file_makes_the_next_session_parse_again() {
2160 let project = warm_store_project();
2161 let root = project.path();
2162 let store = Arc::new(WarmParseStore::new(
2163 crate::warm_parse::WarmParseLimits::default(),
2164 ));
2165 drop(warm_session(root, &store).parse_modules(false, None));
2166
2167 std::fs::write(root.join("src/other.ts"), "export const renamed = 22;\n").expect("edit");
2168 let edited = warm_session(root, &store).parse_modules(false, None);
2169 assert_eq!(exported_names(&edited.modules), ["entry", "renamed"]);
2170 assert_eq!(
2171 edited.metrics.cache_misses, 1,
2172 "only the edited file is parsed"
2173 );
2174
2175 std::fs::write(root.join("src/added.ts"), "export const added = 3;\n").expect("add");
2176 let added = warm_session(root, &store).parse_modules(false, None);
2177 assert_eq!(
2178 exported_names(&added.modules),
2179 ["added", "entry", "renamed"]
2180 );
2181
2182 let counts = store.counts();
2183 assert_eq!(counts.parse_runs, 3);
2184 assert_eq!(counts.modules_reused, 0);
2185 assert_eq!(store.len(), 2, "each file list keeps its latest parse only");
2186 }
2187
2188 #[cfg(unix)]
2189 #[test]
2190 fn a_session_without_the_persisted_cache_does_not_use_the_store() {
2191 let project = warm_store_project();
2192 let store = Arc::new(WarmParseStore::new(
2193 crate::warm_parse::WarmParseLimits::default(),
2194 ));
2195
2196 drop(
2197 uncached_session(project.path())
2198 .with_warm_parse(Some(Arc::clone(&store)))
2199 .parse_modules(false, None),
2200 );
2201
2202 assert_eq!(
2203 store.counts(),
2204 crate::warm_parse::WarmParseCounts::default()
2205 );
2206 assert!(store.is_empty());
2207 }
2208
2209 #[cfg(unix)]
2213 #[test]
2214 fn a_kept_parse_records_its_read_failures_again() {
2215 let project = warm_store_project();
2216 let root = project.path();
2217 std::fs::write(root.join("src/broken.ts"), [0xff, 0xfe, 0x00]).expect("invalid UTF-8");
2218 let store = Arc::new(WarmParseStore::new(
2219 crate::warm_parse::WarmParseLimits::default(),
2220 ));
2221 let read_failures = |session: &AnalysisSession| {
2222 session
2223 .current_workspace_diagnostics()
2224 .into_iter()
2225 .filter(|diagnostic| {
2226 matches!(
2227 diagnostic.kind,
2228 fallow_types::workspace::WorkspaceDiagnosticKind::SourceReadFailure { .. }
2229 )
2230 })
2231 .count()
2232 };
2233
2234 let first = warm_session(root, &store);
2235 drop(first.parse_modules(false, None));
2236 assert_eq!(read_failures(&first), 1);
2237
2238 drop(fallow_config::record_source_read_failures(root, &[]));
2239 let second = warm_session(root, &store);
2240 drop(second.parse_modules(false, None));
2241 assert_eq!(store.counts().modules_reused, 2);
2242 assert_eq!(read_failures(&second), 1);
2243 }
2244
2245 #[test]
2246 fn warm_parse_cache_reuses_module_storage() {
2247 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2248 let first = session.parse_modules(true, None);
2249 let second = session.parse_modules(false, None);
2250
2251 assert!(
2252 Arc::ptr_eq(&first.modules, &second.modules),
2253 "warm session queries must share parsed module storage"
2254 );
2255 }
2256
2257 #[test]
2258 fn warm_styling_cache_reuses_artifact_allocation() {
2259 let project = tempfile::tempdir().expect("project");
2260 let root = project.path();
2261 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
2262 .expect("write stylesheet");
2263 let session = AnalysisSession::load_default(root);
2264
2265 let first = session.styling_analysis_artifacts();
2266 let second = session.styling_analysis_artifacts();
2267
2268 assert!(
2269 Arc::ptr_eq(&first, &second),
2270 "warm styling queries must share the cached artifact allocation"
2271 );
2272 }
2273
2274 #[test]
2275 fn shared_parsed_modules_reuse_public_session_storage() {
2276 let (_project, session) = session_with_source("export const value = 1;\n");
2277 let first = session.shared_parsed_modules(true);
2278 let second = session.shared_parsed_modules(false);
2279
2280 assert!(Arc::ptr_eq(&first, &second));
2281 }
2282
2283 #[test]
2284 fn shared_parsed_parts_reuse_public_session_storage() {
2285 let (_project, session) = session_with_source("export const value = 1;\n");
2286 let cached = session.shared_parsed_modules(true);
2287 let parts = session.shared_parsed_parts(false);
2288
2289 assert!(Arc::ptr_eq(&cached, &parts.modules));
2290 }
2291
2292 #[test]
2293 fn warm_complexity_artifacts_reuse_cached_module_storage() {
2294 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2295 let cached = session.parse_modules(true, None);
2296 let artifacts = session
2297 .analyze_dead_code_with_reuse_artifacts(true, true, false)
2298 .expect("analysis succeeds");
2299 let retained = artifacts.modules.expect("complexity modules retained");
2300
2301 assert!(
2302 Arc::ptr_eq(&cached.modules, &retained),
2303 "warm complexity artifacts must share parsed module storage"
2304 );
2305 }
2306
2307 #[test]
2308 fn shared_and_owned_artifacts_preserve_output_bytes() {
2309 let (_project, session) = session_with_source(
2310 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
2311 );
2312 let owned = session
2313 .analyze_dead_code_with_artifacts(true, true)
2314 .expect("owned analysis succeeds");
2315 let shared = session
2316 .analyze_dead_code_with_shared_artifacts(true, true)
2317 .expect("shared analysis succeeds");
2318
2319 assert_eq!(
2320 serde_json::to_vec(&owned.results).expect("serialize owned results"),
2321 serde_json::to_vec(&shared.results).expect("serialize shared results")
2322 );
2323 assert_eq!(owned.file_hashes, shared.file_hashes);
2324 assert_eq!(
2325 owned
2326 .modules
2327 .as_deref()
2328 .unwrap_or_default()
2329 .iter()
2330 .map(|module| module.content_hash)
2331 .collect::<Vec<_>>(),
2332 shared
2333 .modules
2334 .as_deref()
2335 .unwrap_or_default()
2336 .iter()
2337 .map(|module| module.content_hash)
2338 .collect::<Vec<_>>()
2339 );
2340 }
2341
2342 #[test]
2343 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
2344 let project = tempfile::tempdir().expect("project");
2345 let root = project.path();
2346 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
2347 std::fs::write(
2348 root.join("package.json"),
2349 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
2350 )
2351 .expect("write package manifest");
2352 std::fs::write(
2353 root.join("app/routes/home.tsx"),
2354 r#"
2355import { useLoaderData } from "react-router";
2356export function loader() { return { opaque: "value" }; }
2357export default function Home() {
2358 const data = useLoaderData<typeof loader>();
2359 const copy = { ...data };
2360 return JSON.stringify(copy);
2361}
2362"#,
2363 )
2364 .expect("write route module");
2365
2366 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2367 let cold_parse = cold_session.parsed_parts(false);
2368 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
2369 let cold = cold_session
2370 .analyze_dead_code()
2371 .expect("cold analysis succeeds");
2372
2373 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2374 let warm_parse = warm_session.parsed_parts(false);
2375 assert!(
2376 warm_parse.cache_hits > 0,
2377 "second session must use disk cache"
2378 );
2379 let warm = warm_session
2380 .analyze_dead_code()
2381 .expect("warm analysis succeeds");
2382
2383 assert!(
2384 cold.results.unused_load_data_keys.is_empty(),
2385 "cold analysis must abstain for an opaque route-loader use"
2386 );
2387 assert_eq!(
2388 serde_json::to_vec(&cold.results).expect("serialize cold results"),
2389 serde_json::to_vec(&warm.results).expect("serialize warm results"),
2390 "warm route-loader analysis must match cold analysis"
2391 );
2392 }
2393
2394 #[test]
2395 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
2396 let project = tempfile::tempdir().expect("project");
2397 let root = project.path();
2398 std::fs::create_dir(root.join("src")).expect("create source directory");
2399 std::fs::write(
2400 root.join("package.json"),
2401 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
2402 )
2403 .expect("write package manifest");
2404 std::fs::write(
2405 root.join("src/dependency.ts"),
2406 "export function dependency() { return 'real'; }\n",
2407 )
2408 .expect("write dependency");
2409 std::fs::write(
2410 root.join("src/wrapper.ts"),
2411 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
2412 )
2413 .expect("write wrapper");
2414 std::fs::write(
2415 root.join("src/index.ts"),
2416 "export { wrapper } from './wrapper';\n",
2417 )
2418 .expect("write entry point");
2419 std::fs::write(
2420 root.join("src/wrapper.test.ts"),
2421 r#"
2422import { vi } from "vitest";
2423vi.mock("./dependency", () => ({ dependency: () => "mock" }));
2424import { wrapper } from "./wrapper";
2425wrapper();
2426"#,
2427 )
2428 .expect("write test");
2429
2430 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2431 let dependency_id = cold_session
2432 .files()
2433 .iter()
2434 .find(|file| file.path == root.join("src/dependency.ts"))
2435 .expect("dependency discovered")
2436 .id;
2437 let cold = cold_session
2438 .analyze_dead_code_with_artifacts(false, true)
2439 .expect("cold analysis succeeds");
2440 let cold_exports = crate::module_graph::module_value_exports(
2441 cold.graph.as_ref().expect("cold graph retained"),
2442 );
2443 assert!(
2444 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
2445 "cold analysis must persist the graph cache"
2446 );
2447
2448 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2449 let warm = warm_session
2450 .analyze_dead_code_with_artifacts(false, true)
2451 .expect("warm analysis succeeds");
2452 let warm_exports = crate::module_graph::module_value_exports(
2453 warm.graph.as_ref().expect("warm graph retained"),
2454 );
2455
2456 let dependency = cold_exports
2457 .iter()
2458 .find(|export| export.file_id == dependency_id && export.name == "dependency")
2459 .expect("dependency export retained");
2460 assert!(!dependency.test_referenced);
2461 assert_eq!(warm_exports, cold_exports);
2462 }
2463
2464 #[test]
2465 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
2466 let project = tempfile::tempdir().expect("project");
2467 let root = project.path();
2468 std::fs::create_dir(root.join("src")).expect("create source directory");
2469 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
2470 .expect("write package manifest");
2471 for name in ["a.ts", "b.ts", "c.ts"] {
2472 std::fs::write(
2473 root.join("src").join(name),
2474 format!("export const {} = 1;\n", name.replace('.', "_")),
2475 )
2476 .expect("write source");
2477 }
2478 let session = AnalysisSession::load(root, None).expect("session loads");
2479 let removed_path = root.join("src/b.ts");
2480 let removed_id = session
2481 .files()
2482 .iter()
2483 .find(|file| file.path == removed_path)
2484 .expect("removed source discovered")
2485 .id;
2486 std::fs::remove_file(&removed_path).expect("remove source after discovery");
2487
2488 let parts = session.parsed_parts(false);
2489
2490 assert!(
2491 parts
2492 .modules
2493 .iter()
2494 .all(|module| module.file_id != removed_id),
2495 "unreadable file must not receive a placeholder module"
2496 );
2497 let diagnostic = parts
2498 .workspace_diagnostics
2499 .iter()
2500 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2501 .expect("parsed session parts carry source read failure");
2502 assert_eq!(diagnostic.path, removed_path);
2503 assert!(
2504 session
2505 .current_workspace_diagnostics()
2506 .iter()
2507 .any(|diagnostic| {
2508 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
2509 }),
2510 "session output carries parse-time source diagnostics"
2511 );
2512 }
2513
2514 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
2515 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
2516 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
2517
2518 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
2519 diagnostics
2520 .iter()
2521 .any(|diagnostic| diagnostic.kind.id() == id)
2522 }
2523
2524 fn write_single_source_project(root: &Path, manifest: &str) {
2525 std::fs::create_dir(root.join("src")).expect("create source directory");
2526 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
2527 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
2528 .expect("write source");
2529 }
2530
2531 #[test]
2537 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
2538 let project = tempfile::tempdir().expect("project");
2539 let root = project.path();
2540 write_single_source_project(
2541 root,
2542 r#"{"name":"issue-2366-engine-session","private":true}"#,
2543 );
2544 std::fs::write(
2545 root.join("pnpm-workspace.yaml"),
2546 MALFORMED_PNPM_WORKSPACE_YAML,
2547 )
2548 .expect("write malformed workspace yaml");
2549
2550 let broken = AnalysisSession::load(root, None).expect("session loads");
2551 broken
2552 .analyze_dead_code()
2553 .expect("analysis on the malformed yaml succeeds");
2554 assert!(
2555 has_diagnostic_kind(
2556 &broken.current_workspace_diagnostics(),
2557 "malformed-pnpm-workspace-yaml"
2558 ),
2559 "the first session surfaces the malformed yaml: {:?}",
2560 broken.current_workspace_diagnostics()
2561 );
2562
2563 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
2564 .expect("fix workspace yaml");
2565
2566 let fixed = AnalysisSession::load(root, None).expect("session loads");
2567 fixed
2568 .analyze_dead_code()
2569 .expect("analysis on the fixed yaml succeeds");
2570 let current = fixed.current_workspace_diagnostics();
2571 assert!(
2572 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
2573 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
2574 );
2575 }
2576
2577 #[test]
2584 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
2585 let project = tempfile::tempdir().expect("project");
2586 let root = project.path();
2587 write_single_source_project(
2588 root,
2589 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2590 );
2591 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2592 .expect("write bun.lockb placeholder");
2593 let config = fallow_config::FallowConfig::default().resolve(
2594 root.to_path_buf(),
2595 fallow_config::OutputFormat::Json,
2596 1,
2597 true,
2598 true,
2599 None,
2600 );
2601 let reload_config = || {
2602 let (_, diagnostics) =
2603 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2604 .expect("workspace discovery succeeds");
2605 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2606 };
2607
2608 reload_config();
2609 let first =
2610 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
2611 first
2612 .analyze_dead_code()
2613 .expect("analysis with bun.lockb only succeeds");
2614 assert!(
2615 has_diagnostic_kind(
2616 &first.current_workspace_diagnostics(),
2617 "bun-lockb-override-resolution-skipped"
2618 ),
2619 "the first run surfaces the bun.lockb skip: {:?}",
2620 first.current_workspace_diagnostics()
2621 );
2622
2623 std::fs::write(
2624 root.join("bun.lock"),
2625 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
2626 )
2627 .expect("write text bun.lock");
2628
2629 reload_config();
2630 let rerun =
2631 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
2632 rerun
2633 .analyze_dead_code()
2634 .expect("analysis with the text bun.lock succeeds");
2635 let current = rerun.current_workspace_diagnostics();
2636 assert!(
2637 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2638 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
2639 );
2640 }
2641
2642 #[test]
2655 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
2656 let project = tempfile::tempdir().expect("project");
2657 let root = project.path();
2658 write_single_source_project(
2659 root,
2660 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
2661 );
2662 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
2663 .expect("write oversized source");
2664 let mut config = fallow_config::FallowConfig::default().resolve(
2665 root.to_path_buf(),
2666 fallow_config::OutputFormat::Json,
2667 1,
2668 true,
2669 true,
2670 None,
2671 );
2672 config.max_file_size_bytes = Some(1024);
2673
2674 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
2675
2676 fallow_config::append_workspace_diagnostics(
2681 root,
2682 vec![WorkspaceDiagnostic::new(
2683 root,
2684 root.join("src/other-walk-only.ts"),
2685 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
2686 size_bytes: 4096,
2687 },
2688 )],
2689 );
2690
2691 let current = session.current_workspace_diagnostics();
2692 let skipped: Vec<&Path> = current
2693 .iter()
2694 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
2695 .map(|diagnostic| diagnostic.path.as_path())
2696 .collect();
2697 assert_eq!(
2698 skipped.len(),
2699 1,
2700 "the session reports its own walk's skips only: {skipped:?}"
2701 );
2702 assert!(
2703 skipped[0].ends_with("src/huge.ts"),
2704 "the surviving skip is this walk's own: {skipped:?}"
2705 );
2706 }
2707
2708 #[test]
2716 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
2717 let project = tempfile::tempdir().expect("project");
2718 let root = project.path();
2719 write_single_source_project(
2720 root,
2721 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2722 );
2723 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2724 .expect("write bun.lockb placeholder");
2725 let config = fallow_config::FallowConfig::default().resolve(
2726 root.to_path_buf(),
2727 fallow_config::OutputFormat::Json,
2728 1,
2729 true,
2730 true,
2731 None,
2732 );
2733 let reload_config = || {
2734 let (_, diagnostics) =
2735 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2736 .expect("workspace discovery succeeds");
2737 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2738 };
2739
2740 reload_config();
2741 let analyzing =
2742 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2743 analyzing
2744 .analyze_dead_code()
2745 .expect("analysis with bun.lockb only succeeds");
2746
2747 reload_config();
2750
2751 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
2752 let current = later.current_workspace_diagnostics();
2753 assert!(
2754 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
2755 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
2756 {current:?}"
2757 );
2758 }
2759}