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