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