1use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use fallow_config::{DuplicatesConfig, ResolvedConfig, WorkspaceInfo};
8use fallow_types::discover::DiscoveredFile;
9use fallow_types::extract::ModuleInfo;
10#[cfg(test)]
11use fallow_types::results::AnalysisResults;
12use fallow_types::source_fingerprint::SourceFingerprint;
13use fallow_types::workspace::{WorkspaceDiagnostic, merge_workspace_diagnostics};
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use crate::{
17 EngineResult, core_backend, duplicates,
18 project_analysis::{
19 ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
20 },
21 project_config::{ProjectConfig, config_for_project, default_project_config},
22 results::{
23 DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
24 SharedDeadCodeAnalysisArtifacts,
25 },
26};
27
28#[derive(Debug)]
34pub struct AnalysisSession {
35 config: ResolvedConfig,
36 config_path: Option<PathBuf>,
37 discovery: crate::discover::AnalysisDiscovery,
38 workspaces: Vec<WorkspaceInfo>,
39 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
40 parsed_cache: Mutex<Option<ParsedModuleCache>>,
41 styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
42}
43
44#[derive(Debug)]
45struct ParsedModuleCache {
46 need_complexity: bool,
47 fingerprints: Vec<SourceFingerprint>,
48 modules: Arc<[ModuleInfo]>,
49}
50
51#[derive(Debug)]
53pub struct AnalysisSessionParts {
54 pub config: ResolvedConfig,
56 pub config_path: Option<PathBuf>,
58 pub files: Vec<DiscoveredFile>,
60 pub workspaces: Vec<WorkspaceInfo>,
62 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
64}
65
66#[derive(Debug)]
68pub struct ParsedAnalysisSessionParts {
69 pub config: ResolvedConfig,
71 pub config_path: Option<PathBuf>,
73 pub files: Vec<DiscoveredFile>,
75 pub modules: Vec<ModuleInfo>,
77 pub workspaces: Vec<WorkspaceInfo>,
79 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
81 pub parse_ms: f64,
83 pub cache_update_ms: f64,
85 pub cache_hits: usize,
87 pub cache_misses: usize,
89 pub parse_cpu_ms: f64,
91}
92
93#[derive(Debug)]
94pub(crate) struct SharedParsedAnalysisSessionParts {
95 pub(crate) config: ResolvedConfig,
96 pub(crate) files: Vec<DiscoveredFile>,
97 pub(crate) modules: Arc<[ModuleInfo]>,
98 pub workspaces: Vec<WorkspaceInfo>,
99 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
100 pub parse_ms: f64,
101 pub parse_cpu_ms: f64,
102}
103
104#[derive(Debug)]
106pub struct AnalysisSessionArtifacts {
107 pub analysis: DeadCodeAnalysisArtifacts,
109 pub changed_files: Option<FxHashSet<PathBuf>>,
111 pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
113}
114
115impl AnalysisSession {
116 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
122 let project_config = config_for_project(root, config_path)?;
123 Ok(Self::from_config(project_config))
124 }
125
126 pub fn load_with_config(
133 root: &Path,
134 config_path: Option<&Path>,
135 configure: impl FnOnce(&mut ResolvedConfig),
136 ) -> EngineResult<Self> {
137 Self::load_with_config_options(
138 root,
139 config_path,
140 fallow_config::ConfigLoadOptions::default(),
141 configure,
142 )
143 }
144
145 pub fn load_with_config_options(
152 root: &Path,
153 config_path: Option<&Path>,
154 load_options: fallow_config::ConfigLoadOptions,
155 configure: impl FnOnce(&mut ResolvedConfig),
156 ) -> EngineResult<Self> {
157 let mut project_config = crate::project_config::config_for_project_with_load_options(
158 root,
159 config_path,
160 load_options,
161 )?;
162 configure(&mut project_config.config);
163 project_config.workspaces.clear();
164 project_config.workspace_diagnostics.clear();
165 project_config.workspace_discovery_ms = None;
166 Ok(Self::from_config(project_config))
167 }
168
169 #[must_use]
174 pub fn load_default(root: &Path) -> Self {
175 Self::from_config(default_project_config(root))
176 }
177
178 #[must_use]
180 pub fn from_config(project_config: ProjectConfig) -> Self {
181 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
182 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
183 {
184 crate::discover::prepare_analysis_discovery_with_workspaces(
185 &project_config.config,
186 &project_config.workspaces,
187 workspace_discovery_ms,
188 )
189 } else {
190 crate::discover::prepare_analysis_discovery(&project_config.config)
191 };
192 let workspaces = if uses_preloaded_workspaces {
193 project_config.workspaces
194 } else {
195 discovery.workspaces().to_vec()
196 };
197 let workspace_diagnostics = merge_workspace_diagnostics(
209 merge_workspace_diagnostics(
210 project_config.workspace_diagnostics,
211 fallow_config::workspace_diagnostics_for(&project_config.config.root)
212 .into_iter()
213 .filter(|diagnostic| {
214 !diagnostic.kind.is_analysis_stage()
215 && !diagnostic.kind.is_source_discovery()
216 })
217 .collect(),
218 ),
219 discovery.source_diagnostics().to_vec(),
220 );
221 Self {
222 config: project_config.config,
223 config_path: project_config.path,
224 discovery,
225 workspaces,
226 workspace_diagnostics,
227 parsed_cache: Mutex::new(None),
228 styling_cache: Mutex::new(None),
229 }
230 }
231
232 pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
240 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
241 crate::project_config::collect_workspace_metadata(&config)?;
242 Ok(Self::from_config(ProjectConfig {
243 config,
244 path: None,
245 workspaces,
246 workspace_diagnostics,
247 workspace_discovery_ms: Some(workspace_discovery_ms),
248 }))
249 }
250
251 #[must_use]
253 pub fn root(&self) -> &Path {
254 &self.config.root
255 }
256
257 #[must_use]
259 pub fn config(&self) -> &ResolvedConfig {
260 &self.config
261 }
262
263 #[must_use]
265 pub fn config_path(&self) -> Option<&Path> {
266 self.config_path.as_deref()
267 }
268
269 #[must_use]
271 pub fn files(&self) -> &[DiscoveredFile] {
272 self.discovery.files()
273 }
274
275 #[must_use]
277 pub fn workspaces(&self) -> &[WorkspaceInfo] {
278 &self.workspaces
279 }
280
281 #[must_use]
283 fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
284 self.discovery
285 .files()
286 .iter()
287 .map(|file| {
288 let fingerprint = std::fs::metadata(&file.path).map_or_else(
289 |_| SourceFingerprint::new(0, file.size_bytes),
290 |metadata| SourceFingerprint::from_metadata(&metadata),
291 );
292 (file.path.clone(), fingerprint)
293 })
294 .collect()
295 }
296
297 pub(crate) fn changed_files_since(
304 &self,
305 git_ref: &str,
306 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
307 crate::changed_files::changed_files(&self.config.root, git_ref)
308 }
309
310 #[must_use]
312 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
313 &self.workspace_diagnostics
314 }
315
316 #[must_use]
329 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
330 merge_workspace_diagnostics(
331 self.workspace_diagnostics.clone(),
332 fallow_config::registry_diagnostics_to_fold(&self.config.root),
333 )
334 }
335
336 pub(crate) fn styling_analysis_artifacts(
337 &self,
338 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
339 if let Ok(cache) = self.styling_cache.lock()
340 && let Some(artifacts) = cache.as_ref()
341 {
342 return Arc::clone(artifacts);
343 }
344
345 let modules = self.shared_parsed_modules(false);
346 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
347 self.files(),
348 &modules,
349 self.config(),
350 ));
351 if let Ok(mut cache) = self.styling_cache.lock() {
352 *cache = Some(Arc::clone(&artifacts));
353 }
354 artifacts
355 }
356
357 #[must_use]
359 pub fn into_parts(self) -> AnalysisSessionParts {
360 let workspace_diagnostics = self.current_workspace_diagnostics();
361 AnalysisSessionParts {
362 config: self.config,
363 config_path: self.config_path,
364 files: self.discovery.into_files(),
365 workspaces: self.workspaces,
366 workspace_diagnostics,
367 }
368 }
369
370 #[must_use]
372 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
373 let AnalysisSessionParts {
374 config,
375 config_path,
376 files,
377 workspaces,
378 workspace_diagnostics,
379 } = self.into_parts();
380 let ParsedModules {
381 modules,
382 metrics,
383 source_diagnostics,
384 } = parse_files_with_config(&config, &files, need_complexity);
385 ParsedAnalysisSessionParts {
386 config,
387 config_path,
388 files,
389 modules,
390 workspaces,
391 workspace_diagnostics: merge_workspace_diagnostics(
392 workspace_diagnostics,
393 source_diagnostics,
394 ),
395 parse_ms: metrics.parse_ms,
396 cache_update_ms: metrics.cache_ms,
397 cache_hits: metrics.cache_hits,
398 cache_misses: metrics.cache_misses,
399 parse_cpu_ms: metrics.parse_cpu_ms,
400 }
401 }
402
403 #[must_use]
405 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
406 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
407 self.parsed_parts_from_modules(modules.to_vec(), metrics)
408 }
409
410 #[must_use]
412 pub(crate) fn shared_parsed_parts(
413 &self,
414 need_complexity: bool,
415 ) -> SharedParsedAnalysisSessionParts {
416 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
417 SharedParsedAnalysisSessionParts {
418 config: self.config.clone(),
419 files: self.discovery.files().to_vec(),
420 modules,
421 workspaces: self.workspaces.clone(),
422 workspace_diagnostics: self.current_workspace_diagnostics(),
423 parse_ms: metrics.parse_ms,
424 parse_cpu_ms: metrics.parse_cpu_ms,
425 }
426 }
427
428 #[doc(hidden)]
434 #[must_use]
435 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
436 self.parse_modules(need_complexity).modules
437 }
438
439 #[must_use]
442 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
443 let ParsedModules {
444 modules,
445 metrics,
446 source_diagnostics: _,
447 } = parse_files_with_config(&self.config, self.files(), need_complexity);
448 self.parsed_parts_from_modules(modules, metrics)
449 }
450
451 fn parsed_parts_from_modules(
452 &self,
453 modules: Vec<ModuleInfo>,
454 metrics: core_backend::ParseMetrics,
455 ) -> ParsedAnalysisSessionParts {
456 ParsedAnalysisSessionParts {
457 config: self.config.clone(),
458 config_path: self.config_path.clone(),
459 files: self.discovery.files().to_vec(),
460 modules,
461 workspaces: self.workspaces.clone(),
462 workspace_diagnostics: self.current_workspace_diagnostics(),
463 parse_ms: metrics.parse_ms,
464 cache_update_ms: metrics.cache_ms,
465 cache_hits: metrics.cache_hits,
466 cache_misses: metrics.cache_misses,
467 parse_cpu_ms: metrics.parse_cpu_ms,
468 }
469 }
470
471 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
477 self.analyze_dead_code_with_artifacts(false, false)
478 .map(|output| DeadCodeAnalysis {
479 results: output.results,
480 })
481 }
482
483 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
489 self.analyze_dead_code_with_artifacts(true, false)
490 .map(|output| DeadCodeAnalysisOutput {
491 results: output.results,
492 modules: output.modules,
493 files: output.files,
494 })
495 }
496
497 pub fn analyze_dead_code_with_artifacts(
503 &self,
504 need_complexity: bool,
505 retain_graph: bool,
506 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
507 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
508 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
509 }
510
511 #[doc(hidden)]
521 pub fn analyze_dead_code_with_shared_artifacts(
522 &self,
523 need_complexity: bool,
524 retain_graph: bool,
525 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
526 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
527 }
528
529 pub fn analyze_dead_code_retaining_files(
536 &self,
537 need_complexity: bool,
538 retain_graph: bool,
539 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
540 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
541 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
542 }
543
544 pub fn analyze_dead_code_with_parsed_modules(
553 &self,
554 modules: &[ModuleInfo],
555 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
556 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
557 }
558
559 #[doc(hidden)]
565 pub(crate) fn analyze_dead_code_with_shared_modules(
566 &self,
567 modules: Arc<[ModuleInfo]>,
568 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
569 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
570 config: &self.config,
571 discovery: &self.discovery,
572 modules,
573 metrics: reused_parse_metrics(),
574 collect_usages: true,
575 retain_graph: true,
576 retain_modules: false,
577 retain_files: false,
578 })
579 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
580 }
581
582 fn analyze_dead_code_with_reuse_artifacts(
583 &self,
584 need_complexity: bool,
585 retain_graph: bool,
586 retain_files: bool,
587 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
588 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
589 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
590 config: &self.config,
591 discovery: &self.discovery,
592 modules,
593 metrics,
594 collect_usages: true,
595 retain_graph,
596 retain_modules: need_complexity,
597 retain_files,
598 })
599 }
600
601 pub fn analyze_dead_code_with_session_artifacts(
612 &self,
613 need_complexity: bool,
614 retain_graph: bool,
615 changed_files: Option<FxHashSet<PathBuf>>,
616 ) -> EngineResult<AnalysisSessionArtifacts> {
617 Ok(AnalysisSessionArtifacts {
618 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
619 changed_files,
620 source_fingerprints: self.source_fingerprints(),
621 })
622 }
623
624 #[must_use]
626 pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
627 duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
628 }
629
630 #[must_use]
632 pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
633 duplicates::find_duplicates(&self.config.root, self.files(), config)
634 }
635
636 pub fn analyze_project_with(
645 &self,
646 duplicates_config: &DuplicatesConfig,
647 retain_complexity_artifacts: bool,
648 ) -> EngineResult<ProjectAnalysisOutput> {
649 self.analyze_project_with_artifacts(
650 duplicates_config,
651 ProjectAnalysisArtifactOptions {
652 retain_complexity_artifacts,
653 ..ProjectAnalysisArtifactOptions::default()
654 },
655 )
656 .map(ProjectAnalysisArtifacts::into_output)
657 }
658
659 pub fn analyze_project_with_artifacts(
669 &self,
670 duplicates_config: &DuplicatesConfig,
671 options: ProjectAnalysisArtifactOptions,
672 ) -> EngineResult<ProjectAnalysisArtifacts> {
673 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
674 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
675 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
676 self.find_duplicates_touching_files_with_defaults(
677 duplicates_config,
678 &changed_files,
679 cache_dir,
680 )
681 .report
682 } else {
683 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
684 .report
685 };
686 let source_fingerprints = options
687 .collect_source_fingerprints
688 .then(|| self.source_fingerprints());
689 Ok(ProjectAnalysisArtifacts {
690 dead_code: self.analyze_dead_code_with_artifacts(
691 options.retain_complexity_artifacts,
692 options.retain_graph,
693 )?,
694 duplication,
695 changed_files: options.changed_files,
696 source_fingerprints,
697 })
698 }
699
700 #[must_use]
702 pub fn find_duplicates_with_defaults(
703 &self,
704 config: &DuplicatesConfig,
705 cache_dir: Option<&Path>,
706 ) -> DuplicationAnalysis {
707 duplicates::find_duplicates_with_defaults(
708 &self.config.root,
709 self.files(),
710 config,
711 cache_dir,
712 )
713 }
714
715 #[must_use]
717 pub fn find_duplicates_touching_files_with_defaults(
718 &self,
719 config: &DuplicatesConfig,
720 changed_files: &[PathBuf],
721 cache_dir: Option<&Path>,
722 ) -> DuplicationAnalysis {
723 duplicates::find_duplicates_touching_files_with_defaults(
724 &self.config.root,
725 self.files(),
726 config,
727 changed_files,
728 cache_dir,
729 )
730 }
731
732 fn parse_modules(&self, need_complexity: bool) -> SharedParsedModules {
733 let fingerprints = source_fingerprints_for_files(self.files());
734 if let Some(fingerprints) = fingerprints.as_ref()
735 && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
736 {
737 return SharedParsedModules {
738 modules,
739 metrics: core_backend::ParseMetrics {
740 parse_ms: 0.0,
741 cache_ms: 0.0,
742 cache_hits: 0,
743 cache_misses: 0,
744 parse_cpu_ms: 0.0,
745 },
746 };
747 }
748
749 let ParsedModules {
750 modules,
751 metrics,
752 source_diagnostics: _,
753 } = parse_files_with_config(&self.config, self.files(), need_complexity);
754 let modules: Arc<[ModuleInfo]> = modules.into();
755 if let Some(fingerprints) = fingerprints
756 && let Ok(mut cache) = self.parsed_cache.lock()
757 {
758 *cache = Some(ParsedModuleCache {
759 need_complexity,
760 fingerprints,
761 modules: Arc::clone(&modules),
762 });
763 }
764 SharedParsedModules { modules, metrics }
765 }
766
767 fn cached_modules(
768 &self,
769 need_complexity: bool,
770 fingerprints: &[SourceFingerprint],
771 ) -> Option<Arc<[ModuleInfo]>> {
772 let Ok(cache) = self.parsed_cache.lock() else {
773 return None;
774 };
775 let cache = cache.as_ref()?;
776 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
777 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
778 return Some(Arc::clone(&cache.modules));
779 }
780 None
781 }
782}
783
784struct ParsedModules {
785 modules: Vec<ModuleInfo>,
786 metrics: core_backend::ParseMetrics,
787 source_diagnostics: Vec<WorkspaceDiagnostic>,
788}
789
790struct SharedParsedModules {
791 modules: Arc<[ModuleInfo]>,
792 metrics: core_backend::ParseMetrics,
793}
794
795fn parse_files_with_config(
796 config: &ResolvedConfig,
797 files: &[DiscoveredFile],
798 need_complexity: bool,
799) -> ParsedModules {
800 let parse_start = Instant::now();
801 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
802 let mut cache = if config.no_cache {
803 None
804 } else {
805 fallow_extract::cache::CacheStore::load(
806 &config.cache_dir,
807 config.cache_config_hash,
808 cache_max_size_bytes,
809 )
810 };
811 let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
812 let source_diagnostics =
813 fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
814 let mut modules = parse_result.modules;
815 for module in &mut modules {
816 module.prepare_analysis_facts();
817 }
818 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
819 let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &modules, files);
820 let metrics = core_backend::ParseMetrics {
821 parse_ms,
822 cache_ms,
823 cache_hits: parse_result.cache_hits,
824 cache_misses: parse_result.cache_misses,
825 parse_cpu_ms: parse_result.parse_cpu_ms,
826 };
827 ParsedModules {
828 modules,
829 metrics,
830 source_diagnostics,
831 }
832}
833
834fn reused_parse_metrics() -> core_backend::ParseMetrics {
835 core_backend::ParseMetrics {
836 parse_ms: 0.0,
837 cache_ms: 0.0,
838 cache_hits: 0,
839 cache_misses: 0,
840 parse_cpu_ms: 0.0,
841 }
842}
843
844fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
845 files
846 .iter()
847 .map(|file| {
848 std::fs::metadata(&file.path)
849 .ok()
850 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
851 .filter(|fingerprint| fingerprint.has_known_mtime())
852 })
853 .collect()
854}
855
856fn update_parse_cache_if_enabled(
857 config: &ResolvedConfig,
858 cache: &mut Option<fallow_extract::cache::CacheStore>,
859 modules: &[ModuleInfo],
860 files: &[DiscoveredFile],
861) -> f64 {
862 let start = Instant::now();
863 if config.no_cache {
864 return start.elapsed().as_secs_f64() * 1000.0;
865 }
866
867 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
868 let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
869 if update_parse_cache(store, modules, files)
870 && let Err(error) = store.save(
871 &config.cache_dir,
872 config.cache_config_hash,
873 cache_max_size_bytes,
874 )
875 {
876 tracing::warn!("Failed to save cache: {error}");
877 }
878 start.elapsed().as_secs_f64() * 1000.0
879}
880
881fn update_parse_cache(
882 store: &mut fallow_extract::cache::CacheStore,
883 modules: &[ModuleInfo],
884 files: &[DiscoveredFile],
885) -> bool {
886 let mut dirty = false;
887 for module in modules {
888 if let Some(file) = files.get(module.file_id.0 as usize) {
889 let fingerprint = source_fingerprint(&file.path);
890 if let Some(cached) = store.get_by_path_only(&file.path)
891 && cached.content_hash == module.content_hash
892 {
893 if cached.source_fingerprint() != fingerprint {
894 let preserved_last_access = cached.last_access_secs;
895 let mut refreshed =
896 fallow_extract::cache::module_to_cached(module, fingerprint);
897 refreshed.last_access_secs = preserved_last_access;
898 store.insert(&file.path, refreshed);
899 dirty = true;
900 }
901 continue;
902 }
903 store.insert(
904 &file.path,
905 fallow_extract::cache::module_to_cached(module, fingerprint),
906 );
907 dirty = true;
908 }
909 }
910 store.retain_paths(files) || dirty
911}
912
913fn source_fingerprint(path: &Path) -> SourceFingerprint {
914 std::fs::metadata(path).map_or_else(
915 |_| SourceFingerprint::new(0, 0),
916 |metadata| SourceFingerprint::from_metadata(&metadata),
917 )
918}
919
920struct EngineDeadCodePipelineInput<'a> {
921 config: &'a ResolvedConfig,
922 discovery: &'a crate::discover::AnalysisDiscovery,
923 modules: Arc<[ModuleInfo]>,
924 metrics: core_backend::ParseMetrics,
925 collect_usages: bool,
926 retain_graph: bool,
927 retain_modules: bool,
928 retain_files: bool,
929}
930
931fn run_engine_owned_dead_code_pipeline(
932 input: EngineDeadCodePipelineInput<'_>,
933) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
934 let EngineDeadCodePipelineInput {
935 config,
936 discovery,
937 modules,
938 metrics,
939 collect_usages,
940 retain_graph,
941 retain_modules,
942 retain_files,
943 } = input;
944 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
945 let prelude_timings = prelude.timings();
946 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
947 let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
948
949 let mut detector = core_backend::run_dead_code_detectors(
950 &prelude,
951 &graph.graph,
952 &resolved.project.modules,
953 &modules,
954 collect_usages,
955 &entry_points,
956 );
957 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
958 let profile =
959 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
960 retain_timings: retain_graph,
961 prelude: &prelude,
962 prelude_timings,
963 parse_metrics: metrics,
964 module_count: modules.len(),
965 entry_points: &entry_points,
966 resolved: &resolved,
967 graph: &graph,
968 detector: &detector,
969 file_count: discovery.files().len(),
970 workspace_count: discovery.workspaces().len(),
971 });
972 let script_used_packages = prelude.script_used_packages();
973 prelude.finish();
974 let file_hashes = collect_file_hashes(&modules, discovery.files());
975
976 Ok(SharedDeadCodeAnalysisArtifacts {
977 results: detector.results,
978 timings: profile.timings,
979 graph: retain_graph.then_some(graph.graph),
980 modules: retain_modules.then_some(modules),
981 files: retain_files.then(|| discovery.files().to_vec()),
982 script_used_packages,
983 file_hashes,
984 })
985}
986
987fn resolve_or_build_dead_code_graph(
988 prelude: &core_backend::DeadCodeBackendPrelude,
989 entry_points: &core_backend::DeadCodeEntryPoints,
990 modules: &[ModuleInfo],
991) -> (
992 core_backend::DeadCodeResolvedModules,
993 core_backend::DeadCodeGraphRun,
994) {
995 if let Some((resolved, graph)) =
996 core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
997 {
998 return (resolved, graph);
999 }
1000
1001 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1002 let graph =
1003 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1004 (resolved, graph)
1005}
1006
1007fn collect_file_hashes(
1008 modules: &[ModuleInfo],
1009 files: &[DiscoveredFile],
1010) -> FxHashMap<PathBuf, u64> {
1011 modules
1012 .iter()
1013 .filter_map(|module| {
1014 files
1015 .get(module.file_id.0 as usize)
1016 .map(|file| (file.path.clone(), module.content_hash))
1017 })
1018 .collect()
1019}
1020
1021pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1022 config: &ResolvedConfig,
1023 modules: &[ModuleInfo],
1024) -> EngineResult<DeadCodeAnalysisArtifacts> {
1025 let (workspaces, _diagnostics, workspaces_ms) =
1026 crate::project_config::collect_workspace_metadata(config)?;
1027 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1028 config,
1029 &workspaces,
1030 workspaces_ms,
1031 );
1032 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1033 config,
1034 discovery: &discovery,
1035 modules: Arc::from(modules),
1036 metrics: reused_parse_metrics(),
1037 collect_usages: true,
1038 retain_graph: true,
1039 retain_modules: false,
1040 retain_files: false,
1041 })
1042 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1050 let project = tempfile::tempdir().expect("project");
1051 let root = project.path();
1052 std::fs::create_dir(root.join("src")).expect("create source directory");
1053 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1054 let session = AnalysisSession::load_default(root);
1055 (project, session)
1056 }
1057
1058 #[test]
1059 fn session_retains_workspace_metadata_from_config_load() {
1060 let project = tempfile::tempdir().expect("project");
1061 let root = project.path();
1062 std::fs::write(
1063 root.join("package.json"),
1064 r#"{"name":"root","workspaces":["packages/*"]}"#,
1065 )
1066 .expect("write root package");
1067 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1068 std::fs::write(
1069 root.join("packages/a/package.json"),
1070 r#"{"name":"pkg-a","type":"module"}"#,
1071 )
1072 .expect("write workspace package");
1073
1074 let session = AnalysisSession::load(root, None).expect("session loads");
1075
1076 assert!(
1077 session
1078 .workspaces()
1079 .iter()
1080 .any(|workspace| workspace.name == "pkg-a"),
1081 "session must retain workspace metadata discovered during config load"
1082 );
1083 }
1084
1085 #[test]
1086 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1087 let project = tempfile::tempdir().expect("project");
1088 let root = project.path();
1089 std::fs::create_dir(root.join("src")).expect("create source directory");
1090 std::fs::write(
1091 root.join("package.json"),
1092 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1093 )
1094 .expect("write package manifest");
1095 std::fs::write(
1096 root.join("vitest.config.ts"),
1097 "import './src/feature';\nexport default {};\n",
1098 )
1099 .expect("write vitest config");
1100 std::fs::write(
1101 root.join("src/feature.ts"),
1102 "export const feature = true;\n",
1103 )
1104 .expect("write reachable source");
1105 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
1106 .expect("write hidden source");
1107
1108 let unfiltered = AnalysisSession::load(root, None)
1109 .expect("unfiltered session loads")
1110 .analyze_dead_code()
1111 .expect("unfiltered analysis succeeds");
1112 assert!(
1113 unfiltered
1114 .results
1115 .unused_files
1116 .iter()
1117 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
1118 );
1119
1120 std::fs::write(
1121 root.join(".fallowrc.json"),
1122 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1123 )
1124 .expect("write fallow config");
1125 let session = AnalysisSession::load(root, None).expect("filtered session loads");
1126 let hidden_path = root.join("src/hidden.ts");
1127 assert!(session.files().iter().any(|file| file.path == hidden_path));
1128
1129 let filtered = session
1130 .analyze_dead_code_with_artifacts(false, true)
1131 .expect("filtered analysis succeeds");
1132 assert!(
1133 filtered
1134 .results
1135 .unused_files
1136 .iter()
1137 .all(|finding| finding.file.path != hidden_path)
1138 );
1139 assert!(
1140 filtered
1141 .graph
1142 .as_ref()
1143 .is_some_and(|graph| graph.module_count() == session.files().len())
1144 );
1145 }
1146
1147 #[test]
1148 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
1149 use fallow_types::output_dead_code::UnusedFileFinding;
1150 use fallow_types::results::UnusedFile;
1151
1152 let project = tempfile::tempdir().expect("project");
1153 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1154 r#"{"ignoreFindings":["**/*.ts"]}"#,
1155 )
1156 .expect("config parses")
1157 .resolve(
1158 project.path().to_path_buf(),
1159 fallow_config::OutputFormat::Human,
1160 1,
1161 true,
1162 true,
1163 None,
1164 );
1165 let outside = project
1166 .path()
1167 .parent()
1168 .expect("project has parent")
1169 .join("outside.ts");
1170 let mut results = AnalysisResults {
1171 unused_files: vec![
1172 UnusedFileFinding::with_actions(UnusedFile {
1173 path: PathBuf::from(r"src\hidden.ts"),
1174 }),
1175 UnusedFileFinding::with_actions(UnusedFile {
1176 path: outside.clone(),
1177 }),
1178 ],
1179 ..AnalysisResults::default()
1180 };
1181
1182 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
1183
1184 assert_eq!(results.unused_files.len(), 1);
1185 assert_eq!(results.unused_files[0].file.path, outside);
1186 }
1187
1188 #[test]
1189 fn warm_parse_cache_reuses_module_storage() {
1190 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1191 let first = session.parse_modules(true);
1192 let second = session.parse_modules(false);
1193
1194 assert!(
1195 Arc::ptr_eq(&first.modules, &second.modules),
1196 "warm session queries must share parsed module storage"
1197 );
1198 }
1199
1200 #[test]
1201 fn warm_styling_cache_reuses_artifact_allocation() {
1202 let project = tempfile::tempdir().expect("project");
1203 let root = project.path();
1204 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
1205 .expect("write stylesheet");
1206 let session = AnalysisSession::load_default(root);
1207
1208 let first = session.styling_analysis_artifacts();
1209 let second = session.styling_analysis_artifacts();
1210
1211 assert!(
1212 Arc::ptr_eq(&first, &second),
1213 "warm styling queries must share the cached artifact allocation"
1214 );
1215 }
1216
1217 #[test]
1218 fn shared_parsed_modules_reuse_public_session_storage() {
1219 let (_project, session) = session_with_source("export const value = 1;\n");
1220 let first = session.shared_parsed_modules(true);
1221 let second = session.shared_parsed_modules(false);
1222
1223 assert!(Arc::ptr_eq(&first, &second));
1224 }
1225
1226 #[test]
1227 fn parsed_parts_keep_owned_module_compatibility() {
1228 let (_project, session) = session_with_source("export const value = 1;\n");
1229 let parts: ParsedAnalysisSessionParts = session.parsed_parts(false);
1230
1231 let _: Vec<ModuleInfo> = parts.modules;
1232 }
1233
1234 #[test]
1235 fn shared_parsed_parts_reuse_public_session_storage() {
1236 let (_project, session) = session_with_source("export const value = 1;\n");
1237 let cached = session.shared_parsed_modules(true);
1238 let parts = session.shared_parsed_parts(false);
1239
1240 assert!(Arc::ptr_eq(&cached, &parts.modules));
1241 }
1242
1243 #[test]
1244 fn warm_complexity_artifacts_reuse_cached_module_storage() {
1245 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1246 let cached = session.parse_modules(true);
1247 let artifacts = session
1248 .analyze_dead_code_with_reuse_artifacts(true, true, false)
1249 .expect("analysis succeeds");
1250 let retained = artifacts.modules.expect("complexity modules retained");
1251
1252 assert!(
1253 Arc::ptr_eq(&cached.modules, &retained),
1254 "warm complexity artifacts must share parsed module storage"
1255 );
1256 }
1257
1258 #[test]
1259 fn shared_and_owned_artifacts_preserve_output_bytes() {
1260 let (_project, session) = session_with_source(
1261 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1262 );
1263 let owned = session
1264 .analyze_dead_code_with_artifacts(true, true)
1265 .expect("owned analysis succeeds");
1266 let shared = session
1267 .analyze_dead_code_with_shared_artifacts(true, true)
1268 .expect("shared analysis succeeds");
1269
1270 assert_eq!(
1271 serde_json::to_vec(&owned.results).expect("serialize owned results"),
1272 serde_json::to_vec(&shared.results).expect("serialize shared results")
1273 );
1274 assert_eq!(owned.file_hashes, shared.file_hashes);
1275 assert_eq!(
1276 owned
1277 .modules
1278 .as_deref()
1279 .unwrap_or_default()
1280 .iter()
1281 .map(|module| module.content_hash)
1282 .collect::<Vec<_>>(),
1283 shared
1284 .modules
1285 .as_deref()
1286 .unwrap_or_default()
1287 .iter()
1288 .map(|module| module.content_hash)
1289 .collect::<Vec<_>>()
1290 );
1291 }
1292
1293 #[test]
1294 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1295 let project = tempfile::tempdir().expect("project");
1296 let root = project.path();
1297 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1298 std::fs::write(
1299 root.join("package.json"),
1300 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1301 )
1302 .expect("write package manifest");
1303 std::fs::write(
1304 root.join("app/routes/home.tsx"),
1305 r#"
1306import { useLoaderData } from "react-router";
1307export function loader() { return { opaque: "value" }; }
1308export default function Home() {
1309 const data = useLoaderData<typeof loader>();
1310 const copy = { ...data };
1311 return JSON.stringify(copy);
1312}
1313"#,
1314 )
1315 .expect("write route module");
1316
1317 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1318 let cold_parse = cold_session.parsed_parts(false);
1319 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1320 let cold = cold_session
1321 .analyze_dead_code()
1322 .expect("cold analysis succeeds");
1323
1324 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1325 let warm_parse = warm_session.parsed_parts(false);
1326 assert!(
1327 warm_parse.cache_hits > 0,
1328 "second session must use disk cache"
1329 );
1330 let warm = warm_session
1331 .analyze_dead_code()
1332 .expect("warm analysis succeeds");
1333
1334 assert!(
1335 cold.results.unused_load_data_keys.is_empty(),
1336 "cold analysis must abstain for an opaque route-loader use"
1337 );
1338 assert_eq!(
1339 serde_json::to_vec(&cold.results).expect("serialize cold results"),
1340 serde_json::to_vec(&warm.results).expect("serialize warm results"),
1341 "warm route-loader analysis must match cold analysis"
1342 );
1343 }
1344
1345 #[test]
1346 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
1347 let project = tempfile::tempdir().expect("project");
1348 let root = project.path();
1349 std::fs::create_dir(root.join("src")).expect("create source directory");
1350 std::fs::write(
1351 root.join("package.json"),
1352 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
1353 )
1354 .expect("write package manifest");
1355 std::fs::write(
1356 root.join("src/dependency.ts"),
1357 "export function dependency() { return 'real'; }\n",
1358 )
1359 .expect("write dependency");
1360 std::fs::write(
1361 root.join("src/wrapper.ts"),
1362 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
1363 )
1364 .expect("write wrapper");
1365 std::fs::write(
1366 root.join("src/index.ts"),
1367 "export { wrapper } from './wrapper';\n",
1368 )
1369 .expect("write entry point");
1370 std::fs::write(
1371 root.join("src/wrapper.test.ts"),
1372 r#"
1373import { vi } from "vitest";
1374vi.mock("./dependency", () => ({ dependency: () => "mock" }));
1375import { wrapper } from "./wrapper";
1376wrapper();
1377"#,
1378 )
1379 .expect("write test");
1380
1381 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1382 let dependency_id = cold_session
1383 .files()
1384 .iter()
1385 .find(|file| file.path == root.join("src/dependency.ts"))
1386 .expect("dependency discovered")
1387 .id;
1388 let cold = cold_session
1389 .analyze_dead_code_with_artifacts(false, true)
1390 .expect("cold analysis succeeds");
1391 let cold_exports = crate::module_graph::module_value_exports(
1392 cold.graph.as_ref().expect("cold graph retained"),
1393 );
1394 assert!(
1395 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_some(),
1396 "cold analysis must persist the graph cache"
1397 );
1398
1399 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1400 let warm = warm_session
1401 .analyze_dead_code_with_artifacts(false, true)
1402 .expect("warm analysis succeeds");
1403 let warm_exports = crate::module_graph::module_value_exports(
1404 warm.graph.as_ref().expect("warm graph retained"),
1405 );
1406
1407 let dependency = cold_exports
1408 .iter()
1409 .find(|export| export.file_id == dependency_id && export.name == "dependency")
1410 .expect("dependency export retained");
1411 assert!(!dependency.test_referenced);
1412 assert_eq!(warm_exports, cold_exports);
1413 }
1414
1415 #[test]
1416 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1417 let project = tempfile::tempdir().expect("project");
1418 let root = project.path();
1419 std::fs::create_dir(root.join("src")).expect("create source directory");
1420 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1421 .expect("write package manifest");
1422 for name in ["a.ts", "b.ts", "c.ts"] {
1423 std::fs::write(
1424 root.join("src").join(name),
1425 format!("export const {} = 1;\n", name.replace('.', "_")),
1426 )
1427 .expect("write source");
1428 }
1429 let session = AnalysisSession::load(root, None).expect("session loads");
1430 let removed_path = root.join("src/b.ts");
1431 let removed_id = session
1432 .files()
1433 .iter()
1434 .find(|file| file.path == removed_path)
1435 .expect("removed source discovered")
1436 .id;
1437 std::fs::remove_file(&removed_path).expect("remove source after discovery");
1438
1439 let parts = session.parsed_parts(false);
1440
1441 assert!(
1442 parts
1443 .modules
1444 .iter()
1445 .all(|module| module.file_id != removed_id),
1446 "unreadable file must not receive a placeholder module"
1447 );
1448 let diagnostic = parts
1449 .workspace_diagnostics
1450 .iter()
1451 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1452 .expect("parsed session parts carry source read failure");
1453 assert_eq!(diagnostic.path, removed_path);
1454 assert!(
1455 session
1456 .current_workspace_diagnostics()
1457 .iter()
1458 .any(|diagnostic| {
1459 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1460 }),
1461 "session output carries parse-time source diagnostics"
1462 );
1463 }
1464
1465 const MALFORMED_PNPM_WORKSPACE_YAML: &str =
1466 "catalog:\n react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
1467 const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n react: ^18.2.0\n";
1468
1469 fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
1470 diagnostics
1471 .iter()
1472 .any(|diagnostic| diagnostic.kind.id() == id)
1473 }
1474
1475 fn write_single_source_project(root: &Path, manifest: &str) {
1476 std::fs::create_dir(root.join("src")).expect("create source directory");
1477 std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
1478 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
1479 .expect("write source");
1480 }
1481
1482 #[test]
1488 fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
1489 let project = tempfile::tempdir().expect("project");
1490 let root = project.path();
1491 write_single_source_project(
1492 root,
1493 r#"{"name":"issue-2366-engine-session","private":true}"#,
1494 );
1495 std::fs::write(
1496 root.join("pnpm-workspace.yaml"),
1497 MALFORMED_PNPM_WORKSPACE_YAML,
1498 )
1499 .expect("write malformed workspace yaml");
1500
1501 let broken = AnalysisSession::load(root, None).expect("session loads");
1502 broken
1503 .analyze_dead_code()
1504 .expect("analysis on the malformed yaml succeeds");
1505 assert!(
1506 has_diagnostic_kind(
1507 &broken.current_workspace_diagnostics(),
1508 "malformed-pnpm-workspace-yaml"
1509 ),
1510 "the first session surfaces the malformed yaml: {:?}",
1511 broken.current_workspace_diagnostics()
1512 );
1513
1514 std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
1515 .expect("fix workspace yaml");
1516
1517 let fixed = AnalysisSession::load(root, None).expect("session loads");
1518 fixed
1519 .analyze_dead_code()
1520 .expect("analysis on the fixed yaml succeeds");
1521 let current = fixed.current_workspace_diagnostics();
1522 assert!(
1523 !has_diagnostic_kind(¤t, "malformed-pnpm-workspace-yaml"),
1524 "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
1525 );
1526 }
1527
1528 #[test]
1535 fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
1536 let project = tempfile::tempdir().expect("project");
1537 let root = project.path();
1538 write_single_source_project(
1539 root,
1540 r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
1541 );
1542 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
1543 .expect("write bun.lockb placeholder");
1544 let config = fallow_config::FallowConfig::default().resolve(
1545 root.to_path_buf(),
1546 fallow_config::OutputFormat::Json,
1547 1,
1548 true,
1549 true,
1550 None,
1551 );
1552 let reload_config = || {
1553 let (_, diagnostics) =
1554 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
1555 .expect("workspace discovery succeeds");
1556 fallow_config::stash_workspace_diagnostics(root, diagnostics);
1557 };
1558
1559 reload_config();
1560 let first =
1561 AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
1562 first
1563 .analyze_dead_code()
1564 .expect("analysis with bun.lockb only succeeds");
1565 assert!(
1566 has_diagnostic_kind(
1567 &first.current_workspace_diagnostics(),
1568 "bun-lockb-override-resolution-skipped"
1569 ),
1570 "the first run surfaces the bun.lockb skip: {:?}",
1571 first.current_workspace_diagnostics()
1572 );
1573
1574 std::fs::write(
1575 root.join("bun.lock"),
1576 r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
1577 )
1578 .expect("write text bun.lock");
1579
1580 reload_config();
1581 let rerun =
1582 AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
1583 rerun
1584 .analyze_dead_code()
1585 .expect("analysis with the text bun.lock succeeds");
1586 let current = rerun.current_workspace_diagnostics();
1587 assert!(
1588 !has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
1589 "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
1590 );
1591 }
1592
1593 #[test]
1606 fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
1607 let project = tempfile::tempdir().expect("project");
1608 let root = project.path();
1609 write_single_source_project(
1610 root,
1611 r#"{"name":"issue-2366-parallel-walks","private":true}"#,
1612 );
1613 std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
1614 .expect("write oversized source");
1615 let mut config = fallow_config::FallowConfig::default().resolve(
1616 root.to_path_buf(),
1617 fallow_config::OutputFormat::Json,
1618 1,
1619 true,
1620 true,
1621 None,
1622 );
1623 config.max_file_size_bytes = Some(1024);
1624
1625 let session = AnalysisSession::from_resolved_config(config).expect("session loads");
1626
1627 fallow_config::append_workspace_diagnostics(
1632 root,
1633 vec![WorkspaceDiagnostic::new(
1634 root,
1635 root.join("src/other-walk-only.ts"),
1636 fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
1637 size_bytes: 4096,
1638 },
1639 )],
1640 );
1641
1642 let current = session.current_workspace_diagnostics();
1643 let skipped: Vec<&Path> = current
1644 .iter()
1645 .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
1646 .map(|diagnostic| diagnostic.path.as_path())
1647 .collect();
1648 assert_eq!(
1649 skipped.len(),
1650 1,
1651 "the session reports its own walk's skips only: {skipped:?}"
1652 );
1653 assert!(
1654 skipped[0].ends_with("src/huge.ts"),
1655 "the surviving skip is this walk's own: {skipped:?}"
1656 );
1657 }
1658
1659 #[test]
1667 fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
1668 let project = tempfile::tempdir().expect("project");
1669 let root = project.path();
1670 write_single_source_project(
1671 root,
1672 r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
1673 );
1674 std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
1675 .expect("write bun.lockb placeholder");
1676 let config = fallow_config::FallowConfig::default().resolve(
1677 root.to_path_buf(),
1678 fallow_config::OutputFormat::Json,
1679 1,
1680 true,
1681 true,
1682 None,
1683 );
1684 let reload_config = || {
1685 let (_, diagnostics) =
1686 fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
1687 .expect("workspace discovery succeeds");
1688 fallow_config::stash_workspace_diagnostics(root, diagnostics);
1689 };
1690
1691 reload_config();
1692 let analyzing =
1693 AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
1694 analyzing
1695 .analyze_dead_code()
1696 .expect("analysis with bun.lockb only succeeds");
1697
1698 reload_config();
1701
1702 let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
1703 let current = later.current_workspace_diagnostics();
1704 assert!(
1705 has_diagnostic_kind(¤t, "bun-lockb-override-resolution-skipped"),
1706 "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
1707 {current:?}"
1708 );
1709 }
1710}