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, now_ns};
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 read_started_ns: u64,
71 modules: Arc<[ModuleInfo]>,
72 read_failures: Vec<SourceReadFailure>,
75 parse_degradations: Vec<SourceParseDegradation>,
77}
78
79#[derive(Debug)]
81pub struct AnalysisSessionParts {
82 pub config: ResolvedConfig,
84 pub config_path: Option<PathBuf>,
86 pub files: Vec<DiscoveredFile>,
88 pub workspaces: Vec<WorkspaceInfo>,
90 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
92}
93
94#[derive(Debug)]
96pub struct ParsedAnalysisSessionParts {
97 pub config: ResolvedConfig,
99 pub config_path: Option<PathBuf>,
101 pub files: Vec<DiscoveredFile>,
103 pub modules: Vec<ModuleInfo>,
105 pub workspaces: Vec<WorkspaceInfo>,
107 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
109 pub parse_ms: f64,
111 pub cache_update_ms: f64,
113 pub cache_hits: usize,
115 pub cache_misses: usize,
117 pub parse_cpu_ms: f64,
119}
120
121#[derive(Debug)]
122pub(crate) struct SharedParsedAnalysisSessionParts {
123 pub(crate) config: ResolvedConfig,
124 pub(crate) files: Vec<DiscoveredFile>,
125 pub(crate) modules: Arc<[ModuleInfo]>,
126 pub workspaces: Vec<WorkspaceInfo>,
127 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
128 pub parse_ms: f64,
129 pub parse_cpu_ms: f64,
130}
131
132#[derive(Debug)]
134pub struct AnalysisSessionArtifacts {
135 pub analysis: DeadCodeAnalysisArtifacts,
137 pub changed_files: Option<FxHashSet<PathBuf>>,
139 pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
141}
142
143impl AnalysisSession {
144 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
150 let project_config = config_for_project(root, config_path)?;
151 Ok(Self::from_config(project_config))
152 }
153
154 pub fn load_with_config(
161 root: &Path,
162 config_path: Option<&Path>,
163 configure: impl FnOnce(&mut ResolvedConfig),
164 ) -> EngineResult<Self> {
165 Self::load_with_config_options(
166 root,
167 config_path,
168 fallow_config::ConfigLoadOptions::default(),
169 configure,
170 )
171 }
172
173 pub fn load_with_config_options(
180 root: &Path,
181 config_path: Option<&Path>,
182 load_options: fallow_config::ConfigLoadOptions,
183 configure: impl FnOnce(&mut ResolvedConfig),
184 ) -> EngineResult<Self> {
185 let mut project_config = crate::project_config::config_for_project_with_load_options(
186 root,
187 config_path,
188 load_options,
189 )?;
190 configure(&mut project_config.config);
191 project_config.workspaces.clear();
192 project_config.workspace_diagnostics.clear();
193 project_config.workspace_discovery_ms = None;
194 Ok(Self::from_config(project_config))
195 }
196
197 #[must_use]
202 pub fn load_default(root: &Path) -> Self {
203 Self::load_default_with_config(root, |_| {})
204 }
205
206 #[must_use]
209 pub fn load_default_with_config(
210 root: &Path,
211 configure: impl FnOnce(&mut ResolvedConfig),
212 ) -> Self {
213 let mut project_config = default_project_config(root);
214 configure(&mut project_config.config);
215 Self::from_config(project_config)
216 }
217
218 #[must_use]
220 pub fn from_config(project_config: ProjectConfig) -> Self {
221 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
222 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
223 {
224 crate::discover::prepare_analysis_discovery_with_workspaces(
225 &project_config.config,
226 &project_config.workspaces,
227 workspace_discovery_ms,
228 )
229 } else {
230 crate::discover::prepare_analysis_discovery(&project_config.config)
231 };
232 let workspaces = if uses_preloaded_workspaces {
233 project_config.workspaces
234 } else {
235 discovery.workspaces().to_vec()
236 };
237 let workspace_diagnostics = merge_workspace_diagnostics(
249 merge_workspace_diagnostics(
250 project_config.workspace_diagnostics,
251 fallow_config::workspace_diagnostics_for(&project_config.config.root)
252 .into_iter()
253 .filter(|diagnostic| {
254 !diagnostic.kind.is_analysis_stage()
255 && !diagnostic.kind.is_source_discovery()
256 })
257 .collect(),
258 ),
259 discovery.source_diagnostics().to_vec(),
260 );
261 Self {
262 config: project_config.config,
263 config_path: project_config.path,
264 config_inputs: project_config.inputs,
265 config_inputs_before_resolve: project_config.inputs_before_resolve,
266 discovery,
267 workspaces,
268 workspace_diagnostics,
269 parsed_cache: Mutex::new(None),
270 styling_cache: Mutex::new(None),
271 cancellation: None,
272 warm_parse: crate::warm_parse::installed(),
273 preloaded_workspaces: uses_preloaded_workspaces,
274 parse_counts: ParseCountCells::default(),
275 disk_cache_stale: AtomicBool::new(false),
276 }
277 }
278
279 #[must_use]
294 pub fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
295 self.cancellation = Some(cancellation);
296 self
297 }
298
299 #[must_use]
303 pub fn with_warm_parse(mut self, store: Option<Arc<WarmParseStore>>) -> Self {
304 self.warm_parse = store;
305 self
306 }
307
308 pub fn set_cancellation(&mut self, cancellation: Arc<AtomicBool>) {
313 self.cancellation = Some(cancellation);
314 }
315
316 #[must_use]
318 pub fn parse_counts(&self) -> SessionParseCounts {
319 self.parse_counts.snapshot()
320 }
321
322 pub fn refresh_discovery(&mut self) -> bool {
337 let discovery = if self.preloaded_workspaces {
338 crate::discover::prepare_analysis_discovery_with_workspaces(
339 &self.config,
340 &self.workspaces,
341 0.0,
342 )
343 } else {
344 crate::discover::prepare_analysis_discovery(&self.config)
345 };
346 let same_files = discovery.files().len() == self.files().len()
347 && discovery
348 .files()
349 .iter()
350 .zip(self.files())
351 .all(|(fresh, known)| fresh.path == known.path);
352 if !same_files || !self.cached_fingerprints_are_trustworthy() {
353 self.flush_parse_cache();
354 *self
355 .parsed_cache
356 .get_mut()
357 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
358 self.clear_styling_cache();
359 }
360 if !self.preloaded_workspaces {
361 self.workspaces = discovery.workspaces().to_vec();
362 }
363 self.discovery = discovery;
364 !same_files
365 }
366
367 fn cached_fingerprints_are_trustworthy(&mut self) -> bool {
370 self.parsed_cache
371 .get_mut()
372 .unwrap_or_else(std::sync::PoisonError::into_inner)
373 .as_ref()
374 .is_none_or(|cache| {
375 cache
376 .fingerprints
377 .iter()
378 .all(|fingerprint| fingerprint.is_trustworthy_without_content())
379 })
380 }
381
382 pub fn flush_parse_cache(&self) {
396 if self.config.no_cache || !self.disk_cache_stale.swap(false, Ordering::SeqCst) {
397 return;
398 }
399 let Ok(cache) = self.parsed_cache.lock() else {
400 return;
401 };
402 let Some(cache) = cache.as_ref() else {
403 return;
404 };
405 let cache_max_size_bytes =
406 crate::project_config::resolve_cache_max_size_bytes(&self.config);
407 let mut store = fallow_extract::cache::CacheStore::load(
408 &self.config.cache_dir,
409 &self.config.root,
410 self.config.cache_config_hash,
411 cache_max_size_bytes,
412 )
413 .ok();
414 let files = self.files();
415 write_parse_cache(
416 &self.config,
417 &mut store,
418 &ParseCacheWrite {
419 modules: &cache.modules,
420 files,
421 need_complexity: cache.need_complexity,
422 fingerprint_of: &|file: &DiscoveredFile| {
423 cache.fingerprints.get(file.id.0 as usize).map_or_else(
424 || SourceFingerprint::new(0, 0),
425 |fingerprint| fingerprint.for_content_read_at(cache.read_started_ns),
426 )
427 },
428 },
429 );
430 }
431
432 pub fn prewarm_parsed_modules(&self, need_complexity: bool) -> EngineResult<()> {
441 self.shared_parsed_modules_cancellable(need_complexity, "prewarm")
442 .map(drop)
443 }
444
445 fn clear_styling_cache(&self) {
446 if let Ok(mut cache) = self.styling_cache.lock() {
447 *cache = None;
448 }
449 }
450
451 #[must_use]
453 pub fn is_cancelled(&self) -> bool {
454 self.cancellation
455 .as_ref()
456 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
457 }
458
459 fn ensure_not_cancelled(&self, stage: &str) -> EngineResult<()> {
460 if self.is_cancelled() {
461 return Err(crate::EngineError::cancelled(stage));
462 }
463 Ok(())
464 }
465
466 pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
474 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
475 crate::project_config::collect_workspace_metadata(&config)?;
476 Ok(Self::from_config(ProjectConfig {
477 inputs: fallow_config::ConfigInputs::default(),
478 inputs_before_resolve: fallow_config::ConfigInputsSnapshot::default(),
479 config,
480 path: None,
481 workspaces,
482 workspace_diagnostics,
483 workspace_discovery_ms: Some(workspace_discovery_ms),
484 }))
485 }
486
487 #[must_use]
489 pub fn root(&self) -> &Path {
490 &self.config.root
491 }
492
493 #[must_use]
495 pub fn config(&self) -> &ResolvedConfig {
496 &self.config
497 }
498
499 #[must_use]
501 pub fn config_path(&self) -> Option<&Path> {
502 self.config_path.as_deref()
503 }
504
505 #[must_use]
509 pub const fn config_inputs(&self) -> &fallow_config::ConfigInputs {
510 &self.config_inputs
511 }
512
513 #[must_use]
517 pub const fn config_inputs_before_resolve(&self) -> &fallow_config::ConfigInputsSnapshot {
518 &self.config_inputs_before_resolve
519 }
520
521 #[must_use]
526 pub fn retained_bytes_estimate(&self) -> u64 {
527 self.parsed_cache
528 .lock()
529 .ok()
530 .and_then(|cache| {
531 cache
532 .as_ref()
533 .map(|cache| crate::warm_parse::estimated_retained_bytes(&cache.fingerprints))
534 })
535 .unwrap_or(0)
536 }
537
538 #[must_use]
540 pub fn files(&self) -> &[DiscoveredFile] {
541 self.discovery.files()
542 }
543
544 #[must_use]
546 pub fn workspaces(&self) -> &[WorkspaceInfo] {
547 &self.workspaces
548 }
549
550 #[must_use]
552 fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
553 self.discovery
554 .files()
555 .iter()
556 .map(|file| {
557 let fingerprint = std::fs::metadata(&file.path).map_or_else(
558 |_| SourceFingerprint::new(0, file.size_bytes),
559 |metadata| SourceFingerprint::from_metadata(&metadata),
560 );
561 (file.path.clone(), fingerprint)
562 })
563 .collect()
564 }
565
566 pub(crate) fn changed_files_since(
573 &self,
574 git_ref: &str,
575 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
576 crate::changed_files::changed_files(&self.config.root, git_ref)
577 }
578
579 pub(crate) const fn discovery(&self) -> &crate::discover::AnalysisDiscovery {
581 &self.discovery
582 }
583
584 #[must_use]
586 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
587 &self.workspace_diagnostics
588 }
589
590 #[must_use]
603 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
604 merge_workspace_diagnostics(
605 self.workspace_diagnostics.clone(),
606 fallow_config::registry_diagnostics_to_fold(&self.config.root),
607 )
608 }
609
610 pub(crate) fn styling_analysis_artifacts(
611 &self,
612 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
613 if let Ok(cache) = self.styling_cache.lock()
614 && let Some(artifacts) = cache.as_ref()
615 {
616 return Arc::clone(artifacts);
617 }
618
619 let modules = self.shared_parsed_modules(false);
620 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
621 self.files(),
622 &modules,
623 self.config(),
624 ));
625 if let Ok(mut cache) = self.styling_cache.lock() {
626 *cache = Some(Arc::clone(&artifacts));
627 }
628 artifacts
629 }
630
631 #[must_use]
633 pub fn into_parts(self) -> AnalysisSessionParts {
634 let workspace_diagnostics = self.current_workspace_diagnostics();
635 AnalysisSessionParts {
636 config: self.config,
637 config_path: self.config_path,
638 files: self.discovery.into_files(),
639 workspaces: self.workspaces,
640 workspace_diagnostics,
641 }
642 }
643
644 #[must_use]
646 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
647 let AnalysisSessionParts {
648 config,
649 config_path,
650 files,
651 workspaces,
652 workspace_diagnostics,
653 } = self.into_parts();
654 let ParsedModules {
655 modules,
656 metrics,
657 source_diagnostics,
658 ..
659 } = parse_files_with_config(&config, &files, need_complexity, None);
660 ParsedAnalysisSessionParts {
661 config,
662 config_path,
663 files,
664 modules,
665 workspaces,
666 workspace_diagnostics: merge_workspace_diagnostics(
667 workspace_diagnostics,
668 source_diagnostics,
669 ),
670 parse_ms: metrics.parse_ms,
671 cache_update_ms: metrics.cache_ms,
672 cache_hits: metrics.cache_hits,
673 cache_misses: metrics.cache_misses,
674 parse_cpu_ms: metrics.parse_cpu_ms,
675 }
676 }
677
678 #[must_use]
680 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
681 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
682 self.parsed_parts_from_modules(modules.to_vec(), metrics)
683 }
684
685 #[must_use]
687 pub(crate) fn shared_parsed_parts(
688 &self,
689 need_complexity: bool,
690 ) -> SharedParsedAnalysisSessionParts {
691 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
692 SharedParsedAnalysisSessionParts {
693 config: self.config.clone(),
694 files: self.discovery.files().to_vec(),
695 modules,
696 workspaces: self.workspaces.clone(),
697 workspace_diagnostics: self.current_workspace_diagnostics(),
698 parse_ms: metrics.parse_ms,
699 parse_cpu_ms: metrics.parse_cpu_ms,
700 }
701 }
702
703 #[doc(hidden)]
709 #[must_use]
710 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
711 self.parse_modules(need_complexity, None).modules
712 }
713
714 pub(crate) fn shared_parsed_modules_cancellable(
725 &self,
726 need_complexity: bool,
727 stage: &str,
728 ) -> EngineResult<Arc<[ModuleInfo]>> {
729 self.ensure_not_cancelled("parsing")?;
730 let modules = self
731 .parse_modules(need_complexity, self.cancellation.as_deref())
732 .modules;
733 self.ensure_not_cancelled(stage)?;
734 Ok(modules)
735 }
736
737 #[must_use]
740 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
741 let read_started_ns = now_ns();
742 let fingerprints = self
743 .warm_parse
744 .as_ref()
745 .and_then(|_| source_fingerprints_for_files(self.files()));
746 if let Some(warm) = self.warm_parse(
747 need_complexity,
748 fingerprints.as_deref(),
749 read_started_ns,
750 None,
751 ) {
752 return self.parsed_parts_from_modules(warm.modules.to_vec(), warm.metrics);
753 }
754 let ParsedModules {
755 modules, metrics, ..
756 } = parse_files_with_config(&self.config, self.files(), need_complexity, None);
757 self.parsed_parts_from_modules(modules, metrics)
758 }
759
760 fn parsed_parts_from_modules(
761 &self,
762 modules: Vec<ModuleInfo>,
763 metrics: core_backend::ParseMetrics,
764 ) -> ParsedAnalysisSessionParts {
765 ParsedAnalysisSessionParts {
766 config: self.config.clone(),
767 config_path: self.config_path.clone(),
768 files: self.discovery.files().to_vec(),
769 modules,
770 workspaces: self.workspaces.clone(),
771 workspace_diagnostics: self.current_workspace_diagnostics(),
772 parse_ms: metrics.parse_ms,
773 cache_update_ms: metrics.cache_ms,
774 cache_hits: metrics.cache_hits,
775 cache_misses: metrics.cache_misses,
776 parse_cpu_ms: metrics.parse_cpu_ms,
777 }
778 }
779
780 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
786 self.analyze_dead_code_with_artifacts(false, false)
787 .map(|output| DeadCodeAnalysis {
788 results: output.results,
789 })
790 }
791
792 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
798 self.analyze_dead_code_with_artifacts(true, false)
799 .map(|output| DeadCodeAnalysisOutput {
800 results: output.results,
801 modules: output.modules,
802 files: output.files,
803 })
804 }
805
806 pub fn analyze_dead_code_with_artifacts(
812 &self,
813 need_complexity: bool,
814 retain_graph: bool,
815 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
816 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
817 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
818 }
819
820 #[doc(hidden)]
830 pub fn analyze_dead_code_with_shared_artifacts(
831 &self,
832 need_complexity: bool,
833 retain_graph: bool,
834 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
835 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
836 }
837
838 pub fn analyze_dead_code_retaining_files(
845 &self,
846 need_complexity: bool,
847 retain_graph: bool,
848 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
849 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
850 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
851 }
852
853 pub fn analyze_dead_code_with_parsed_modules(
862 &self,
863 modules: &[ModuleInfo],
864 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
865 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
866 }
867
868 #[doc(hidden)]
874 pub(crate) fn analyze_dead_code_with_shared_modules(
875 &self,
876 modules: Arc<[ModuleInfo]>,
877 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
878 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
879 config: &self.config,
880 discovery: &self.discovery,
881 modules,
882 metrics: reused_parse_metrics(),
883 collect_usages: true,
884 retain_graph: true,
885 retain_modules: false,
886 retain_files: false,
887 cancellation: self.cancellation.as_deref(),
888 })
889 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
890 }
891
892 fn analyze_dead_code_with_reuse_artifacts(
893 &self,
894 need_complexity: bool,
895 retain_graph: bool,
896 retain_files: bool,
897 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
898 self.ensure_not_cancelled("parsing")?;
899 let SharedParsedModules { modules, metrics } =
900 self.parse_modules(need_complexity, self.cancellation.as_deref());
901 self.ensure_not_cancelled("the dead-code pipeline")?;
905 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
906 config: &self.config,
907 discovery: &self.discovery,
908 modules,
909 metrics,
910 collect_usages: true,
911 retain_graph,
912 retain_modules: need_complexity,
913 retain_files,
914 cancellation: self.cancellation.as_deref(),
915 })
916 }
917
918 pub fn analyze_dead_code_with_session_artifacts(
929 &self,
930 need_complexity: bool,
931 retain_graph: bool,
932 changed_files: Option<FxHashSet<PathBuf>>,
933 ) -> EngineResult<AnalysisSessionArtifacts> {
934 Ok(AnalysisSessionArtifacts {
935 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
936 changed_files,
937 source_fingerprints: self.source_fingerprints(),
938 })
939 }
940
941 pub fn analyze_project_with(
950 &self,
951 duplicates_config: &DuplicatesConfig,
952 retain_complexity_artifacts: bool,
953 ) -> EngineResult<ProjectAnalysisOutput> {
954 self.analyze_project_with_artifacts(
955 duplicates_config,
956 ProjectAnalysisArtifactOptions {
957 retain_complexity_artifacts,
958 ..ProjectAnalysisArtifactOptions::default()
959 },
960 )
961 .map(ProjectAnalysisArtifacts::into_output)
962 }
963
964 pub fn analyze_project_with_artifacts(
974 &self,
975 duplicates_config: &DuplicatesConfig,
976 options: ProjectAnalysisArtifactOptions,
977 ) -> EngineResult<ProjectAnalysisArtifacts> {
978 self.ensure_not_cancelled("duplication detection")?;
979 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
980 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
981 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
982 self.find_duplicates_touching_files_with_defaults(
983 duplicates_config,
984 &changed_files,
985 cache_dir,
986 )
987 .report
988 } else {
989 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
990 .report
991 };
992 self.ensure_not_cancelled("the dead-code half of project analysis")?;
996 let source_fingerprints = options
997 .collect_source_fingerprints
998 .then(|| self.source_fingerprints());
999 Ok(ProjectAnalysisArtifacts {
1000 dead_code: self.analyze_dead_code_with_artifacts(
1001 options.retain_complexity_artifacts,
1002 options.retain_graph,
1003 )?,
1004 duplication,
1005 changed_files: options.changed_files,
1006 source_fingerprints,
1007 })
1008 }
1009
1010 #[must_use]
1012 pub fn find_duplicates_with_defaults(
1013 &self,
1014 config: &DuplicatesConfig,
1015 cache_dir: Option<&Path>,
1016 ) -> DuplicationAnalysis {
1017 duplicates::find_duplicates_with_defaults(
1018 &self.config.root,
1019 self.files(),
1020 config,
1021 cache_dir,
1022 )
1023 }
1024
1025 #[must_use]
1027 pub fn find_duplicates_touching_files_with_defaults(
1028 &self,
1029 config: &DuplicatesConfig,
1030 changed_files: &[PathBuf],
1031 cache_dir: Option<&Path>,
1032 ) -> DuplicationAnalysis {
1033 duplicates::find_duplicates_touching_files_with_defaults(
1034 &self.config.root,
1035 self.files(),
1036 config,
1037 changed_files,
1038 cache_dir,
1039 )
1040 }
1041
1042 fn parse_modules(
1048 &self,
1049 need_complexity: bool,
1050 cancellation: Option<&AtomicBool>,
1051 ) -> SharedParsedModules {
1052 let read_started_ns = now_ns();
1053 let fingerprints = source_fingerprints_for_files(self.files());
1054 if let Some(fingerprints) = fingerprints.as_ref()
1055 && let Some(parsed) = self.reparse_changed_modules(
1056 need_complexity,
1057 fingerprints,
1058 read_started_ns,
1059 cancellation,
1060 )
1061 {
1062 return parsed;
1063 }
1064
1065 let (modules, metrics, has_complexity, modules_reused, problems) = if let Some(warm) = self
1066 .warm_parse(
1067 need_complexity,
1068 fingerprints.as_deref(),
1069 read_started_ns,
1070 cancellation,
1071 ) {
1072 let reused = if warm.reused { warm.modules.len() } else { 0 };
1073 (warm.modules, warm.metrics, true, reused, warm.problems)
1074 } else {
1075 let ParsedModules {
1076 modules,
1077 metrics,
1078 read_failures,
1079 parse_degradations,
1080 ..
1081 } = parse_files_with_config(&self.config, self.files(), need_complexity, cancellation);
1082 let problems = SourceProblems {
1083 read_failures,
1084 parse_degradations,
1085 };
1086 (modules.into(), metrics, need_complexity, 0, problems)
1087 };
1088 self.parse_counts.record(SessionParseCounts {
1089 modules_parsed: metrics.cache_misses,
1090 disk_cache_hits: metrics.cache_hits,
1091 modules_reused,
1092 });
1093 if !token_is_set(cancellation)
1097 && let Some(fingerprints) = fingerprints
1098 && let Ok(mut cache) = self.parsed_cache.lock()
1099 {
1100 *cache = Some(ParsedModuleCache {
1101 need_complexity: has_complexity,
1102 fingerprints,
1103 read_started_ns,
1104 modules: Arc::clone(&modules),
1105 read_failures: problems.read_failures,
1106 parse_degradations: problems.parse_degradations,
1107 });
1108 self.disk_cache_stale.store(false, Ordering::SeqCst);
1110 self.clear_styling_cache();
1111 }
1112 SharedParsedModules { modules, metrics }
1113 }
1114
1115 fn warm_parse(
1128 &self,
1129 need_complexity: bool,
1130 fingerprints: Option<&[SourceFingerprint]>,
1131 read_started_ns: u64,
1132 cancellation: Option<&AtomicBool>,
1133 ) -> Option<WarmParsedModules> {
1134 let store = self.warm_parse.as_deref()?;
1135 if self.config.no_cache {
1136 return None;
1137 }
1138 let key = WarmParseKey {
1139 root: &self.config.root,
1140 cache_config_hash: self.config.cache_config_hash,
1141 files: self.files(),
1142 fingerprints: fingerprints?,
1143 read_started_ns,
1144 };
1145 if !key.is_reusable() {
1146 return None;
1147 }
1148 if let Some(parse) = store.get(&key, need_complexity) {
1149 record_source_diagnostics(
1154 &self.config,
1155 &parse.read_failures,
1156 &parse.parse_degradations,
1157 );
1158 return Some(WarmParsedModules {
1159 modules: parse.modules,
1160 metrics: reused_parse_metrics(),
1161 reused: true,
1162 problems: SourceProblems {
1163 read_failures: parse.read_failures.to_vec(),
1164 parse_degradations: parse.parse_degradations.to_vec(),
1165 },
1166 });
1167 }
1168
1169 let parsed = parse_files_with_config(&self.config, self.files(), true, cancellation);
1170 store.record_parse(parsed.metrics.cache_misses, parsed.metrics.cache_hits);
1171 let modules: Arc<[ModuleInfo]> = parsed.modules.into();
1172 if !token_is_set(cancellation) {
1173 store.put(
1174 &key,
1175 true,
1176 WarmParse {
1177 modules: Arc::clone(&modules),
1178 read_failures: parsed.read_failures.clone().into(),
1179 parse_degradations: parsed.parse_degradations.clone().into(),
1180 },
1181 );
1182 }
1183 Some(WarmParsedModules {
1184 modules,
1185 metrics: parsed.metrics,
1186 reused: false,
1187 problems: SourceProblems {
1188 read_failures: parsed.read_failures,
1189 parse_degradations: parsed.parse_degradations,
1190 },
1191 })
1192 }
1193
1194 fn changed_since_kept_parse(
1202 &self,
1203 need_complexity: bool,
1204 fingerprints: &[SourceFingerprint],
1205 ) -> Option<(Vec<usize>, u64, Arc<[ModuleInfo]>)> {
1206 let unsettled = std::cell::RefCell::new(Vec::new());
1207 let guard = self.parsed_cache.lock().ok()?;
1208 let cache = guard.as_ref()?;
1209 if need_complexity && !cache.need_complexity {
1210 return None;
1211 }
1212 let mut changed = changed_file_indices(
1213 &cache.fingerprints,
1214 cache.read_started_ns,
1215 fingerprints,
1216 &|index| {
1217 unsettled.borrow_mut().push(index);
1218 true
1219 },
1220 )?;
1221 let kept_read_started_ns = cache.read_started_ns;
1222 let kept_modules = Arc::clone(&cache.modules);
1223 drop(guard);
1224 changed.extend(unsettled.into_inner().into_iter().filter(|&index| {
1225 !self.files().get(index).is_some_and(|file| {
1226 crate::session_reuse::module_matches_source(&kept_modules, file.id, &file.path)
1227 })
1228 }));
1229 changed.sort_unstable();
1230 Some((changed, kept_read_started_ns, kept_modules))
1231 }
1232
1233 fn reparse_changed_modules(
1242 &self,
1243 need_complexity: bool,
1244 fingerprints: &[SourceFingerprint],
1245 read_started_ns: u64,
1246 cancellation: Option<&AtomicBool>,
1247 ) -> Option<SharedParsedModules> {
1248 let (changed, kept_read_started_ns, kept_modules) =
1249 self.changed_since_kept_parse(need_complexity, fingerprints)?;
1250 let mut guard = self.parsed_cache.lock().ok()?;
1251 let cache = guard.as_mut()?;
1252 if !Arc::ptr_eq(&cache.modules, &kept_modules)
1255 || cache.read_started_ns != kept_read_started_ns
1256 {
1257 return None;
1258 }
1259 drop(kept_modules);
1262 if changed.is_empty() {
1263 cache.read_started_ns = read_started_ns;
1267 let modules = Arc::clone(&cache.modules);
1268 drop(guard);
1269 self.parse_counts.record(SessionParseCounts {
1270 modules_reused: modules.len(),
1271 ..SessionParseCounts::default()
1272 });
1273 return Some(SharedParsedModules {
1274 modules,
1275 metrics: reused_parse_metrics(),
1276 });
1277 }
1278 if changed.len() > MAX_INCREMENTAL_REPARSE_FILES {
1279 return None;
1280 }
1281 let files: Vec<DiscoveredFile> = changed
1282 .iter()
1283 .filter_map(|&index| self.files().get(index).cloned())
1284 .collect();
1285 let parse_start = Instant::now();
1286 let parsed = crate::source::parse_all_files(
1287 &files,
1288 None,
1289 cache.need_complexity,
1290 cancellation,
1291 &self.config.flags.patterns(),
1292 );
1293 if token_is_set(cancellation) {
1296 return Some(SharedParsedModules {
1297 modules: Arc::clone(&cache.modules),
1298 metrics: reused_parse_metrics(),
1299 });
1300 }
1301 let mut fresh = parsed.modules;
1302 for module in &mut fresh {
1303 module.prepare_analysis_facts();
1304 }
1305 let fresh_count = fresh.len();
1306 let reparsed: Vec<_> = files.iter().map(|file| file.id).collect();
1307 merge_reparsed_modules(&mut cache.modules, &reparsed, fresh);
1308 cache.fingerprints = fingerprints.to_vec();
1309 cache.read_started_ns = read_started_ns;
1310 cache
1311 .read_failures
1312 .retain(|failure| !reparsed.contains(&failure.file_id));
1313 cache.read_failures.extend(parsed.read_failures);
1314 cache
1315 .parse_degradations
1316 .retain(|degradation| !reparsed.contains(°radation.file_id));
1317 cache.parse_degradations.extend(parsed.parse_degradations);
1318 record_source_diagnostics(
1319 &self.config,
1320 &cache.read_failures,
1321 &cache.parse_degradations,
1322 );
1323 self.parse_counts.record(SessionParseCounts {
1324 modules_parsed: parsed.cache_misses,
1325 disk_cache_hits: 0,
1326 modules_reused: cache.modules.len().saturating_sub(fresh_count),
1327 });
1328 let modules = Arc::clone(&cache.modules);
1329 drop(guard);
1330 self.disk_cache_stale.store(true, Ordering::SeqCst);
1331 self.clear_styling_cache();
1332 Some(SharedParsedModules {
1333 modules,
1334 metrics: core_backend::ParseMetrics {
1335 parse_ms: parse_start.elapsed().as_secs_f64() * 1000.0,
1336 cache_ms: 0.0,
1337 cache_hits: 0,
1338 cache_misses: parsed.cache_misses,
1339 parse_cpu_ms: parsed.parse_cpu_ms,
1340 cache_rejection: None,
1341 files_read: parsed.files_read,
1342 source_bytes_read: parsed.source_bytes_read,
1343 parse_cache_bytes_read: 0,
1344 css_masked_bytes: parsed.css_masked_bytes,
1345 parse_cache_load_ms: 0.0,
1346 },
1347 })
1348 }
1349}
1350
1351struct ParsedModules {
1352 modules: Vec<ModuleInfo>,
1353 metrics: core_backend::ParseMetrics,
1354 source_diagnostics: Vec<WorkspaceDiagnostic>,
1355 read_failures: Vec<SourceReadFailure>,
1356 parse_degradations: Vec<SourceParseDegradation>,
1357}
1358
1359struct SharedParsedModules {
1360 modules: Arc<[ModuleInfo]>,
1361 metrics: core_backend::ParseMetrics,
1362}
1363
1364struct WarmParsedModules {
1366 modules: Arc<[ModuleInfo]>,
1367 metrics: core_backend::ParseMetrics,
1368 reused: bool,
1370 problems: SourceProblems,
1371}
1372
1373struct SourceProblems {
1375 read_failures: Vec<SourceReadFailure>,
1376 parse_degradations: Vec<SourceParseDegradation>,
1377}
1378
1379fn token_is_set(cancellation: Option<&AtomicBool>) -> bool {
1380 cancellation.is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
1381}
1382
1383fn parse_files_with_config(
1384 config: &ResolvedConfig,
1385 files: &[DiscoveredFile],
1386 need_complexity: bool,
1387 cancellation: Option<&AtomicBool>,
1388) -> ParsedModules {
1389 let parse_start = Instant::now();
1390 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1391 let mut cache_rejection = None;
1392 let mut parse_cache_bytes_read = 0;
1393 let mut parse_cache_load_ms = 0.0;
1394 let mut cache = if config.no_cache {
1395 None
1396 } else {
1397 let load_start = Instant::now();
1398 let (loaded, bytes_read) = fallow_extract::cache::CacheStore::load_counting_bytes(
1399 &config.cache_dir,
1400 &config.root,
1401 config.cache_config_hash,
1402 cache_max_size_bytes,
1403 );
1404 parse_cache_bytes_read = bytes_read;
1405 parse_cache_load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
1406 match loaded {
1407 Ok(store) => Some(store),
1408 Err(rejection) => {
1409 cache_rejection = Some(rejection);
1410 None
1411 }
1412 }
1413 };
1414 let read_started_ns = now_ns();
1415 let parse_result = crate::source::parse_all_files(
1416 files,
1417 cache.as_ref(),
1418 need_complexity,
1419 cancellation,
1420 &config.flags.patterns(),
1421 );
1422 let source_diagnostics = record_source_diagnostics(
1423 config,
1424 &parse_result.read_failures,
1425 &parse_result.parse_degradations,
1426 );
1427 let mut modules = parse_result.modules;
1428 for module in &mut modules {
1429 module.prepare_analysis_facts();
1430 }
1431 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
1432 let cache_ms = if token_is_set(cancellation) {
1433 0.0
1434 } else {
1435 update_parse_cache_if_enabled(
1436 config,
1437 &mut cache,
1438 &ParseCacheWrite {
1439 modules: &modules,
1440 files,
1441 need_complexity,
1442 fingerprint_of: &|file: &DiscoveredFile| {
1443 source_fingerprint(&file.path).for_content_read_at(read_started_ns)
1444 },
1445 },
1446 )
1447 };
1448 let metrics = core_backend::ParseMetrics {
1449 parse_ms,
1450 cache_ms,
1451 cache_hits: parse_result.cache_hits,
1452 cache_misses: parse_result.cache_misses,
1453 parse_cpu_ms: parse_result.parse_cpu_ms,
1454 cache_rejection,
1455 files_read: parse_result.files_read,
1456 source_bytes_read: parse_result.source_bytes_read,
1457 parse_cache_bytes_read,
1458 css_masked_bytes: parse_result.css_masked_bytes,
1459 parse_cache_load_ms,
1460 };
1461 ParsedModules {
1462 modules,
1463 metrics,
1464 source_diagnostics,
1465 read_failures: parse_result.read_failures,
1466 parse_degradations: parse_result.parse_degradations,
1467 }
1468}
1469
1470fn record_source_diagnostics(
1473 config: &ResolvedConfig,
1474 read_failures: &[SourceReadFailure],
1475 parse_degradations: &[SourceParseDegradation],
1476) -> Vec<WorkspaceDiagnostic> {
1477 let mut diagnostics = fallow_config::record_source_read_failures(&config.root, read_failures);
1478 diagnostics.extend(fallow_config::record_source_parse_degradations(
1479 config,
1480 parse_degradations,
1481 ));
1482 diagnostics
1483}
1484
1485fn reused_parse_metrics() -> core_backend::ParseMetrics {
1486 core_backend::ParseMetrics {
1487 parse_ms: 0.0,
1488 cache_ms: 0.0,
1489 cache_hits: 0,
1490 cache_misses: 0,
1491 parse_cpu_ms: 0.0,
1492 cache_rejection: None,
1493 files_read: 0,
1494 source_bytes_read: 0,
1495 parse_cache_bytes_read: 0,
1496 css_masked_bytes: 0,
1497 parse_cache_load_ms: 0.0,
1498 }
1499}
1500
1501fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
1502 files
1503 .iter()
1504 .map(|file| {
1505 std::fs::metadata(&file.path)
1506 .ok()
1507 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
1508 .filter(|fingerprint| fingerprint.has_known_mtime())
1509 })
1510 .collect()
1511}
1512
1513fn update_parse_cache_if_enabled(
1514 config: &ResolvedConfig,
1515 cache: &mut Option<fallow_extract::cache::CacheStore>,
1516 write: &ParseCacheWrite<'_>,
1517) -> f64 {
1518 let start = Instant::now();
1519 write_parse_cache(config, cache, write);
1520 start.elapsed().as_secs_f64() * 1000.0
1521}
1522
1523struct ParseCacheWrite<'a> {
1526 modules: &'a [ModuleInfo],
1527 files: &'a [DiscoveredFile],
1528 need_complexity: bool,
1529 fingerprint_of: &'a dyn Fn(&DiscoveredFile) -> SourceFingerprint,
1530}
1531
1532fn write_parse_cache(
1533 config: &ResolvedConfig,
1534 cache: &mut Option<fallow_extract::cache::CacheStore>,
1535 write: &ParseCacheWrite<'_>,
1536) {
1537 if config.no_cache {
1538 return;
1539 }
1540
1541 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1542 let store = cache.get_or_insert_with(|| fallow_extract::cache::CacheStore::new(&config.root));
1543 if update_parse_cache(store, write)
1544 && let Err(error) = store.save(
1545 &config.cache_dir,
1546 config.cache_config_hash,
1547 cache_max_size_bytes,
1548 )
1549 {
1550 tracing::warn!("Failed to save cache: {error}");
1551 }
1552}
1553
1554fn update_parse_cache(
1559 store: &mut fallow_extract::cache::CacheStore,
1560 write: &ParseCacheWrite<'_>,
1561) -> bool {
1562 let ParseCacheWrite {
1563 modules,
1564 files,
1565 need_complexity,
1566 fingerprint_of,
1567 } = *write;
1568 let mut dirty = false;
1569 for module in modules {
1570 if let Some(file) = files.get(module.file_id.0 as usize) {
1571 let fingerprint = fingerprint_of(file);
1572 if let Some(cached) = store.get_by_path_only(&file.path)
1573 && cached.content_hash == module.content_hash
1574 {
1575 let stale_metadata = cached.source_fingerprint() != fingerprint;
1576 let adds_complexity = need_complexity && !cached.complexity_extracted;
1577 if stale_metadata && !adds_complexity {
1578 store.refresh_fingerprint(&file.path, fingerprint);
1579 dirty = true;
1580 } else if adds_complexity {
1581 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
1582 .then(|| cached.complexity.clone());
1583 let mut refreshed = fallow_extract::cache::module_to_cached(
1584 module,
1585 fingerprint,
1586 need_complexity,
1587 );
1588 if let Some(complexity) = preserved_complexity {
1589 refreshed.complexity = complexity;
1590 refreshed.complexity_extracted = true;
1591 }
1592 store.insert(&file.path, refreshed);
1593 dirty = true;
1594 }
1595 continue;
1596 }
1597 store.insert(
1598 &file.path,
1599 fallow_extract::cache::module_to_cached(module, fingerprint, need_complexity),
1600 );
1601 dirty = true;
1602 }
1603 }
1604 store.retain_paths(files) || dirty
1605}
1606
1607fn source_fingerprint(path: &Path) -> SourceFingerprint {
1608 std::fs::metadata(path).map_or_else(
1609 |_| SourceFingerprint::new(0, 0),
1610 |metadata| SourceFingerprint::from_metadata(&metadata),
1611 )
1612}
1613
1614struct EngineDeadCodePipelineInput<'a> {
1615 config: &'a ResolvedConfig,
1616 discovery: &'a crate::discover::AnalysisDiscovery,
1617 modules: Arc<[ModuleInfo]>,
1618 metrics: core_backend::ParseMetrics,
1619 collect_usages: bool,
1620 retain_graph: bool,
1621 retain_modules: bool,
1622 retain_files: bool,
1623 cancellation: Option<&'a AtomicBool>,
1624}
1625
1626fn run_engine_owned_dead_code_pipeline(
1627 input: EngineDeadCodePipelineInput<'_>,
1628) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
1629 let EngineDeadCodePipelineInput {
1630 config,
1631 discovery,
1632 modules,
1633 metrics,
1634 collect_usages,
1635 retain_graph,
1636 retain_modules,
1637 retain_files,
1638 cancellation,
1639 } = input;
1640 let stopped = |stage: &str| -> EngineResult<()> {
1641 if token_is_set(cancellation) {
1642 return Err(crate::EngineError::cancelled(stage));
1643 }
1644 Ok(())
1645 };
1646 stopped("the dead-code prelude")?;
1647 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
1648 let prelude_timings = prelude.timings();
1649 stopped("dead-code entry-point discovery")?;
1650 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
1651 stopped("import resolution and graph construction")?;
1652 let (resolved, graph, graph_cache_rejection) =
1653 resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
1654 stopped("the dead-code detectors")?;
1655
1656 let mut detector = core_backend::run_dead_code_detectors(
1657 &prelude,
1658 &graph.graph,
1659 &resolved.project.modules,
1660 &modules,
1661 collect_usages,
1662 &entry_points,
1663 );
1664 crate::dead_code::stamp_finding_ids(&mut detector.results, &config.root);
1667 crate::security::stamp_security_finding_ids(
1669 &mut detector.results.security_findings,
1670 &config.root,
1671 );
1672 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1673 stopped("assembling the dead-code report")?;
1678 let profile =
1679 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1680 retain_timings: retain_graph,
1681 prelude: &prelude,
1682 prelude_timings,
1683 parse_metrics: metrics,
1684 module_count: modules.len(),
1685 entry_points: &entry_points,
1686 resolved: &resolved,
1687 graph: &graph,
1688 detector: &detector,
1689 file_count: discovery.files().len(),
1690 workspace_count: discovery.workspaces().len(),
1691 graph_cache_rejection,
1692 });
1693 let script_used_packages = prelude.script_used_packages();
1694 let trace_provenance = prelude.trace_provenance(&modules, retain_graph);
1695 prelude.finish();
1696 let file_hashes = collect_file_hashes(&modules, discovery.files());
1697
1698 Ok(SharedDeadCodeAnalysisArtifacts {
1699 results: detector.results,
1700 timings: profile.timings,
1701 graph: retain_graph.then_some(graph.graph),
1702 modules: retain_modules.then_some(modules),
1703 files: retain_files.then(|| discovery.files().to_vec()),
1704 script_used_packages,
1705 trace_provenance,
1706 file_hashes,
1707 })
1708}
1709
1710fn resolve_or_build_dead_code_graph(
1718 prelude: &core_backend::DeadCodeBackendPrelude,
1719 entry_points: &core_backend::DeadCodeEntryPoints,
1720 modules: &[ModuleInfo],
1721) -> (
1722 core_backend::DeadCodeResolvedModules,
1723 core_backend::DeadCodeGraphRun,
1724 Option<CacheRejection>,
1725) {
1726 let rejection =
1727 match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1728 Ok((resolved, graph)) => return (resolved, graph, None),
1729 Err(rejection) => rejection,
1730 };
1731
1732 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1733 let graph =
1734 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1735 (resolved, graph, rejection)
1736}
1737
1738fn collect_file_hashes(
1739 modules: &[ModuleInfo],
1740 files: &[DiscoveredFile],
1741) -> FxHashMap<PathBuf, u64> {
1742 modules
1743 .iter()
1744 .filter_map(|module| {
1745 files
1746 .get(module.file_id.0 as usize)
1747 .map(|file| (file.path.clone(), module.content_hash))
1748 })
1749 .collect()
1750}
1751
1752pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1753 config: &ResolvedConfig,
1754 modules: &[ModuleInfo],
1755) -> EngineResult<DeadCodeAnalysisArtifacts> {
1756 let (workspaces, _diagnostics, workspaces_ms) =
1757 crate::project_config::collect_workspace_metadata(config)?;
1758 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1759 config,
1760 &workspaces,
1761 workspaces_ms,
1762 );
1763 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1764 config,
1765 discovery: &discovery,
1766 modules: Arc::from(modules),
1767 metrics: reused_parse_metrics(),
1768 collect_usages: true,
1769 retain_graph: true,
1770 retain_modules: false,
1771 retain_files: false,
1772 cancellation: None,
1773 })
1774 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779 use std::fmt::Write as _;
1780 use std::time::Duration;
1781
1782 use super::*;
1783
1784 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1785 let project = tempfile::tempdir().expect("project");
1786 let root = project.path();
1787 std::fs::create_dir(root.join("src")).expect("create source directory");
1788 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1789 let session = AnalysisSession::load_default(root);
1790 (project, session)
1791 }
1792
1793 fn class_projection_session(root: &Path) -> AnalysisSession {
1794 AnalysisSession::load_default_with_config(root, |config| {
1795 config.rules.unused_class_members = fallow_config::Severity::Error;
1796 })
1797 }
1798
1799 fn session_class_projection(session: &AnalysisSession) -> Vec<String> {
1800 let analysis = session.analyze_dead_code().expect("class member analysis");
1801 assert!(analysis.results.unresolved_imports.is_empty());
1802 let mut members: Vec<_> = analysis
1803 .results
1804 .unused_class_members
1805 .iter()
1806 .filter(|finding| finding.member.parent_name == "C")
1807 .map(|finding| finding.member.member_name.clone())
1808 .collect();
1809 members.sort();
1810 members
1811 }
1812
1813 #[test]
1814 fn factory_property_proof_refresh_matches_fresh_sessions() {
1815 for (present, absent) in [
1816 (
1817 "import { C } from './classes'; const make = (): C => new C(); export const createUi = () => ({ page: make() });",
1818 "export const createUi = () => ({ page: { used() {} } });",
1819 ),
1820 (
1821 "import { Owner } from './owner'; export const createUi = () => { const owner = new Owner(); return { page: owner.widget }; };",
1822 "export const createUi = () => ({ page: { used() {} } });",
1823 ),
1824 ] {
1825 let project = tempfile::tempdir().expect("session fixture");
1826 let root = project.path();
1827 std::fs::write(
1828 root.join("package.json"),
1829 r#"{"name":"producer-refresh","type":"module","main":"index.ts"}"#,
1830 )
1831 .expect("package");
1832 std::fs::write(
1833 root.join("classes.ts"),
1834 "export class C { control() {} used() {} dead() {} }",
1835 )
1836 .expect("class");
1837 std::fs::write(
1838 root.join("owner.ts"),
1839 "import { C } from './classes'; export class Owner { widget: C = new C(); }",
1840 )
1841 .expect("owner");
1842 std::fs::write(root.join("index.ts"), "import { createUi } from './factory'; const ui = createUi(); ui.page.used(); import { C } from './classes'; new C().control();").expect("consumer");
1843 let path = root.join("factory.ts");
1844 std::fs::write(&path, present).expect("initial producer");
1845 let mut session = class_projection_session(root);
1846 assert_eq!(session_class_projection(&session), ["dead"]);
1847 for (source, expected) in [(absent, vec!["dead", "used"]), (present, vec!["dead"])] {
1848 std::fs::write(&path, source).expect("producer update");
1849 session.refresh_discovery();
1850 assert_eq!(session_class_projection(&session), expected);
1851 assert_eq!(
1852 session_class_projection(&session),
1853 session_class_projection(&class_projection_session(root))
1854 );
1855 }
1856 }
1857 }
1858
1859 #[test]
1860 fn callback_property_proof_refresh_matches_fresh_sessions() {
1861 for (present, absent) in [
1862 (
1863 "export function wrap<T>(callback: () => T) { return { value: callback() }; }",
1864 "export function wrap<T>(callback: () => T) { return { value: { used() {} } }; }",
1865 ),
1866 (
1867 "export { computed as wrap } from 'vue';",
1868 "export { computed as wrap } from 'foreign';",
1869 ),
1870 ] {
1871 let project = tempfile::tempdir().expect("callback session fixture");
1872 let root = project.path();
1873 std::fs::write(
1874 root.join("package.json"),
1875 r#"{"name":"callback-refresh","type":"module","main":"index.ts","dependencies":{"vue":"*","foreign":"*"}}"#,
1876 )
1877 .expect("package");
1878 std::fs::write(
1879 root.join("classes.ts"),
1880 "export class C { control() {} used() {} dead() {} }",
1881 )
1882 .expect("class");
1883 std::fs::write(root.join("index.ts"), "import { wrap } from './factory'; import { C } from './classes'; new C().control(); wrap(() => new C()).value.used();").expect("consumer");
1884 let path = root.join("factory.ts");
1885 std::fs::write(&path, present).expect("initial producer");
1886 let mut session = class_projection_session(root);
1887 assert_eq!(session_class_projection(&session), ["dead"]);
1888 for (source, expected) in [(absent, vec!["dead", "used"]), (present, vec!["dead"])] {
1889 std::fs::write(&path, source).expect("producer update");
1890 session.refresh_discovery();
1891 assert_eq!(session_class_projection(&session), expected);
1892 assert_eq!(
1893 session_class_projection(&session),
1894 session_class_projection(&class_projection_session(root))
1895 );
1896 }
1897 }
1898 }
1899
1900 #[test]
1901 fn callback_review_proof_refresh_matches_fresh_sessions() {
1902 for (present, absent) in [
1903 (
1904 "let current = new C(); wrap((ignored = 0) => current).value.used();",
1905 "let current = new C(); wrap((ignored = (current = { used() {} })) => current).value.used();",
1906 ),
1907 (
1908 "import { computed } from 'vue'; const getter = () => new C(); const alias = getter; const options = { get: alias, set: value => {} }; computed(options).value.used();",
1909 "import { computed } from 'vue'; const getter = () => new C(); const alias = getter; const options = { get: alias, set: value => {} }; options.get = () => ({ used() {} }); computed(options).value.used();",
1910 ),
1911 (
1912 "let current = new C(); wrap(() => current).value.used();",
1913 "let current = new C(); wrap(() => { current = { used() {} }; return current; }).value.used();",
1914 ),
1915 (
1916 "import { computed } from 'vue'; const options = { get: () => new C(), set: value => {} }; const alias = options; computed(alias).value.used();",
1917 "import { computed } from 'vue'; const options = { get: () => new C(), set: value => {} }; const alias = options; alias.get = () => ({ used() {} }); computed(options).value.used();",
1918 ),
1919 ] {
1920 let project = tempfile::tempdir().expect("callback review session fixture");
1921 let root = project.path();
1922 std::fs::write(root.join("package.json"), r#"{"name":"callback-review-refresh","type":"module","main":"index.ts","dependencies":{"vue":"*"}}"#).expect("package");
1923 std::fs::write(
1924 root.join("classes.ts"),
1925 "export class C { control() {} used() {} dead() {} }",
1926 )
1927 .expect("class");
1928 std::fs::write(
1929 root.join("helper.ts"),
1930 "export function wrap(fn) { return { value: fn() }; }",
1931 )
1932 .expect("helper");
1933 let path = root.join("index.ts");
1934 let source = |body: &str| {
1935 format!(
1936 "import {{ C }} from './classes'; import {{ wrap }} from './helper'; new C().control(); {body}"
1937 )
1938 };
1939 std::fs::write(&path, source(present)).expect("initial source");
1940 let mut session = class_projection_session(root);
1941 assert_eq!(session_class_projection(&session), ["dead"]);
1942 for (body, expected) in [(absent, vec!["dead", "used"]), (present, vec!["dead"])] {
1943 std::fs::write(&path, source(body)).expect("source update");
1944 session.refresh_discovery();
1945 assert_eq!(session_class_projection(&session), expected);
1946 assert_eq!(
1947 session_class_projection(&session),
1948 session_class_projection(&class_projection_session(root))
1949 );
1950 }
1951 }
1952 }
1953
1954 #[test]
1955 fn indexed_source_proof_removal_and_restoration_matches_fresh_sessions() {
1956 for (label, present, absent, access) in [
1957 (
1958 "literal",
1959 "const items = [new C()];",
1960 "const items = [new Other()];",
1961 "items[0].used();",
1962 ),
1963 (
1964 "vue",
1965 "import { computed } from 'vue';const items = computed(() => [new C()]);",
1966 "import { computed } from 'foreign';const items = computed(() => [new C()]);",
1967 "items.value[0].used();",
1968 ),
1969 ] {
1970 let project = tempfile::tempdir().expect("session fixture");
1971 let root = project.path();
1972 std::fs::create_dir(root.join("src")).expect("source directory");
1973 std::fs::write(root.join("package.json"),
1974 r#"{"name":"session-array-proof","type":"module","main":"src/index.ts","dependencies":{"vue":"*","foreign":"*"}}"#)
1975 .expect("package manifest");
1976 std::fs::write(root.join("src/classes.ts"),
1977 "export class C { control() {} used() {} dead() {} } export class Other { used() {} }")
1978 .expect("classes");
1979 for provider in ["vue", "foreign"] {
1980 let directory = root.join("node_modules").join(provider);
1981 std::fs::create_dir_all(&directory).expect("provider directory");
1982 std::fs::write(
1983 directory.join("package.json"),
1984 format!(r#"{{"name":"{provider}","type":"module","main":"index.js"}}"#),
1985 )
1986 .expect("provider package");
1987 std::fs::write(
1988 directory.join("index.js"),
1989 "export const computed = getter => ({ get value() { return getter(); } });",
1990 )
1991 .expect("provider source");
1992 }
1993 let source = |producer: &str| {
1994 format!(
1995 "import {{ C, Other }} from './classes';new C().control();\n{producer}\n{access}\n"
1996 )
1997 };
1998 let original = source(present);
1999 let path = root.join("src/index.ts");
2000 std::fs::write(&path, &original).expect("initial source");
2001 let mut retained = class_projection_session(root);
2002 for (phase, content, expected) in [
2003 ("initial", original.as_str(), vec!["dead"]),
2004 ("removed", source(absent).as_str(), vec!["dead", "used"]),
2005 ("restored", original.as_str(), vec!["dead"]),
2006 ] {
2007 std::fs::write(&path, content).expect("update proof source");
2008 retained.refresh_discovery();
2009 let actual = session_class_projection(&retained);
2010 assert_eq!(actual, expected, "{label}: {phase}");
2011 assert_eq!(
2012 actual,
2013 session_class_projection(&class_projection_session(root)),
2014 "{label}: retained and fresh differ after {phase}"
2015 );
2016 }
2017 }
2018 }
2019
2020 #[test]
2023 fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
2024 let project = tempfile::tempdir().expect("project");
2025 let root = project.path();
2026 std::fs::create_dir(root.join("src")).expect("create source directory");
2027 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2028 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
2029
2030 let baseline = AnalysisSession::load_default(root)
2031 .analyze_dead_code()
2032 .expect("an uncancelled session analyzes");
2033 assert!(
2034 !baseline.results.unused_files.is_empty(),
2035 "the fixture must have findings, so an empty result would be a plausible wrong answer"
2036 );
2037
2038 let error = AnalysisSession::load_default(root)
2039 .with_cancellation(Arc::new(AtomicBool::new(true)))
2040 .analyze_dead_code()
2041 .expect_err("a cancelled session must not return results");
2042 assert!(error.is_cancelled(), "unexpected error: {error}");
2043 assert!(error.message().contains("cancelled"));
2044 }
2045
2046 #[test]
2052 fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
2053 let project = tempfile::tempdir().expect("project");
2054 let root = project.path();
2055 std::fs::create_dir(root.join("src")).expect("create source directory");
2056 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2057
2058 let cold = AnalysisSession::load_default(root)
2059 .analyze_dead_code_with_artifacts(false, true)
2060 .expect("cold run analyzes");
2061 assert_eq!(
2062 cold.timings
2063 .expect("cold timings retained")
2064 .graph_cache_rejection,
2065 Some(CacheRejection::Absent),
2066 "a first run has no persisted graph to refuse"
2067 );
2068
2069 std::fs::write(
2070 root.join("src/index.ts"),
2071 "export const entry = 1;\nexport const added = 2;\n",
2072 )
2073 .expect("edit the entry");
2074
2075 let warm = AnalysisSession::load_default(root)
2076 .analyze_dead_code_with_artifacts(false, true)
2077 .expect("warm run analyzes");
2078 assert_eq!(
2079 warm.timings
2080 .expect("warm timings retained")
2081 .graph_cache_rejection,
2082 Some(CacheRejection::FingerprintChanged),
2083 "the decoded graph was refused because a file changed, and the run must say so"
2084 );
2085 }
2086
2087 #[test]
2090 fn a_session_without_a_token_is_never_cancelled() {
2091 let (_project, session) = session_with_source("export const unused = 1;\n");
2092 assert!(!session.is_cancelled());
2093 session
2094 .analyze_dead_code()
2095 .expect("a session without a token analyzes");
2096 }
2097
2098 const PARSE_LOOP_FILES: usize = 400;
2102
2103 fn parse_loop_project() -> tempfile::TempDir {
2104 let project = tempfile::tempdir().expect("project");
2105 let src = project.path().join("src");
2106 std::fs::create_dir_all(&src).expect("src dir");
2107 for module in 0..PARSE_LOOP_FILES {
2108 let mut source = String::new();
2109 for symbol in 0..20 {
2110 let _ = writeln!(
2111 source,
2112 "export const helper{symbol} = (input: number): number => {{\n \
2113 if (input > {symbol}) {{\n return input * {symbol};\n }}\n \
2114 return input - {symbol};\n}};"
2115 );
2116 }
2117 std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
2118 }
2119 project
2120 }
2121
2122 #[cfg(unix)]
2129 #[test]
2130 fn a_refresh_drops_modules_whose_fingerprints_need_a_content_check() {
2131 let (_project, mut session) = session_with_source("export const kept = 1;\n");
2132 drop(session.parse_modules(false, None));
2133
2134 assert!(!session.refresh_discovery(), "the file set is the same");
2135 assert!(
2136 session.parsed_cache.lock().expect("parse cache").is_some(),
2137 "fingerprints with a known ctime keep the modules for the next run"
2138 );
2139
2140 if let Some(cache) = session
2141 .parsed_cache
2142 .get_mut()
2143 .expect("parse cache")
2144 .as_mut()
2145 {
2146 for fingerprint in &mut cache.fingerprints {
2147 fingerprint.ctime_ns = 0;
2148 }
2149 }
2150 assert!(!session.refresh_discovery(), "the file set is the same");
2151 assert!(
2152 session.parsed_cache.lock().expect("parse cache").is_none(),
2153 "the next run parses through the persisted cache, which checks the content"
2154 );
2155 }
2156
2157 #[cfg(unix)]
2161 #[test]
2162 fn a_flush_stores_an_unsettled_fingerprint_without_its_ctime() {
2163 let (project, mut session) = session_with_source("export const kept = 1;\n");
2164 drop(session.parse_modules(false, None));
2165 let path = project.path().join("src/index.ts");
2166 let stored_ctime = |session: &AnalysisSession| {
2167 let _ = std::fs::remove_dir_all(&session.config.cache_dir);
2168 session.disk_cache_stale.store(true, Ordering::SeqCst);
2169 session.flush_parse_cache();
2170 let store = fallow_extract::cache::CacheStore::load(
2171 &session.config.cache_dir,
2172 &session.config.root,
2173 session.config.cache_config_hash,
2174 crate::project_config::resolve_cache_max_size_bytes(&session.config),
2175 )
2176 .expect("flush writes the parse cache");
2177 store
2178 .get_by_path_only(&path)
2179 .expect("flushed module")
2180 .ctime_ns
2181 };
2182
2183 let set_read_started = |session: &mut AnalysisSession, offset: u64| {
2186 if let Some(cache) = session
2187 .parsed_cache
2188 .get_mut()
2189 .expect("parse cache")
2190 .as_mut()
2191 {
2192 let ctime = cache.fingerprints[0].ctime_ns;
2193 assert!(ctime > 0, "the platform reports a ctime");
2194 cache.read_started_ns = ctime.saturating_add(offset);
2195 }
2196 };
2197
2198 set_read_started(&mut session, 0);
2199 assert_eq!(
2200 stored_ctime(&session),
2201 0,
2202 "a fingerprint inside the window is not settled"
2203 );
2204
2205 set_read_started(
2206 &mut session,
2207 fallow_types::source_fingerprint::TIMESTAMP_SETTLE_WINDOW_NS,
2208 );
2209 assert!(
2210 stored_ctime(&session) > 0,
2211 "a settled fingerprint keeps its ctime"
2212 );
2213 }
2214
2215 #[cfg(unix)]
2219 #[test]
2220 fn a_same_tick_rewrite_does_not_reuse_the_kept_module() {
2221 let (project, mut session) = session_with_source("export const alpha = 1;\n");
2222 drop(session.parse_modules(false, None));
2223
2224 let path = project.path().join("src/index.ts");
2225 std::fs::write(&path, "export const bravo = 1;\n").expect("rewrite source");
2226 let same_tick = SourceFingerprint::from_metadata(
2227 &std::fs::metadata(&path).expect("metadata after rewrite"),
2228 );
2229 if let Some(cache) = session
2230 .parsed_cache
2231 .get_mut()
2232 .expect("parse cache")
2233 .as_mut()
2234 {
2235 cache.fingerprints = vec![same_tick];
2236 }
2237
2238 let modules = session.parse_modules(false, None).modules;
2239 let exports: Vec<String> = modules
2240 .iter()
2241 .flat_map(|module| module.exports.iter())
2242 .map(|export| export.name.to_string())
2243 .collect();
2244 assert_eq!(exports, vec!["bravo".to_string()]);
2245 }
2246
2247 fn uncached_session(root: &Path) -> AnalysisSession {
2250 let mut project_config = crate::project_config::default_project_config(root);
2251 project_config.config.no_cache = true;
2252 AnalysisSession::from_config(project_config)
2253 }
2254
2255 #[test]
2264 fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
2265 let project = parse_loop_project();
2266 let root = project.path();
2267
2268 let token = Arc::new(AtomicBool::new(true));
2271 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
2272 let stopped = session.parse_modules(false, Some(&token));
2273 assert!(stopped.modules.is_empty(), "the loop never read the token");
2274 assert!(
2275 session.parsed_cache.lock().expect("parse cache").is_none(),
2276 "a truncated parse must not be retained as this session's warm cache"
2277 );
2278
2279 drop(uncached_session(root).parse_modules(false, None));
2281 let session = uncached_session(root);
2282 let started = Instant::now();
2283 let full_count = session.parse_modules(false, None).modules.len();
2284 let full_parse = started.elapsed();
2285 assert_eq!(
2286 full_count, PARSE_LOOP_FILES,
2287 "the fixture must parse every generated module"
2288 );
2289
2290 let mut shorter_than = None::<Duration>;
2295 let mut longer_than = Duration::ZERO;
2296 let mut delay = full_parse / 2;
2297 let mut stopped_part_way = false;
2298 for _ in 0..16 {
2299 let token = Arc::new(AtomicBool::new(false));
2300 let session = uncached_session(root).with_cancellation(Arc::clone(&token));
2303 let watchdog = {
2304 let token = Arc::clone(&token);
2305 std::thread::spawn(move || {
2306 std::thread::sleep(delay);
2307 token.store(true, Ordering::SeqCst);
2308 })
2309 };
2310 let cancelled = session.parse_modules(false, Some(&token));
2311 watchdog.join().expect("watchdog thread");
2312
2313 let parsed = cancelled.modules.len();
2314 if parsed == full_count {
2315 shorter_than = Some(delay);
2316 } else if parsed == 0 {
2317 longer_than = delay;
2318 } else {
2319 stopped_part_way = true;
2320 break;
2321 }
2322 delay = match shorter_than {
2323 Some(upper) => (longer_than + upper) / 2,
2324 None => delay * 2,
2325 };
2326 }
2327 assert!(
2328 stopped_part_way,
2329 "no attempt in 16 stopped the parse part way through"
2330 );
2331
2332 let recovered = uncached_session(root).parse_modules(false, None);
2333 assert_eq!(
2334 recovered.modules.len(),
2335 full_count,
2336 "a later uncancelled parse must still see the whole project"
2337 );
2338 }
2339
2340 #[test]
2341 fn session_retains_workspace_metadata_from_config_load() {
2342 let project = tempfile::tempdir().expect("project");
2343 let root = project.path();
2344 std::fs::write(
2345 root.join("package.json"),
2346 r#"{"name":"root","workspaces":["packages/*"]}"#,
2347 )
2348 .expect("write root package");
2349 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
2350 std::fs::write(
2351 root.join("packages/a/package.json"),
2352 r#"{"name":"pkg-a","type":"module"}"#,
2353 )
2354 .expect("write workspace package");
2355
2356 let session = AnalysisSession::load(root, None).expect("session loads");
2357
2358 assert!(
2359 session
2360 .workspaces()
2361 .iter()
2362 .any(|workspace| workspace.name == "pkg-a"),
2363 "session must retain workspace metadata discovered during config load"
2364 );
2365 }
2366
2367 #[test]
2368 fn finding_ignore_filters_results_without_removing_graph_inputs() {
2369 let project = tempfile::tempdir().expect("project");
2370 let root = project.path();
2371 std::fs::create_dir(root.join("src")).expect("create source directory");
2372 std::fs::write(
2373 root.join("package.json"),
2374 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
2375 )
2376 .expect("write package manifest");
2377 std::fs::write(
2378 root.join("vitest.config.ts"),
2379 "import './src/feature';\nexport default {};\n",
2380 )
2381 .expect("write vitest config");
2382 std::fs::write(
2383 root.join("src/feature.ts"),
2384 "export const feature = true;\n",
2385 )
2386 .expect("write reachable source");
2387 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
2388 .expect("write hidden source");
2389
2390 let unfiltered = AnalysisSession::load(root, None)
2391 .expect("unfiltered session loads")
2392 .analyze_dead_code()
2393 .expect("unfiltered analysis succeeds");
2394 assert!(
2395 unfiltered
2396 .results
2397 .unused_files
2398 .iter()
2399 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
2400 );
2401
2402 std::fs::write(
2403 root.join(".fallowrc.json"),
2404 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
2405 )
2406 .expect("write fallow config");
2407 let session = AnalysisSession::load(root, None).expect("filtered session loads");
2408 let hidden_path = root.join("src/hidden.ts");
2409 assert!(session.files().iter().any(|file| file.path == hidden_path));
2410
2411 let filtered = session
2412 .analyze_dead_code_with_artifacts(false, true)
2413 .expect("filtered analysis succeeds");
2414 assert!(
2415 filtered
2416 .results
2417 .unused_files
2418 .iter()
2419 .all(|finding| finding.file.path != hidden_path)
2420 );
2421 assert!(
2422 filtered
2423 .graph
2424 .as_ref()
2425 .is_some_and(|graph| graph.module_count() == session.files().len())
2426 );
2427 }
2428
2429 #[test]
2430 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
2431 use fallow_types::output_dead_code::UnusedFileFinding;
2432 use fallow_types::results::UnusedFile;
2433
2434 let project = tempfile::tempdir().expect("project");
2435 let config = serde_json::from_str::<fallow_config::FallowConfig>(
2436 r#"{"ignoreFindings":["**/*.ts"]}"#,
2437 )
2438 .expect("config parses")
2439 .resolve(
2440 project.path().to_path_buf(),
2441 fallow_config::OutputFormat::Human,
2442 1,
2443 true,
2444 true,
2445 None,
2446 );
2447 let outside = project
2448 .path()
2449 .parent()
2450 .expect("project has parent")
2451 .join("outside.ts");
2452 let mut results = AnalysisResults {
2453 unused_files: vec![
2454 UnusedFileFinding::with_actions(UnusedFile {
2455 path: PathBuf::from(r"src\hidden.ts"),
2456 }),
2457 UnusedFileFinding::with_actions(UnusedFile {
2458 path: outside.clone(),
2459 }),
2460 ],
2461 ..AnalysisResults::default()
2462 };
2463
2464 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
2465
2466 assert_eq!(results.unused_files.len(), 1);
2467 assert_eq!(results.unused_files[0].file.path, outside);
2468 }
2469
2470 #[cfg(unix)]
2473 fn warm_store_project() -> tempfile::TempDir {
2474 let project = tempfile::tempdir().expect("project");
2475 let root = project.path();
2476 std::fs::create_dir(root.join("src")).expect("create source directory");
2477 std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2478 std::fs::write(root.join("src/other.ts"), "export const other = 2;\n").expect("other");
2479 project
2480 }
2481
2482 #[cfg(unix)]
2483 fn warm_session(root: &Path, store: &Arc<WarmParseStore>) -> AnalysisSession {
2484 AnalysisSession::load_default(root).with_warm_parse(Some(Arc::clone(store)))
2485 }
2486
2487 #[cfg(unix)]
2488 fn exported_names(modules: &[ModuleInfo]) -> Vec<String> {
2489 let mut names: Vec<String> = modules
2490 .iter()
2491 .flat_map(|module| module.exports.iter().map(|export| export.name.to_string()))
2492 .collect();
2493 names.sort();
2494 names
2495 }
2496
2497 #[cfg(unix)]
2498 #[test]
2499 fn a_warm_store_serves_a_new_session_without_parse_work() {
2500 let project = warm_store_project();
2501 let store = Arc::new(WarmParseStore::new(
2502 crate::warm_parse::WarmParseLimits::default(),
2503 ));
2504
2505 let first = warm_session(project.path(), &store).parse_modules(false, None);
2506 let second = warm_session(project.path(), &store).parse_modules(false, None);
2507
2508 assert!(Arc::ptr_eq(&first.modules, &second.modules));
2509 assert_eq!(second.metrics.cache_hits + second.metrics.cache_misses, 0);
2510 let counts = store.counts();
2511 assert_eq!(counts.parse_runs, 1);
2512 assert_eq!(counts.modules_parsed, 2);
2513 assert_eq!(counts.modules_reused, 2);
2514 }
2515
2516 #[cfg(unix)]
2517 #[test]
2518 fn a_warm_store_serves_the_health_parse_of_a_new_session() {
2519 let project = warm_store_project();
2520 let store = Arc::new(WarmParseStore::new(
2521 crate::warm_parse::WarmParseLimits::default(),
2522 ));
2523
2524 drop(warm_session(project.path(), &store).parse_modules(false, None));
2525 let health = warm_session(project.path(), &store).parsed_parts_uncached(true);
2526
2527 assert_eq!(health.modules.len(), 2);
2528 assert_eq!(health.cache_hits + health.cache_misses, 0);
2529 assert_eq!(
2530 store.counts().parse_runs,
2531 1,
2532 "the first parse computed complexity"
2533 );
2534 }
2535
2536 #[cfg(unix)]
2537 #[test]
2538 fn an_edit_or_a_new_file_makes_the_next_session_parse_again() {
2539 let project = warm_store_project();
2540 let root = project.path();
2541 let store = Arc::new(WarmParseStore::new(
2542 crate::warm_parse::WarmParseLimits::default(),
2543 ));
2544 drop(warm_session(root, &store).parse_modules(false, None));
2545
2546 std::fs::write(root.join("src/other.ts"), "export const renamed = 22;\n").expect("edit");
2547 let edited = warm_session(root, &store).parse_modules(false, None);
2548 assert_eq!(exported_names(&edited.modules), ["entry", "renamed"]);
2549 assert_eq!(
2550 edited.metrics.cache_misses, 1,
2551 "only the edited file is parsed"
2552 );
2553
2554 std::fs::write(root.join("src/added.ts"), "export const added = 3;\n").expect("add");
2555 let added = warm_session(root, &store).parse_modules(false, None);
2556 assert_eq!(
2557 exported_names(&added.modules),
2558 ["added", "entry", "renamed"]
2559 );
2560
2561 let counts = store.counts();
2562 assert_eq!(counts.parse_runs, 3);
2563 assert_eq!(counts.modules_reused, 0);
2564 assert_eq!(store.len(), 2, "each file list keeps its latest parse only");
2565 }
2566
2567 #[cfg(unix)]
2568 #[test]
2569 fn a_session_without_the_persisted_cache_does_not_use_the_store() {
2570 let project = warm_store_project();
2571 let store = Arc::new(WarmParseStore::new(
2572 crate::warm_parse::WarmParseLimits::default(),
2573 ));
2574
2575 drop(
2576 uncached_session(project.path())
2577 .with_warm_parse(Some(Arc::clone(&store)))
2578 .parse_modules(false, None),
2579 );
2580
2581 assert_eq!(
2582 store.counts(),
2583 crate::warm_parse::WarmParseCounts::default()
2584 );
2585 assert!(store.is_empty());
2586 }
2587
2588 #[cfg(unix)]
2592 #[test]
2593 fn a_kept_parse_records_its_read_failures_again() {
2594 let project = warm_store_project();
2595 let root = project.path();
2596 std::fs::write(root.join("src/broken.ts"), [0xff, 0xfe, 0x00]).expect("invalid UTF-8");
2597 let store = Arc::new(WarmParseStore::new(
2598 crate::warm_parse::WarmParseLimits::default(),
2599 ));
2600 let read_failures = |session: &AnalysisSession| {
2601 session
2602 .current_workspace_diagnostics()
2603 .into_iter()
2604 .filter(|diagnostic| {
2605 matches!(
2606 diagnostic.kind,
2607 fallow_types::workspace::WorkspaceDiagnosticKind::SourceReadFailure { .. }
2608 )
2609 })
2610 .count()
2611 };
2612
2613 let first = warm_session(root, &store);
2614 drop(first.parse_modules(false, None));
2615 assert_eq!(read_failures(&first), 1);
2616
2617 drop(fallow_config::record_source_read_failures(root, &[]));
2618 let second = warm_session(root, &store);
2619 drop(second.parse_modules(false, None));
2620 assert_eq!(store.counts().modules_reused, 2);
2621 assert_eq!(read_failures(&second), 1);
2622 }
2623
2624 #[test]
2625 fn warm_parse_cache_reuses_module_storage() {
2626 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2627 let first = session.parse_modules(true, None);
2628 let second = session.parse_modules(false, None);
2629
2630 assert!(
2631 Arc::ptr_eq(&first.modules, &second.modules),
2632 "warm session queries must share parsed module storage"
2633 );
2634 }
2635
2636 #[test]
2637 fn warm_styling_cache_reuses_artifact_allocation() {
2638 let project = tempfile::tempdir().expect("project");
2639 let root = project.path();
2640 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
2641 .expect("write stylesheet");
2642 let session = AnalysisSession::load_default(root);
2643
2644 let first = session.styling_analysis_artifacts();
2645 let second = session.styling_analysis_artifacts();
2646
2647 assert!(
2648 Arc::ptr_eq(&first, &second),
2649 "warm styling queries must share the cached artifact allocation"
2650 );
2651 }
2652
2653 #[test]
2654 fn shared_parsed_modules_reuse_public_session_storage() {
2655 let (_project, session) = session_with_source("export const value = 1;\n");
2656 let first = session.shared_parsed_modules(true);
2657 let second = session.shared_parsed_modules(false);
2658
2659 assert!(Arc::ptr_eq(&first, &second));
2660 }
2661
2662 #[test]
2663 fn shared_parsed_parts_reuse_public_session_storage() {
2664 let (_project, session) = session_with_source("export const value = 1;\n");
2665 let cached = session.shared_parsed_modules(true);
2666 let parts = session.shared_parsed_parts(false);
2667
2668 assert!(Arc::ptr_eq(&cached, &parts.modules));
2669 }
2670
2671 #[test]
2672 fn warm_complexity_artifacts_reuse_cached_module_storage() {
2673 let (_project, session) = session_with_source("export function value() { return 1; }\n");
2674 let cached = session.parse_modules(true, None);
2675 let artifacts = session
2676 .analyze_dead_code_with_reuse_artifacts(true, true, false)
2677 .expect("analysis succeeds");
2678 let retained = artifacts.modules.expect("complexity modules retained");
2679
2680 assert!(
2681 Arc::ptr_eq(&cached.modules, &retained),
2682 "warm complexity artifacts must share parsed module storage"
2683 );
2684 }
2685
2686 #[test]
2687 fn shared_and_owned_artifacts_preserve_output_bytes() {
2688 let (_project, session) = session_with_source(
2689 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
2690 );
2691 let owned = session
2692 .analyze_dead_code_with_artifacts(true, true)
2693 .expect("owned analysis succeeds");
2694 let shared = session
2695 .analyze_dead_code_with_shared_artifacts(true, true)
2696 .expect("shared analysis succeeds");
2697
2698 assert_eq!(
2699 serde_json::to_vec(&owned.results).expect("serialize owned results"),
2700 serde_json::to_vec(&shared.results).expect("serialize shared results")
2701 );
2702 assert_eq!(owned.file_hashes, shared.file_hashes);
2703 assert_eq!(
2704 owned
2705 .modules
2706 .as_deref()
2707 .unwrap_or_default()
2708 .iter()
2709 .map(|module| module.content_hash)
2710 .collect::<Vec<_>>(),
2711 shared
2712 .modules
2713 .as_deref()
2714 .unwrap_or_default()
2715 .iter()
2716 .map(|module| module.content_hash)
2717 .collect::<Vec<_>>()
2718 );
2719 }
2720
2721 #[test]
2722 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
2723 let project = tempfile::tempdir().expect("project");
2724 let root = project.path();
2725 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
2726 std::fs::write(
2727 root.join("package.json"),
2728 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
2729 )
2730 .expect("write package manifest");
2731 std::fs::write(
2732 root.join("app/routes/home.tsx"),
2733 r#"
2734import { useLoaderData } from "react-router";
2735export function loader() { return { opaque: "value" }; }
2736export default function Home() {
2737 const data = useLoaderData<typeof loader>();
2738 const copy = { ...data };
2739 return JSON.stringify(copy);
2740}
2741"#,
2742 )
2743 .expect("write route module");
2744
2745 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2746 let cold_parse = cold_session.parsed_parts(false);
2747 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
2748 let cold = cold_session
2749 .analyze_dead_code()
2750 .expect("cold analysis succeeds");
2751
2752 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2753 let warm_parse = warm_session.parsed_parts(false);
2754 assert!(
2755 warm_parse.cache_hits > 0,
2756 "second session must use disk cache"
2757 );
2758 let warm = warm_session
2759 .analyze_dead_code()
2760 .expect("warm analysis succeeds");
2761
2762 assert!(
2763 cold.results.unused_load_data_keys.is_empty(),
2764 "cold analysis must abstain for an opaque route-loader use"
2765 );
2766 assert_eq!(
2767 serde_json::to_vec(&cold.results).expect("serialize cold results"),
2768 serde_json::to_vec(&warm.results).expect("serialize warm results"),
2769 "warm route-loader analysis must match cold analysis"
2770 );
2771 }
2772
2773 #[test]
2774 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
2775 let project = tempfile::tempdir().expect("project");
2776 let root = project.path();
2777 std::fs::create_dir(root.join("src")).expect("create source directory");
2778 std::fs::write(
2779 root.join("package.json"),
2780 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
2781 )
2782 .expect("write package manifest");
2783 std::fs::write(
2784 root.join("src/dependency.ts"),
2785 "export function dependency() { return 'real'; }\n",
2786 )
2787 .expect("write dependency");
2788 std::fs::write(
2789 root.join("src/wrapper.ts"),
2790 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
2791 )
2792 .expect("write wrapper");
2793 std::fs::write(
2794 root.join("src/index.ts"),
2795 "export { wrapper } from './wrapper';\n",
2796 )
2797 .expect("write entry point");
2798 std::fs::write(
2799 root.join("src/wrapper.test.ts"),
2800 r#"
2801import { vi } from "vitest";
2802vi.mock("./dependency", () => ({ dependency: () => "mock" }));
2803import { wrapper } from "./wrapper";
2804wrapper();
2805"#,
2806 )
2807 .expect("write test");
2808
2809 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2810 let dependency_id = cold_session
2811 .files()
2812 .iter()
2813 .find(|file| file.path == root.join("src/dependency.ts"))
2814 .expect("dependency discovered")
2815 .id;
2816 let cold = cold_session
2817 .analyze_dead_code_with_artifacts(false, true)
2818 .expect("cold analysis succeeds");
2819 let cold_exports = crate::module_graph::module_value_exports(
2820 cold.graph.as_ref().expect("cold graph retained"),
2821 );
2822 assert!(
2823 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
2824 "cold analysis must persist the graph cache"
2825 );
2826
2827 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2828 let warm = warm_session
2829 .analyze_dead_code_with_artifacts(false, true)
2830 .expect("warm analysis succeeds");
2831 let warm_exports = crate::module_graph::module_value_exports(
2832 warm.graph.as_ref().expect("warm graph retained"),
2833 );
2834
2835 let dependency = cold_exports
2836 .iter()
2837 .find(|export| export.file_id == dependency_id && export.name == "dependency")
2838 .expect("dependency export retained");
2839 assert!(!dependency.test_referenced);
2840 assert_eq!(warm_exports, cold_exports);
2841 }
2842
2843 #[test]
2844 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
2845 let project = tempfile::tempdir().expect("project");
2846 let root = project.path();
2847 std::fs::create_dir(root.join("src")).expect("create source directory");
2848 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
2849 .expect("write package manifest");
2850 for name in ["a.ts", "b.ts", "c.ts"] {
2851 std::fs::write(
2852 root.join("src").join(name),
2853 format!("export const {} = 1;\n", name.replace('.', "_")),
2854 )
2855 .expect("write source");
2856 }
2857 let session = AnalysisSession::load(root, None).expect("session loads");
2858 let removed_path = root.join("src/b.ts");
2859 let removed_id = session
2860 .files()
2861 .iter()
2862 .find(|file| file.path == removed_path)
2863 .expect("removed source discovered")
2864 .id;
2865 std::fs::remove_file(&removed_path).expect("remove source after discovery");
2866
2867 let parts = session.parsed_parts(false);
2868
2869 assert!(
2870 parts
2871 .modules
2872 .iter()
2873 .all(|module| module.file_id != removed_id),
2874 "unreadable file must not receive a placeholder module"
2875 );
2876 let diagnostic = parts
2877 .workspace_diagnostics
2878 .iter()
2879 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2880 .expect("parsed session parts carry source read failure");
2881 assert_eq!(diagnostic.path, removed_path);
2882 assert!(
2883 session
2884 .current_workspace_diagnostics()
2885 .iter()
2886 .any(|diagnostic| {
2887 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
2888 }),
2889 "session output carries parse-time source diagnostics"
2890 );
2891 }
2892
2893 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
2894 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
2895 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
2896
2897 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
2898 diagnostics
2899 .iter()
2900 .any(|diagnostic| diagnostic.kind.id() == id)
2901 }
2902
2903 fn write_single_source_project(root: &Path, manifest: &str) {
2904 std::fs::create_dir(root.join("src")).expect("create source directory");
2905 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
2906 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
2907 .expect("write source");
2908 }
2909
2910 #[test]
2916 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
2917 let project = tempfile::tempdir().expect("project");
2918 let root = project.path();
2919 write_single_source_project(
2920 root,
2921 r#"{"name":"issue-2366-engine-session","private":true}"#,
2922 );
2923 std::fs::write(
2924 root.join("pnpm-workspace.yaml"),
2925 MALFORMED_PNPM_WORKSPACE_YAML,
2926 )
2927 .expect("write malformed workspace yaml");
2928
2929 let broken = AnalysisSession::load(root, None).expect("session loads");
2930 broken
2931 .analyze_dead_code()
2932 .expect("analysis on the malformed yaml succeeds");
2933 assert!(
2934 has_diagnostic_kind(
2935 &broken.current_workspace_diagnostics(),
2936 "malformed-pnpm-workspace-yaml"
2937 ),
2938 "the first session surfaces the malformed yaml: {:?}",
2939 broken.current_workspace_diagnostics()
2940 );
2941
2942 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
2943 .expect("fix workspace yaml");
2944
2945 let fixed = AnalysisSession::load(root, None).expect("session loads");
2946 fixed
2947 .analyze_dead_code()
2948 .expect("analysis on the fixed yaml succeeds");
2949 let current = fixed.current_workspace_diagnostics();
2950 assert!(
2951 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
2952 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
2953 );
2954 }
2955
2956 #[test]
2963 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
2964 let project = tempfile::tempdir().expect("project");
2965 let root = project.path();
2966 write_single_source_project(
2967 root,
2968 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2969 );
2970 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2971 .expect("write bun.lockb placeholder");
2972 let config = fallow_config::FallowConfig::default().resolve(
2973 root.to_path_buf(),
2974 fallow_config::OutputFormat::Json,
2975 1,
2976 true,
2977 true,
2978 None,
2979 );
2980 let reload_config = || {
2981 let (_, diagnostics) =
2982 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2983 .expect("workspace discovery succeeds");
2984 fallow_config::stash_workspace_diagnostics(root, diagnostics);
2985 };
2986
2987 reload_config();
2988 let first =
2989 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
2990 first
2991 .analyze_dead_code()
2992 .expect("analysis with bun.lockb only succeeds");
2993 assert!(
2994 has_diagnostic_kind(
2995 &first.current_workspace_diagnostics(),
2996 "bun-lockb-override-resolution-skipped"
2997 ),
2998 "the first run surfaces the bun.lockb skip: {:?}",
2999 first.current_workspace_diagnostics()
3000 );
3001
3002 std::fs::write(
3003 root.join("bun.lock"),
3004 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
3005 )
3006 .expect("write text bun.lock");
3007
3008 reload_config();
3009 let rerun =
3010 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
3011 rerun
3012 .analyze_dead_code()
3013 .expect("analysis with the text bun.lock succeeds");
3014 let current = rerun.current_workspace_diagnostics();
3015 assert!(
3016 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
3017 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
3018 );
3019 }
3020
3021 #[test]
3034 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
3035 let project = tempfile::tempdir().expect("project");
3036 let root = project.path();
3037 write_single_source_project(
3038 root,
3039 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
3040 );
3041 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
3042 .expect("write oversized source");
3043 let mut config = fallow_config::FallowConfig::default().resolve(
3044 root.to_path_buf(),
3045 fallow_config::OutputFormat::Json,
3046 1,
3047 true,
3048 true,
3049 None,
3050 );
3051 config.max_file_size_bytes = Some(1024);
3052
3053 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
3054
3055 fallow_config::append_workspace_diagnostics(
3060 root,
3061 vec![WorkspaceDiagnostic::new(
3062 root,
3063 root.join("src/other-walk-only.ts"),
3064 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
3065 size_bytes: 4096,
3066 },
3067 )],
3068 );
3069
3070 let current = session.current_workspace_diagnostics();
3071 let skipped: Vec<&Path> = current
3072 .iter()
3073 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
3074 .map(|diagnostic| diagnostic.path.as_path())
3075 .collect();
3076 assert_eq!(
3077 skipped.len(),
3078 1,
3079 "the session reports its own walk's skips only: {skipped:?}"
3080 );
3081 assert!(
3082 skipped[0].ends_with("src/huge.ts"),
3083 "the surviving skip is this walk's own: {skipped:?}"
3084 );
3085 }
3086
3087 #[test]
3095 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
3096 let project = tempfile::tempdir().expect("project");
3097 let root = project.path();
3098 write_single_source_project(
3099 root,
3100 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
3101 );
3102 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
3103 .expect("write bun.lockb placeholder");
3104 let config = fallow_config::FallowConfig::default().resolve(
3105 root.to_path_buf(),
3106 fallow_config::OutputFormat::Json,
3107 1,
3108 true,
3109 true,
3110 None,
3111 );
3112 let reload_config = || {
3113 let (_, diagnostics) =
3114 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
3115 .expect("workspace discovery succeeds");
3116 fallow_config::stash_workspace_diagnostics(root, diagnostics);
3117 };
3118
3119 reload_config();
3120 let analyzing =
3121 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
3122 analyzing
3123 .analyze_dead_code()
3124 .expect("analysis with bun.lockb only succeeds");
3125
3126 reload_config();
3129
3130 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
3131 let current = later.current_workspace_diagnostics();
3132 assert!(
3133 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
3134 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
3135 {current:?}"
3136 );
3137 }
3138}