Skip to main content

fallow_engine/
session.rs

1//! Engine-owned analysis session orchestration.
2
3use 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;
10use fallow_types::source_fingerprint::SourceFingerprint;
11use fallow_types::workspace::WorkspaceDiagnostic;
12use rustc_hash::{FxHashMap, FxHashSet};
13
14use crate::{
15    EngineResult, core_backend, duplicates,
16    project_analysis::{
17        ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
18    },
19    project_config::{ProjectConfig, config_for_project, default_project_config},
20    results::{
21        DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
22        SharedDeadCodeAnalysisArtifacts,
23    },
24};
25
26/// Reusable engine session for one resolved project.
27///
28/// The session owns the resolved config and discovered file set so future
29/// consumers can share graph-sensitive inputs without each surface recreating
30/// its own partial orchestration.
31#[derive(Debug)]
32pub struct AnalysisSession {
33    config: ResolvedConfig,
34    config_path: Option<PathBuf>,
35    discovery: crate::discover::AnalysisDiscovery,
36    workspaces: Vec<WorkspaceInfo>,
37    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
38    parsed_cache: Mutex<Option<ParsedModuleCache>>,
39    styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
40}
41
42#[derive(Debug)]
43struct ParsedModuleCache {
44    need_complexity: bool,
45    fingerprints: Vec<SourceFingerprint>,
46    modules: Arc<[ModuleInfo]>,
47}
48
49/// Owned session parts for runners that need to continue an existing pipeline.
50#[derive(Debug)]
51pub struct AnalysisSessionParts {
52    pub config: ResolvedConfig,
53    pub config_path: Option<PathBuf>,
54    pub files: Vec<DiscoveredFile>,
55    pub workspaces: Vec<WorkspaceInfo>,
56    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
57}
58
59/// Owned session parts after parsing the discovered files.
60#[derive(Debug)]
61pub struct ParsedAnalysisSessionParts {
62    pub config: ResolvedConfig,
63    pub config_path: Option<PathBuf>,
64    pub files: Vec<DiscoveredFile>,
65    pub modules: Vec<ModuleInfo>,
66    pub workspaces: Vec<WorkspaceInfo>,
67    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
68    pub parse_ms: f64,
69    pub cache_update_ms: f64,
70    pub cache_hits: usize,
71    pub cache_misses: usize,
72    pub parse_cpu_ms: f64,
73}
74
75#[derive(Debug)]
76pub(crate) struct SharedParsedAnalysisSessionParts {
77    pub(crate) config: ResolvedConfig,
78    pub(crate) files: Vec<DiscoveredFile>,
79    pub(crate) modules: Arc<[ModuleInfo]>,
80    pub workspaces: Vec<WorkspaceInfo>,
81    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
82    pub parse_ms: f64,
83    pub parse_cpu_ms: f64,
84}
85
86/// Reusable artifacts produced by one session-owned dead-code run.
87#[derive(Debug)]
88pub struct AnalysisSessionArtifacts {
89    pub analysis: DeadCodeAnalysisArtifacts,
90    pub changed_files: Option<FxHashSet<PathBuf>>,
91    pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
92}
93
94impl AnalysisSession {
95    /// Load config and discover files for a project root.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error when config loading fails.
100    pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
101        let project_config = config_for_project(root, config_path)?;
102        Ok(Self::from_config(project_config))
103    }
104
105    /// Load config, apply one caller-supplied config adjustment, then discover
106    /// files for a project root.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error when config loading fails.
111    pub fn load_with_config(
112        root: &Path,
113        config_path: Option<&Path>,
114        configure: impl FnOnce(&mut ResolvedConfig),
115    ) -> EngineResult<Self> {
116        Self::load_with_config_options(
117            root,
118            config_path,
119            fallow_config::ConfigLoadOptions::default(),
120            configure,
121        )
122    }
123
124    /// Load config with an explicit inheritance trust policy, apply one
125    /// caller-supplied adjustment, then discover project files.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error when config loading fails.
130    pub fn load_with_config_options(
131        root: &Path,
132        config_path: Option<&Path>,
133        load_options: fallow_config::ConfigLoadOptions,
134        configure: impl FnOnce(&mut ResolvedConfig),
135    ) -> EngineResult<Self> {
136        let mut project_config = crate::project_config::config_for_project_with_load_options(
137            root,
138            config_path,
139            load_options,
140        )?;
141        configure(&mut project_config.config);
142        project_config.workspaces.clear();
143        project_config.workspace_diagnostics.clear();
144        project_config.workspace_discovery_ms = None;
145        Ok(Self::from_config(project_config))
146    }
147
148    /// Build a session from built-in defaults, ignoring project config files.
149    ///
150    /// This is intended for editor fallback paths that have already reported a
151    /// config-load warning but should still surface best-effort diagnostics.
152    #[must_use]
153    pub fn load_default(root: &Path) -> Self {
154        Self::from_config(default_project_config(root))
155    }
156
157    /// Build a session from a previously resolved config.
158    #[must_use]
159    pub fn from_config(project_config: ProjectConfig) -> Self {
160        let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
161        let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
162        {
163            crate::discover::prepare_analysis_discovery_with_workspaces(
164                &project_config.config,
165                &project_config.workspaces,
166                workspace_discovery_ms,
167            )
168        } else {
169            crate::discover::prepare_analysis_discovery(&project_config.config)
170        };
171        let workspaces = if uses_preloaded_workspaces {
172            project_config.workspaces
173        } else {
174            discovery.workspaces().to_vec()
175        };
176        let workspace_diagnostics = merge_workspace_diagnostics(
177            project_config.workspace_diagnostics,
178            fallow_config::workspace_diagnostics_for(&project_config.config.root),
179        );
180        Self {
181            config: project_config.config,
182            config_path: project_config.path,
183            discovery,
184            workspaces,
185            workspace_diagnostics,
186            parsed_cache: Mutex::new(None),
187            styling_cache: Mutex::new(None),
188        }
189    }
190
191    /// Build a session from a resolved config when the caller already owns
192    /// command-specific config loading.
193    #[must_use]
194    pub fn from_resolved_config(config: ResolvedConfig) -> Self {
195        Self::from_config(ProjectConfig {
196            config,
197            path: None,
198            workspaces: Vec::new(),
199            workspace_diagnostics: Vec::new(),
200            workspace_discovery_ms: None,
201        })
202    }
203
204    /// Resolved project root.
205    #[must_use]
206    pub fn root(&self) -> &Path {
207        &self.config.root
208    }
209
210    /// Resolved project config.
211    #[must_use]
212    pub fn config(&self) -> &ResolvedConfig {
213        &self.config
214    }
215
216    /// Config file path when one was loaded.
217    #[must_use]
218    pub fn config_path(&self) -> Option<&Path> {
219        self.config_path.as_deref()
220    }
221
222    /// Discovered files for this session.
223    #[must_use]
224    pub fn files(&self) -> &[DiscoveredFile] {
225        self.discovery.files()
226    }
227
228    /// Workspace packages discovered during config/session setup.
229    #[must_use]
230    pub fn workspaces(&self) -> &[WorkspaceInfo] {
231        &self.workspaces
232    }
233
234    /// Source metadata fingerprints for every discovered source file.
235    #[must_use]
236    pub fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
237        self.discovery
238            .files()
239            .iter()
240            .map(|file| {
241                let fingerprint = std::fs::metadata(&file.path).map_or_else(
242                    |_| SourceFingerprint::new(0, file.size_bytes),
243                    |metadata| SourceFingerprint::from_metadata(&metadata),
244                );
245                (file.path.clone(), fingerprint)
246            })
247            .collect()
248    }
249
250    /// Resolve files changed since a git ref against this session root.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error when the ref is invalid, git is unavailable, or the
255    /// root is not part of a repository.
256    pub fn changed_files_since(
257        &self,
258        git_ref: &str,
259    ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
260        crate::changed_files::changed_files(&self.config.root, git_ref)
261    }
262
263    /// Workspace and source-discovery diagnostics captured for this session.
264    #[must_use]
265    pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
266        &self.workspace_diagnostics
267    }
268
269    /// Current diagnostics, including source read failures discovered lazily
270    /// after the session was created.
271    #[must_use]
272    pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
273        merge_workspace_diagnostics(
274            self.workspace_diagnostics.clone(),
275            fallow_config::workspace_diagnostics_for(&self.config.root),
276        )
277    }
278
279    pub(crate) fn styling_analysis_artifacts(
280        &self,
281    ) -> Arc<crate::health::StylingAnalysisArtifacts> {
282        if let Ok(cache) = self.styling_cache.lock()
283            && let Some(artifacts) = cache.as_ref()
284        {
285            return Arc::clone(artifacts);
286        }
287
288        let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
289            self.files(),
290            self.config(),
291        ));
292        if let Ok(mut cache) = self.styling_cache.lock() {
293            *cache = Some(Arc::clone(&artifacts));
294        }
295        artifacts
296    }
297
298    /// Consume the session and return the resolved config plus discovery data.
299    #[must_use]
300    pub fn into_parts(self) -> AnalysisSessionParts {
301        let workspace_diagnostics = self.current_workspace_diagnostics();
302        AnalysisSessionParts {
303            config: self.config,
304            config_path: self.config_path,
305            files: self.discovery.into_files(),
306            workspaces: self.workspaces,
307            workspace_diagnostics,
308        }
309    }
310
311    /// Consume the session, load the parser cache, and parse discovered files.
312    #[must_use]
313    pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
314        let AnalysisSessionParts {
315            config,
316            config_path,
317            files,
318            workspaces,
319            workspace_diagnostics,
320        } = self.into_parts();
321        let ParsedModules {
322            modules,
323            metrics,
324            source_diagnostics,
325        } = parse_files_with_config(&config, &files, need_complexity);
326        ParsedAnalysisSessionParts {
327            config,
328            config_path,
329            files,
330            modules,
331            workspaces,
332            workspace_diagnostics: merge_workspace_diagnostics(
333                workspace_diagnostics,
334                source_diagnostics,
335            ),
336            parse_ms: metrics.parse_ms,
337            cache_update_ms: metrics.cache_ms,
338            cache_hits: metrics.cache_hits,
339            cache_misses: metrics.cache_misses,
340            parse_cpu_ms: metrics.parse_cpu_ms,
341        }
342    }
343
344    /// Parse discovered files without consuming the session.
345    #[must_use]
346    pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
347        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
348        self.parsed_parts_from_modules(modules.to_vec(), metrics)
349    }
350
351    /// Parse discovered files while retaining shared immutable module storage.
352    #[must_use]
353    pub(crate) fn shared_parsed_parts(
354        &self,
355        need_complexity: bool,
356    ) -> SharedParsedAnalysisSessionParts {
357        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
358        SharedParsedAnalysisSessionParts {
359            config: self.config.clone(),
360            files: self.discovery.files().to_vec(),
361            modules,
362            workspaces: self.workspaces.clone(),
363            workspace_diagnostics: self.current_workspace_diagnostics(),
364            parse_ms: metrics.parse_ms,
365            parse_cpu_ms: metrics.parse_cpu_ms,
366        }
367    }
368
369    /// Return immutable parsed modules backed by the reusable session cache.
370    ///
371    /// Workspace-owned consumers use this additive path when they only need
372    /// parsed modules and can borrow discovery and config directly from the
373    /// session. Stable owned callers can continue using [`Self::parsed_parts`].
374    #[doc(hidden)]
375    #[must_use]
376    pub fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
377        self.parse_modules(need_complexity).modules
378    }
379
380    /// Parse discovered files without consuming the session or retaining parser
381    /// output in the session cache.
382    #[must_use]
383    pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
384        let ParsedModules {
385            modules,
386            metrics,
387            source_diagnostics: _,
388        } = parse_files_with_config(&self.config, self.files(), need_complexity);
389        self.parsed_parts_from_modules(modules, metrics)
390    }
391
392    fn parsed_parts_from_modules(
393        &self,
394        modules: Vec<ModuleInfo>,
395        metrics: core_backend::ParseMetrics,
396    ) -> ParsedAnalysisSessionParts {
397        ParsedAnalysisSessionParts {
398            config: self.config.clone(),
399            config_path: self.config_path.clone(),
400            files: self.discovery.files().to_vec(),
401            modules,
402            workspaces: self.workspaces.clone(),
403            workspace_diagnostics: self.current_workspace_diagnostics(),
404            parse_ms: metrics.parse_ms,
405            cache_update_ms: metrics.cache_ms,
406            cache_hits: metrics.cache_hits,
407            cache_misses: metrics.cache_misses,
408            parse_cpu_ms: metrics.parse_cpu_ms,
409        }
410    }
411
412    /// Run dead-code analysis for this session.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if parsing or analysis fails.
417    pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
418        self.analyze_dead_code_with_artifacts(false, false)
419            .map(|output| DeadCodeAnalysis {
420                results: output.results,
421            })
422    }
423
424    /// Run dead-code analysis with retained complexity artifacts.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if parsing or analysis fails.
429    pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
430        self.analyze_dead_code_with_artifacts(true, false)
431            .map(|output| DeadCodeAnalysisOutput {
432                results: output.results,
433                modules: output.modules,
434                files: output.files,
435            })
436    }
437
438    /// Run dead-code analysis with retained modules, discovered files and graph.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if parsing or analysis fails.
443    pub fn analyze_dead_code_with_artifacts(
444        &self,
445        need_complexity: bool,
446        retain_graph: bool,
447    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
448        self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
449            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
450    }
451
452    /// Run dead-code analysis with shared immutable parser artifacts.
453    ///
454    /// Workspace-owned consumers use this additive path to retain warm parser
455    /// modules without deep-cloning the session cache. External callers can
456    /// continue using [`Self::analyze_dead_code_with_artifacts`].
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if parsing or analysis fails.
461    #[doc(hidden)]
462    pub fn analyze_dead_code_with_shared_artifacts(
463        &self,
464        need_complexity: bool,
465        retain_graph: bool,
466    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
467        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
468    }
469
470    /// Run dead-code analysis while retaining discovered files for downstream
471    /// command stages that reuse discovery but do not need parser modules.
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if parsing or analysis fails.
476    pub fn analyze_dead_code_retaining_files(
477        &self,
478        need_complexity: bool,
479        retain_graph: bool,
480    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
481        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
482            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
483    }
484
485    /// Run dead-code analysis from modules already parsed through this session.
486    ///
487    /// This preserves the session's resolved config and discovered file set for
488    /// follow-up analyses that reuse parser output without redoing discovery.
489    ///
490    /// # Errors
491    ///
492    /// Returns an error if graph construction or analysis fails.
493    pub fn analyze_dead_code_with_parsed_modules(
494        &self,
495        modules: &[ModuleInfo],
496    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
497        self.analyze_dead_code_with_shared_modules(Arc::from(modules))
498    }
499
500    /// Run dead-code analysis from shared immutable parser modules.
501    ///
502    /// # Errors
503    ///
504    /// Returns an error if graph construction or analysis fails.
505    #[doc(hidden)]
506    pub fn analyze_dead_code_with_shared_modules(
507        &self,
508        modules: Arc<[ModuleInfo]>,
509    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
510        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
511            config: &self.config,
512            discovery: &self.discovery,
513            modules,
514            metrics: reused_parse_metrics(),
515            collect_usages: true,
516            retain_graph: true,
517            retain_modules: false,
518            retain_files: false,
519        })
520        .map(SharedDeadCodeAnalysisArtifacts::into_owned)
521    }
522
523    fn analyze_dead_code_with_reuse_artifacts(
524        &self,
525        need_complexity: bool,
526        retain_graph: bool,
527        retain_files: bool,
528    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
529        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
530        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
531            config: &self.config,
532            discovery: &self.discovery,
533            modules,
534            metrics,
535            collect_usages: true,
536            retain_graph,
537            retain_modules: need_complexity,
538            retain_files,
539        })
540    }
541
542    /// Run dead-code analysis and return the session-scoped reuse artifacts.
543    ///
544    /// Callers pass a changed-file set they have already resolved for the
545    /// command. The returned value keeps that set beside parser, graph, and
546    /// source-fingerprint data so downstream runners do not have to rebuild or
547    /// rediscover the same inputs.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if parsing or analysis fails.
552    pub fn analyze_dead_code_with_session_artifacts(
553        &self,
554        need_complexity: bool,
555        retain_graph: bool,
556        changed_files: Option<FxHashSet<PathBuf>>,
557    ) -> EngineResult<AnalysisSessionArtifacts> {
558        Ok(AnalysisSessionArtifacts {
559            analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
560            changed_files,
561            source_fingerprints: self.source_fingerprints(),
562        })
563    }
564
565    /// Run duplication detection using the session's discovered files.
566    #[must_use]
567    pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
568        duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
569    }
570
571    /// Run duplication detection using custom duplicate options.
572    #[must_use]
573    pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
574        duplicates::find_duplicates(&self.config.root, self.files(), config)
575    }
576
577    /// Run dead-code and duplication analysis for this session.
578    ///
579    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
580    /// parser artifacts needed by editor overlays such as inline complexity.
581    ///
582    /// # Errors
583    ///
584    /// Returns an error if dead-code parsing or analysis fails.
585    pub fn analyze_project_with(
586        &self,
587        duplicates_config: &DuplicatesConfig,
588        retain_complexity_artifacts: bool,
589    ) -> EngineResult<ProjectAnalysisOutput> {
590        self.analyze_project_with_artifacts(
591            duplicates_config,
592            ProjectAnalysisArtifactOptions {
593                retain_complexity_artifacts,
594                ..ProjectAnalysisArtifactOptions::default()
595            },
596        )
597        .map(ProjectAnalysisArtifacts::into_output)
598    }
599
600    /// Run dead-code and duplication analysis with retained session reuse data.
601    ///
602    /// This is the engine-owned project artifact boundary for callers that need
603    /// to hand one analysis result across audit, decision, editor, or follow-up
604    /// analysis surfaces without rediscovering session metadata.
605    ///
606    /// # Errors
607    ///
608    /// Returns an error if dead-code parsing or analysis fails.
609    pub fn analyze_project_with_artifacts(
610        &self,
611        duplicates_config: &DuplicatesConfig,
612        options: ProjectAnalysisArtifactOptions,
613    ) -> EngineResult<ProjectAnalysisArtifacts> {
614        let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
615        let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
616            let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
617            self.find_duplicates_touching_files_with_defaults(
618                duplicates_config,
619                &changed_files,
620                cache_dir,
621            )
622            .report
623        } else {
624            self.find_duplicates_with_defaults(duplicates_config, cache_dir)
625                .report
626        };
627        let source_fingerprints = options
628            .collect_source_fingerprints
629            .then(|| self.source_fingerprints());
630        Ok(ProjectAnalysisArtifacts {
631            dead_code: self.analyze_dead_code_with_artifacts(
632                options.retain_complexity_artifacts,
633                options.retain_graph,
634            )?,
635            duplication,
636            changed_files: options.changed_files,
637            source_fingerprints,
638        })
639    }
640
641    /// Run duplication detection and return report sidecar metadata.
642    #[must_use]
643    pub fn find_duplicates_with_defaults(
644        &self,
645        config: &DuplicatesConfig,
646        cache_dir: Option<&Path>,
647    ) -> DuplicationAnalysis {
648        duplicates::find_duplicates_with_defaults(
649            &self.config.root,
650            self.files(),
651            config,
652            cache_dir,
653        )
654    }
655
656    /// Run focused duplication detection for a changed-file set.
657    #[must_use]
658    pub fn find_duplicates_touching_files_with_defaults(
659        &self,
660        config: &DuplicatesConfig,
661        changed_files: &[PathBuf],
662        cache_dir: Option<&Path>,
663    ) -> DuplicationAnalysis {
664        duplicates::find_duplicates_touching_files_with_defaults(
665            &self.config.root,
666            self.files(),
667            config,
668            changed_files,
669            cache_dir,
670        )
671    }
672
673    fn parse_modules(&self, need_complexity: bool) -> SharedParsedModules {
674        let fingerprints = source_fingerprints_for_files(self.files());
675        if let Some(fingerprints) = fingerprints.as_ref()
676            && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
677        {
678            return SharedParsedModules {
679                modules,
680                metrics: core_backend::ParseMetrics {
681                    parse_ms: 0.0,
682                    cache_ms: 0.0,
683                    cache_hits: 0,
684                    cache_misses: 0,
685                    parse_cpu_ms: 0.0,
686                },
687            };
688        }
689
690        let ParsedModules {
691            modules,
692            metrics,
693            source_diagnostics: _,
694        } = parse_files_with_config(&self.config, self.files(), need_complexity);
695        let modules: Arc<[ModuleInfo]> = modules.into();
696        if let Some(fingerprints) = fingerprints
697            && let Ok(mut cache) = self.parsed_cache.lock()
698        {
699            *cache = Some(ParsedModuleCache {
700                need_complexity,
701                fingerprints,
702                modules: Arc::clone(&modules),
703            });
704        }
705        SharedParsedModules { modules, metrics }
706    }
707
708    fn cached_modules(
709        &self,
710        need_complexity: bool,
711        fingerprints: &[SourceFingerprint],
712    ) -> Option<Arc<[ModuleInfo]>> {
713        let Ok(cache) = self.parsed_cache.lock() else {
714            return None;
715        };
716        let cache = cache.as_ref()?;
717        let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
718        if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
719            return Some(Arc::clone(&cache.modules));
720        }
721        None
722    }
723}
724
725fn merge_workspace_diagnostics(
726    primary: Vec<WorkspaceDiagnostic>,
727    secondary: Vec<WorkspaceDiagnostic>,
728) -> Vec<WorkspaceDiagnostic> {
729    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
730    let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
731    for diagnostic in primary.into_iter().chain(secondary) {
732        let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
733        if seen.insert(key) {
734            merged.push(diagnostic);
735        }
736    }
737    merged
738}
739
740struct ParsedModules {
741    modules: Vec<ModuleInfo>,
742    metrics: core_backend::ParseMetrics,
743    source_diagnostics: Vec<WorkspaceDiagnostic>,
744}
745
746struct SharedParsedModules {
747    modules: Arc<[ModuleInfo]>,
748    metrics: core_backend::ParseMetrics,
749}
750
751fn parse_files_with_config(
752    config: &ResolvedConfig,
753    files: &[DiscoveredFile],
754    need_complexity: bool,
755) -> ParsedModules {
756    let parse_start = Instant::now();
757    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
758    let mut cache = if config.no_cache {
759        None
760    } else {
761        fallow_extract::cache::CacheStore::load(
762            &config.cache_dir,
763            config.cache_config_hash,
764            cache_max_size_bytes,
765        )
766    };
767    let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
768    let source_diagnostics =
769        fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
770    let mut modules = parse_result.modules;
771    for module in &mut modules {
772        module.prepare_analysis_facts();
773    }
774    let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
775    let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &modules, files);
776    let metrics = core_backend::ParseMetrics {
777        parse_ms,
778        cache_ms,
779        cache_hits: parse_result.cache_hits,
780        cache_misses: parse_result.cache_misses,
781        parse_cpu_ms: parse_result.parse_cpu_ms,
782    };
783    ParsedModules {
784        modules,
785        metrics,
786        source_diagnostics,
787    }
788}
789
790fn reused_parse_metrics() -> core_backend::ParseMetrics {
791    core_backend::ParseMetrics {
792        parse_ms: 0.0,
793        cache_ms: 0.0,
794        cache_hits: 0,
795        cache_misses: 0,
796        parse_cpu_ms: 0.0,
797    }
798}
799
800fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
801    files
802        .iter()
803        .map(|file| {
804            std::fs::metadata(&file.path)
805                .ok()
806                .map(|metadata| SourceFingerprint::from_metadata(&metadata))
807                .filter(|fingerprint| fingerprint.has_known_mtime())
808        })
809        .collect()
810}
811
812fn update_parse_cache_if_enabled(
813    config: &ResolvedConfig,
814    cache: &mut Option<fallow_extract::cache::CacheStore>,
815    modules: &[ModuleInfo],
816    files: &[DiscoveredFile],
817) -> f64 {
818    let start = Instant::now();
819    if config.no_cache {
820        return start.elapsed().as_secs_f64() * 1000.0;
821    }
822
823    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
824    let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
825    if update_parse_cache(store, modules, files)
826        && let Err(error) = store.save(
827            &config.cache_dir,
828            config.cache_config_hash,
829            cache_max_size_bytes,
830        )
831    {
832        tracing::warn!("Failed to save cache: {error}");
833    }
834    start.elapsed().as_secs_f64() * 1000.0
835}
836
837fn update_parse_cache(
838    store: &mut fallow_extract::cache::CacheStore,
839    modules: &[ModuleInfo],
840    files: &[DiscoveredFile],
841) -> bool {
842    let mut dirty = false;
843    for module in modules {
844        if let Some(file) = files.get(module.file_id.0 as usize) {
845            let fingerprint = source_fingerprint(&file.path);
846            if let Some(cached) = store.get_by_path_only(&file.path)
847                && cached.content_hash == module.content_hash
848            {
849                if cached.source_fingerprint() != fingerprint {
850                    let preserved_last_access = cached.last_access_secs;
851                    let mut refreshed =
852                        fallow_extract::cache::module_to_cached(module, fingerprint);
853                    refreshed.last_access_secs = preserved_last_access;
854                    store.insert(&file.path, refreshed);
855                    dirty = true;
856                }
857                continue;
858            }
859            store.insert(
860                &file.path,
861                fallow_extract::cache::module_to_cached(module, fingerprint),
862            );
863            dirty = true;
864        }
865    }
866    store.retain_paths(files) || dirty
867}
868
869fn source_fingerprint(path: &Path) -> SourceFingerprint {
870    std::fs::metadata(path).map_or_else(
871        |_| SourceFingerprint::new(0, 0),
872        |metadata| SourceFingerprint::from_metadata(&metadata),
873    )
874}
875
876struct EngineDeadCodePipelineInput<'a> {
877    config: &'a ResolvedConfig,
878    discovery: &'a crate::discover::AnalysisDiscovery,
879    modules: Arc<[ModuleInfo]>,
880    metrics: core_backend::ParseMetrics,
881    collect_usages: bool,
882    retain_graph: bool,
883    retain_modules: bool,
884    retain_files: bool,
885}
886
887fn run_engine_owned_dead_code_pipeline(
888    input: EngineDeadCodePipelineInput<'_>,
889) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
890    let EngineDeadCodePipelineInput {
891        config,
892        discovery,
893        modules,
894        metrics,
895        collect_usages,
896        retain_graph,
897        retain_modules,
898        retain_files,
899    } = input;
900    let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
901    let prelude_timings = prelude.timings();
902    let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
903    let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
904
905    let detector = core_backend::run_dead_code_detectors(
906        &prelude,
907        &graph.graph,
908        &resolved.resolved,
909        &modules,
910        collect_usages,
911        &entry_points,
912    );
913    let profile =
914        core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
915            retain_timings: retain_graph,
916            prelude: &prelude,
917            prelude_timings,
918            parse_metrics: metrics,
919            module_count: modules.len(),
920            entry_points: &entry_points,
921            resolved: &resolved,
922            graph: &graph,
923            detector: &detector,
924            file_count: discovery.files().len(),
925            workspace_count: discovery.workspaces().len(),
926        });
927    let script_used_packages = prelude.script_used_packages();
928    prelude.finish();
929    let file_hashes = collect_file_hashes(&modules, discovery.files());
930
931    Ok(SharedDeadCodeAnalysisArtifacts {
932        results: detector.results,
933        timings: profile.timings,
934        graph: retain_graph.then_some(graph.graph),
935        modules: retain_modules.then_some(modules),
936        files: retain_files.then(|| discovery.files().to_vec()),
937        script_used_packages,
938        file_hashes,
939    })
940}
941
942fn resolve_or_build_dead_code_graph(
943    prelude: &core_backend::DeadCodeBackendPrelude,
944    entry_points: &core_backend::DeadCodeEntryPoints,
945    modules: &[ModuleInfo],
946) -> (
947    core_backend::DeadCodeResolvedModules,
948    core_backend::DeadCodeGraphRun,
949) {
950    if let Some((resolved, graph)) =
951        core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
952    {
953        return (resolved, graph);
954    }
955
956    let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
957    let graph =
958        core_backend::build_dead_code_graph(prelude, &resolved.resolved, entry_points, modules);
959    (resolved, graph)
960}
961
962fn collect_file_hashes(
963    modules: &[ModuleInfo],
964    files: &[DiscoveredFile],
965) -> FxHashMap<PathBuf, u64> {
966    modules
967        .iter()
968        .filter_map(|module| {
969            files
970                .get(module.file_id.0 as usize)
971                .map(|file| (file.path.clone(), module.content_hash))
972        })
973        .collect()
974}
975
976pub(crate) fn analyze_dead_code_with_parse_result_from_config(
977    config: &ResolvedConfig,
978    modules: &[ModuleInfo],
979) -> EngineResult<DeadCodeAnalysisArtifacts> {
980    let discovery = crate::discover::prepare_analysis_discovery(config);
981    run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
982        config,
983        discovery: &discovery,
984        modules: Arc::from(modules),
985        metrics: reused_parse_metrics(),
986        collect_usages: true,
987        retain_graph: true,
988        retain_modules: false,
989        retain_files: false,
990    })
991    .map(SharedDeadCodeAnalysisArtifacts::into_owned)
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
999        let project = tempfile::tempdir().expect("project");
1000        let root = project.path();
1001        std::fs::create_dir(root.join("src")).expect("create source directory");
1002        std::fs::write(root.join("src/index.ts"), source).expect("write source");
1003        let session = AnalysisSession::load_default(root);
1004        (project, session)
1005    }
1006
1007    #[test]
1008    fn session_retains_workspace_metadata_from_config_load() {
1009        let project = tempfile::tempdir().expect("project");
1010        let root = project.path();
1011        std::fs::write(
1012            root.join("package.json"),
1013            r#"{"name":"root","workspaces":["packages/*"]}"#,
1014        )
1015        .expect("write root package");
1016        std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1017        std::fs::write(
1018            root.join("packages/a/package.json"),
1019            r#"{"name":"pkg-a","type":"module"}"#,
1020        )
1021        .expect("write workspace package");
1022
1023        let session = AnalysisSession::load(root, None).expect("session loads");
1024
1025        assert!(
1026            session
1027                .workspaces()
1028                .iter()
1029                .any(|workspace| workspace.name == "pkg-a"),
1030            "session must retain workspace metadata discovered during config load"
1031        );
1032    }
1033
1034    #[test]
1035    fn warm_parse_cache_reuses_module_storage() {
1036        let (_project, session) = session_with_source("export function value() { return 1; }\n");
1037        let first = session.parse_modules(true);
1038        let second = session.parse_modules(false);
1039
1040        assert!(
1041            Arc::ptr_eq(&first.modules, &second.modules),
1042            "warm session queries must share parsed module storage"
1043        );
1044    }
1045
1046    #[test]
1047    fn warm_styling_cache_reuses_artifact_allocation() {
1048        let project = tempfile::tempdir().expect("project");
1049        let root = project.path();
1050        std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
1051            .expect("write stylesheet");
1052        let session = AnalysisSession::load_default(root);
1053
1054        let first = session.styling_analysis_artifacts();
1055        let second = session.styling_analysis_artifacts();
1056
1057        assert!(
1058            Arc::ptr_eq(&first, &second),
1059            "warm styling queries must share the cached artifact allocation"
1060        );
1061    }
1062
1063    #[test]
1064    fn shared_parsed_modules_reuse_public_session_storage() {
1065        let (_project, session) = session_with_source("export const value = 1;\n");
1066        let first = session.shared_parsed_modules(true);
1067        let second = session.shared_parsed_modules(false);
1068
1069        assert!(Arc::ptr_eq(&first, &second));
1070    }
1071
1072    #[test]
1073    fn parsed_parts_keep_owned_module_compatibility() {
1074        let (_project, session) = session_with_source("export const value = 1;\n");
1075        let parts: ParsedAnalysisSessionParts = session.parsed_parts(false);
1076
1077        let _: Vec<ModuleInfo> = parts.modules;
1078    }
1079
1080    #[test]
1081    fn shared_parsed_parts_reuse_public_session_storage() {
1082        let (_project, session) = session_with_source("export const value = 1;\n");
1083        let cached = session.shared_parsed_modules(true);
1084        let parts = session.shared_parsed_parts(false);
1085
1086        assert!(Arc::ptr_eq(&cached, &parts.modules));
1087    }
1088
1089    #[test]
1090    fn warm_complexity_artifacts_reuse_cached_module_storage() {
1091        let (_project, session) = session_with_source("export function value() { return 1; }\n");
1092        let cached = session.parse_modules(true);
1093        let artifacts = session
1094            .analyze_dead_code_with_reuse_artifacts(true, true, false)
1095            .expect("analysis succeeds");
1096        let retained = artifacts.modules.expect("complexity modules retained");
1097
1098        assert!(
1099            Arc::ptr_eq(&cached.modules, &retained),
1100            "warm complexity artifacts must share parsed module storage"
1101        );
1102    }
1103
1104    #[test]
1105    fn shared_and_owned_artifacts_preserve_output_bytes() {
1106        let (_project, session) = session_with_source(
1107            "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1108        );
1109        let owned = session
1110            .analyze_dead_code_with_artifacts(true, true)
1111            .expect("owned analysis succeeds");
1112        let shared = session
1113            .analyze_dead_code_with_shared_artifacts(true, true)
1114            .expect("shared analysis succeeds");
1115
1116        assert_eq!(
1117            serde_json::to_vec(&owned.results).expect("serialize owned results"),
1118            serde_json::to_vec(&shared.results).expect("serialize shared results")
1119        );
1120        assert_eq!(owned.file_hashes, shared.file_hashes);
1121        assert_eq!(
1122            owned
1123                .modules
1124                .as_deref()
1125                .unwrap_or_default()
1126                .iter()
1127                .map(|module| module.content_hash)
1128                .collect::<Vec<_>>(),
1129            shared
1130                .modules
1131                .as_deref()
1132                .unwrap_or_default()
1133                .iter()
1134                .map(|module| module.content_hash)
1135                .collect::<Vec<_>>()
1136        );
1137    }
1138
1139    #[test]
1140    fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1141        let project = tempfile::tempdir().expect("project");
1142        let root = project.path();
1143        std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1144        std::fs::write(
1145            root.join("package.json"),
1146            r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1147        )
1148        .expect("write package manifest");
1149        std::fs::write(
1150            root.join("app/routes/home.tsx"),
1151            r#"
1152import { useLoaderData } from "react-router";
1153export function loader() { return { opaque: "value" }; }
1154export default function Home() {
1155  const data = useLoaderData<typeof loader>();
1156  const copy = { ...data };
1157  return JSON.stringify(copy);
1158}
1159"#,
1160        )
1161        .expect("write route module");
1162
1163        let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1164        let cold_parse = cold_session.parsed_parts(false);
1165        assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1166        let cold = cold_session
1167            .analyze_dead_code()
1168            .expect("cold analysis succeeds");
1169
1170        let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1171        let warm_parse = warm_session.parsed_parts(false);
1172        assert!(
1173            warm_parse.cache_hits > 0,
1174            "second session must use disk cache"
1175        );
1176        let warm = warm_session
1177            .analyze_dead_code()
1178            .expect("warm analysis succeeds");
1179
1180        assert!(
1181            cold.results.unused_load_data_keys.is_empty(),
1182            "cold analysis must abstain for an opaque route-loader use"
1183        );
1184        assert_eq!(
1185            serde_json::to_vec(&cold.results).expect("serialize cold results"),
1186            serde_json::to_vec(&warm.results).expect("serialize warm results"),
1187            "warm route-loader analysis must match cold analysis"
1188        );
1189    }
1190
1191    #[test]
1192    fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1193        let project = tempfile::tempdir().expect("project");
1194        let root = project.path();
1195        std::fs::create_dir(root.join("src")).expect("create source directory");
1196        std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1197            .expect("write package manifest");
1198        for name in ["a.ts", "b.ts", "c.ts"] {
1199            std::fs::write(
1200                root.join("src").join(name),
1201                format!("export const {} = 1;\n", name.replace('.', "_")),
1202            )
1203            .expect("write source");
1204        }
1205        let session = AnalysisSession::load(root, None).expect("session loads");
1206        let removed_path = root.join("src/b.ts");
1207        let removed_id = session
1208            .files()
1209            .iter()
1210            .find(|file| file.path == removed_path)
1211            .expect("removed source discovered")
1212            .id;
1213        std::fs::remove_file(&removed_path).expect("remove source after discovery");
1214
1215        let parts = session.parsed_parts(false);
1216
1217        assert!(
1218            parts
1219                .modules
1220                .iter()
1221                .all(|module| module.file_id != removed_id),
1222            "unreadable file must not receive a placeholder module"
1223        );
1224        let diagnostic = parts
1225            .workspace_diagnostics
1226            .iter()
1227            .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1228            .expect("parsed session parts carry source read failure");
1229        assert_eq!(diagnostic.path, removed_path);
1230        assert!(
1231            session
1232                .current_workspace_diagnostics()
1233                .iter()
1234                .any(|diagnostic| {
1235                    diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1236                }),
1237            "session output carries parse-time source diagnostics"
1238        );
1239    }
1240}