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<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(&self) -> crate::health::StylingAnalysisArtifacts {
280        if let Ok(cache) = self.styling_cache.lock()
281            && let Some(artifacts) = cache.as_ref()
282        {
283            return artifacts.clone();
284        }
285
286        let artifacts =
287            crate::health::build_styling_analysis_artifacts(self.files(), self.config());
288        if let Ok(mut cache) = self.styling_cache.lock() {
289            *cache = Some(artifacts.clone());
290        }
291        artifacts
292    }
293
294    /// Consume the session and return the resolved config plus discovery data.
295    #[must_use]
296    pub fn into_parts(self) -> AnalysisSessionParts {
297        let workspace_diagnostics = self.current_workspace_diagnostics();
298        AnalysisSessionParts {
299            config: self.config,
300            config_path: self.config_path,
301            files: self.discovery.into_files(),
302            workspaces: self.workspaces,
303            workspace_diagnostics,
304        }
305    }
306
307    /// Consume the session, load the parser cache, and parse discovered files.
308    #[must_use]
309    pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
310        let AnalysisSessionParts {
311            config,
312            config_path,
313            files,
314            workspaces,
315            workspace_diagnostics,
316        } = self.into_parts();
317        let ParsedModules {
318            modules,
319            metrics,
320            source_diagnostics,
321        } = parse_files_with_config(&config, &files, need_complexity);
322        ParsedAnalysisSessionParts {
323            config,
324            config_path,
325            files,
326            modules,
327            workspaces,
328            workspace_diagnostics: merge_workspace_diagnostics(
329                workspace_diagnostics,
330                source_diagnostics,
331            ),
332            parse_ms: metrics.parse_ms,
333            cache_update_ms: metrics.cache_ms,
334            cache_hits: metrics.cache_hits,
335            cache_misses: metrics.cache_misses,
336            parse_cpu_ms: metrics.parse_cpu_ms,
337        }
338    }
339
340    /// Parse discovered files without consuming the session.
341    #[must_use]
342    pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
343        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
344        self.parsed_parts_from_modules(modules.to_vec(), metrics)
345    }
346
347    /// Parse discovered files while retaining shared immutable module storage.
348    #[must_use]
349    pub(crate) fn shared_parsed_parts(
350        &self,
351        need_complexity: bool,
352    ) -> SharedParsedAnalysisSessionParts {
353        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
354        SharedParsedAnalysisSessionParts {
355            config: self.config.clone(),
356            files: self.discovery.files().to_vec(),
357            modules,
358            workspaces: self.workspaces.clone(),
359            workspace_diagnostics: self.current_workspace_diagnostics(),
360            parse_ms: metrics.parse_ms,
361            parse_cpu_ms: metrics.parse_cpu_ms,
362        }
363    }
364
365    /// Return immutable parsed modules backed by the reusable session cache.
366    ///
367    /// Workspace-owned consumers use this additive path when they only need
368    /// parsed modules and can borrow discovery and config directly from the
369    /// session. Stable owned callers can continue using [`Self::parsed_parts`].
370    #[doc(hidden)]
371    #[must_use]
372    pub fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
373        self.parse_modules(need_complexity).modules
374    }
375
376    /// Parse discovered files without consuming the session or retaining parser
377    /// output in the session cache.
378    #[must_use]
379    pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
380        let ParsedModules {
381            modules,
382            metrics,
383            source_diagnostics: _,
384        } = parse_files_with_config(&self.config, self.files(), need_complexity);
385        self.parsed_parts_from_modules(modules, metrics)
386    }
387
388    fn parsed_parts_from_modules(
389        &self,
390        modules: Vec<ModuleInfo>,
391        metrics: core_backend::ParseMetrics,
392    ) -> ParsedAnalysisSessionParts {
393        ParsedAnalysisSessionParts {
394            config: self.config.clone(),
395            config_path: self.config_path.clone(),
396            files: self.discovery.files().to_vec(),
397            modules,
398            workspaces: self.workspaces.clone(),
399            workspace_diagnostics: self.current_workspace_diagnostics(),
400            parse_ms: metrics.parse_ms,
401            cache_update_ms: metrics.cache_ms,
402            cache_hits: metrics.cache_hits,
403            cache_misses: metrics.cache_misses,
404            parse_cpu_ms: metrics.parse_cpu_ms,
405        }
406    }
407
408    /// Run dead-code analysis for this session.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error if parsing or analysis fails.
413    pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
414        self.analyze_dead_code_with_artifacts(false, false)
415            .map(|output| DeadCodeAnalysis {
416                results: output.results,
417            })
418    }
419
420    /// Run dead-code analysis with retained complexity artifacts.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if parsing or analysis fails.
425    pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
426        self.analyze_dead_code_with_artifacts(true, false)
427            .map(|output| DeadCodeAnalysisOutput {
428                results: output.results,
429                modules: output.modules,
430                files: output.files,
431            })
432    }
433
434    /// Run dead-code analysis with retained modules, discovered files and graph.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if parsing or analysis fails.
439    pub fn analyze_dead_code_with_artifacts(
440        &self,
441        need_complexity: bool,
442        retain_graph: bool,
443    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
444        self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
445            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
446    }
447
448    /// Run dead-code analysis with shared immutable parser artifacts.
449    ///
450    /// Workspace-owned consumers use this additive path to retain warm parser
451    /// modules without deep-cloning the session cache. External callers can
452    /// continue using [`Self::analyze_dead_code_with_artifacts`].
453    ///
454    /// # Errors
455    ///
456    /// Returns an error if parsing or analysis fails.
457    #[doc(hidden)]
458    pub fn analyze_dead_code_with_shared_artifacts(
459        &self,
460        need_complexity: bool,
461        retain_graph: bool,
462    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
463        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
464    }
465
466    /// Run dead-code analysis while retaining discovered files for downstream
467    /// command stages that reuse discovery but do not need parser modules.
468    ///
469    /// # Errors
470    ///
471    /// Returns an error if parsing or analysis fails.
472    pub fn analyze_dead_code_retaining_files(
473        &self,
474        need_complexity: bool,
475        retain_graph: bool,
476    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
477        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
478            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
479    }
480
481    /// Run dead-code analysis from modules already parsed through this session.
482    ///
483    /// This preserves the session's resolved config and discovered file set for
484    /// follow-up analyses that reuse parser output without redoing discovery.
485    ///
486    /// # Errors
487    ///
488    /// Returns an error if graph construction or analysis fails.
489    pub fn analyze_dead_code_with_parsed_modules(
490        &self,
491        modules: &[ModuleInfo],
492    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
493        self.analyze_dead_code_with_shared_modules(Arc::from(modules))
494    }
495
496    /// Run dead-code analysis from shared immutable parser modules.
497    ///
498    /// # Errors
499    ///
500    /// Returns an error if graph construction or analysis fails.
501    #[doc(hidden)]
502    pub fn analyze_dead_code_with_shared_modules(
503        &self,
504        modules: Arc<[ModuleInfo]>,
505    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
506        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
507            config: &self.config,
508            discovery: &self.discovery,
509            modules,
510            metrics: reused_parse_metrics(),
511            collect_usages: true,
512            retain_graph: true,
513            retain_modules: false,
514            retain_files: false,
515        })
516        .map(SharedDeadCodeAnalysisArtifacts::into_owned)
517    }
518
519    fn analyze_dead_code_with_reuse_artifacts(
520        &self,
521        need_complexity: bool,
522        retain_graph: bool,
523        retain_files: bool,
524    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
525        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
526        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
527            config: &self.config,
528            discovery: &self.discovery,
529            modules,
530            metrics,
531            collect_usages: true,
532            retain_graph,
533            retain_modules: need_complexity,
534            retain_files,
535        })
536    }
537
538    /// Run dead-code analysis and return the session-scoped reuse artifacts.
539    ///
540    /// Callers pass a changed-file set they have already resolved for the
541    /// command. The returned value keeps that set beside parser, graph, and
542    /// source-fingerprint data so downstream runners do not have to rebuild or
543    /// rediscover the same inputs.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if parsing or analysis fails.
548    pub fn analyze_dead_code_with_session_artifacts(
549        &self,
550        need_complexity: bool,
551        retain_graph: bool,
552        changed_files: Option<FxHashSet<PathBuf>>,
553    ) -> EngineResult<AnalysisSessionArtifacts> {
554        Ok(AnalysisSessionArtifacts {
555            analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
556            changed_files,
557            source_fingerprints: self.source_fingerprints(),
558        })
559    }
560
561    /// Run duplication detection using the session's discovered files.
562    #[must_use]
563    pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
564        duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
565    }
566
567    /// Run duplication detection using custom duplicate options.
568    #[must_use]
569    pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
570        duplicates::find_duplicates(&self.config.root, self.files(), config)
571    }
572
573    /// Run dead-code and duplication analysis for this session.
574    ///
575    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
576    /// parser artifacts needed by editor overlays such as inline complexity.
577    ///
578    /// # Errors
579    ///
580    /// Returns an error if dead-code parsing or analysis fails.
581    pub fn analyze_project_with(
582        &self,
583        duplicates_config: &DuplicatesConfig,
584        retain_complexity_artifacts: bool,
585    ) -> EngineResult<ProjectAnalysisOutput> {
586        self.analyze_project_with_artifacts(
587            duplicates_config,
588            ProjectAnalysisArtifactOptions {
589                retain_complexity_artifacts,
590                ..ProjectAnalysisArtifactOptions::default()
591            },
592        )
593        .map(ProjectAnalysisArtifacts::into_output)
594    }
595
596    /// Run dead-code and duplication analysis with retained session reuse data.
597    ///
598    /// This is the engine-owned project artifact boundary for callers that need
599    /// to hand one analysis result across audit, decision, editor, or follow-up
600    /// analysis surfaces without rediscovering session metadata.
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if dead-code parsing or analysis fails.
605    pub fn analyze_project_with_artifacts(
606        &self,
607        duplicates_config: &DuplicatesConfig,
608        options: ProjectAnalysisArtifactOptions,
609    ) -> EngineResult<ProjectAnalysisArtifacts> {
610        let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
611        let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
612            let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
613            self.find_duplicates_touching_files_with_defaults(
614                duplicates_config,
615                &changed_files,
616                cache_dir,
617            )
618            .report
619        } else {
620            self.find_duplicates_with_defaults(duplicates_config, cache_dir)
621                .report
622        };
623        let source_fingerprints = options
624            .collect_source_fingerprints
625            .then(|| self.source_fingerprints());
626        Ok(ProjectAnalysisArtifacts {
627            dead_code: self.analyze_dead_code_with_artifacts(
628                options.retain_complexity_artifacts,
629                options.retain_graph,
630            )?,
631            duplication,
632            changed_files: options.changed_files,
633            source_fingerprints,
634        })
635    }
636
637    /// Run duplication detection and return report sidecar metadata.
638    #[must_use]
639    pub fn find_duplicates_with_defaults(
640        &self,
641        config: &DuplicatesConfig,
642        cache_dir: Option<&Path>,
643    ) -> DuplicationAnalysis {
644        duplicates::find_duplicates_with_defaults(
645            &self.config.root,
646            self.files(),
647            config,
648            cache_dir,
649        )
650    }
651
652    /// Run focused duplication detection for a changed-file set.
653    #[must_use]
654    pub fn find_duplicates_touching_files_with_defaults(
655        &self,
656        config: &DuplicatesConfig,
657        changed_files: &[PathBuf],
658        cache_dir: Option<&Path>,
659    ) -> DuplicationAnalysis {
660        duplicates::find_duplicates_touching_files_with_defaults(
661            &self.config.root,
662            self.files(),
663            config,
664            changed_files,
665            cache_dir,
666        )
667    }
668
669    fn parse_modules(&self, need_complexity: bool) -> SharedParsedModules {
670        let fingerprints = source_fingerprints_for_files(self.files());
671        if let Some(fingerprints) = fingerprints.as_ref()
672            && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
673        {
674            return SharedParsedModules {
675                modules,
676                metrics: core_backend::ParseMetrics {
677                    parse_ms: 0.0,
678                    cache_ms: 0.0,
679                    cache_hits: 0,
680                    cache_misses: 0,
681                    parse_cpu_ms: 0.0,
682                },
683            };
684        }
685
686        let ParsedModules {
687            modules,
688            metrics,
689            source_diagnostics: _,
690        } = parse_files_with_config(&self.config, self.files(), need_complexity);
691        let modules: Arc<[ModuleInfo]> = modules.into();
692        if let Some(fingerprints) = fingerprints
693            && let Ok(mut cache) = self.parsed_cache.lock()
694        {
695            *cache = Some(ParsedModuleCache {
696                need_complexity,
697                fingerprints,
698                modules: Arc::clone(&modules),
699            });
700        }
701        SharedParsedModules { modules, metrics }
702    }
703
704    fn cached_modules(
705        &self,
706        need_complexity: bool,
707        fingerprints: &[SourceFingerprint],
708    ) -> Option<Arc<[ModuleInfo]>> {
709        let Ok(cache) = self.parsed_cache.lock() else {
710            return None;
711        };
712        let cache = cache.as_ref()?;
713        let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
714        if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
715            return Some(Arc::clone(&cache.modules));
716        }
717        None
718    }
719}
720
721fn merge_workspace_diagnostics(
722    primary: Vec<WorkspaceDiagnostic>,
723    secondary: Vec<WorkspaceDiagnostic>,
724) -> Vec<WorkspaceDiagnostic> {
725    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
726    let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
727    for diagnostic in primary.into_iter().chain(secondary) {
728        let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
729        if seen.insert(key) {
730            merged.push(diagnostic);
731        }
732    }
733    merged
734}
735
736struct ParsedModules {
737    modules: Vec<ModuleInfo>,
738    metrics: core_backend::ParseMetrics,
739    source_diagnostics: Vec<WorkspaceDiagnostic>,
740}
741
742struct SharedParsedModules {
743    modules: Arc<[ModuleInfo]>,
744    metrics: core_backend::ParseMetrics,
745}
746
747fn parse_files_with_config(
748    config: &ResolvedConfig,
749    files: &[DiscoveredFile],
750    need_complexity: bool,
751) -> ParsedModules {
752    let parse_start = Instant::now();
753    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
754    let mut cache = if config.no_cache {
755        None
756    } else {
757        fallow_extract::cache::CacheStore::load(
758            &config.cache_dir,
759            config.cache_config_hash,
760            cache_max_size_bytes,
761        )
762    };
763    let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
764    let source_diagnostics =
765        fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
766    let mut modules = parse_result.modules;
767    for module in &mut modules {
768        module.prepare_analysis_facts();
769    }
770    let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
771    let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &modules, files);
772    let metrics = core_backend::ParseMetrics {
773        parse_ms,
774        cache_ms,
775        cache_hits: parse_result.cache_hits,
776        cache_misses: parse_result.cache_misses,
777        parse_cpu_ms: parse_result.parse_cpu_ms,
778    };
779    ParsedModules {
780        modules,
781        metrics,
782        source_diagnostics,
783    }
784}
785
786fn reused_parse_metrics() -> core_backend::ParseMetrics {
787    core_backend::ParseMetrics {
788        parse_ms: 0.0,
789        cache_ms: 0.0,
790        cache_hits: 0,
791        cache_misses: 0,
792        parse_cpu_ms: 0.0,
793    }
794}
795
796fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
797    files
798        .iter()
799        .map(|file| {
800            std::fs::metadata(&file.path)
801                .ok()
802                .map(|metadata| SourceFingerprint::from_metadata(&metadata))
803                .filter(|fingerprint| fingerprint.has_known_mtime())
804        })
805        .collect()
806}
807
808fn update_parse_cache_if_enabled(
809    config: &ResolvedConfig,
810    cache: &mut Option<fallow_extract::cache::CacheStore>,
811    modules: &[ModuleInfo],
812    files: &[DiscoveredFile],
813) -> f64 {
814    let start = Instant::now();
815    if config.no_cache {
816        return start.elapsed().as_secs_f64() * 1000.0;
817    }
818
819    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
820    let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
821    if update_parse_cache(store, modules, files)
822        && let Err(error) = store.save(
823            &config.cache_dir,
824            config.cache_config_hash,
825            cache_max_size_bytes,
826        )
827    {
828        tracing::warn!("Failed to save cache: {error}");
829    }
830    start.elapsed().as_secs_f64() * 1000.0
831}
832
833fn update_parse_cache(
834    store: &mut fallow_extract::cache::CacheStore,
835    modules: &[ModuleInfo],
836    files: &[DiscoveredFile],
837) -> bool {
838    let mut dirty = false;
839    for module in modules {
840        if let Some(file) = files.get(module.file_id.0 as usize) {
841            let fingerprint = source_fingerprint(&file.path);
842            if let Some(cached) = store.get_by_path_only(&file.path)
843                && cached.content_hash == module.content_hash
844            {
845                if cached.source_fingerprint() != fingerprint {
846                    let preserved_last_access = cached.last_access_secs;
847                    let mut refreshed =
848                        fallow_extract::cache::module_to_cached(module, fingerprint);
849                    refreshed.last_access_secs = preserved_last_access;
850                    store.insert(&file.path, refreshed);
851                    dirty = true;
852                }
853                continue;
854            }
855            store.insert(
856                &file.path,
857                fallow_extract::cache::module_to_cached(module, fingerprint),
858            );
859            dirty = true;
860        }
861    }
862    store.retain_paths(files) || dirty
863}
864
865fn source_fingerprint(path: &Path) -> SourceFingerprint {
866    std::fs::metadata(path).map_or_else(
867        |_| SourceFingerprint::new(0, 0),
868        |metadata| SourceFingerprint::from_metadata(&metadata),
869    )
870}
871
872struct EngineDeadCodePipelineInput<'a> {
873    config: &'a ResolvedConfig,
874    discovery: &'a crate::discover::AnalysisDiscovery,
875    modules: Arc<[ModuleInfo]>,
876    metrics: core_backend::ParseMetrics,
877    collect_usages: bool,
878    retain_graph: bool,
879    retain_modules: bool,
880    retain_files: bool,
881}
882
883fn run_engine_owned_dead_code_pipeline(
884    input: EngineDeadCodePipelineInput<'_>,
885) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
886    let EngineDeadCodePipelineInput {
887        config,
888        discovery,
889        modules,
890        metrics,
891        collect_usages,
892        retain_graph,
893        retain_modules,
894        retain_files,
895    } = input;
896    let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
897    let prelude_timings = prelude.timings();
898    let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
899    let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
900
901    let detector = core_backend::run_dead_code_detectors(
902        &prelude,
903        &graph.graph,
904        &resolved.resolved,
905        &modules,
906        collect_usages,
907        &entry_points,
908    );
909    let profile =
910        core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
911            retain_timings: retain_graph,
912            prelude: &prelude,
913            prelude_timings,
914            parse_metrics: metrics,
915            module_count: modules.len(),
916            entry_points: &entry_points,
917            resolved: &resolved,
918            graph: &graph,
919            detector: &detector,
920            file_count: discovery.files().len(),
921            workspace_count: discovery.workspaces().len(),
922        });
923    let script_used_packages = prelude.script_used_packages();
924    prelude.finish();
925    let file_hashes = collect_file_hashes(&modules, discovery.files());
926
927    Ok(SharedDeadCodeAnalysisArtifacts {
928        results: detector.results,
929        timings: profile.timings,
930        graph: retain_graph.then_some(graph.graph),
931        modules: retain_modules.then_some(modules),
932        files: retain_files.then(|| discovery.files().to_vec()),
933        script_used_packages,
934        file_hashes,
935    })
936}
937
938fn resolve_or_build_dead_code_graph(
939    prelude: &core_backend::DeadCodeBackendPrelude,
940    entry_points: &core_backend::DeadCodeEntryPoints,
941    modules: &[ModuleInfo],
942) -> (
943    core_backend::DeadCodeResolvedModules,
944    core_backend::DeadCodeGraphRun,
945) {
946    if let Some((resolved, graph)) =
947        core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
948    {
949        return (resolved, graph);
950    }
951
952    let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
953    let graph =
954        core_backend::build_dead_code_graph(prelude, &resolved.resolved, entry_points, modules);
955    (resolved, graph)
956}
957
958fn collect_file_hashes(
959    modules: &[ModuleInfo],
960    files: &[DiscoveredFile],
961) -> FxHashMap<PathBuf, u64> {
962    modules
963        .iter()
964        .filter_map(|module| {
965            files
966                .get(module.file_id.0 as usize)
967                .map(|file| (file.path.clone(), module.content_hash))
968        })
969        .collect()
970}
971
972pub(crate) fn analyze_dead_code_with_parse_result_from_config(
973    config: &ResolvedConfig,
974    modules: &[ModuleInfo],
975) -> EngineResult<DeadCodeAnalysisArtifacts> {
976    let discovery = crate::discover::prepare_analysis_discovery(config);
977    run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
978        config,
979        discovery: &discovery,
980        modules: Arc::from(modules),
981        metrics: reused_parse_metrics(),
982        collect_usages: true,
983        retain_graph: true,
984        retain_modules: false,
985        retain_files: false,
986    })
987    .map(SharedDeadCodeAnalysisArtifacts::into_owned)
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
995        let project = tempfile::tempdir().expect("project");
996        let root = project.path();
997        std::fs::create_dir(root.join("src")).expect("create source directory");
998        std::fs::write(root.join("src/index.ts"), source).expect("write source");
999        let session = AnalysisSession::load_default(root);
1000        (project, session)
1001    }
1002
1003    #[test]
1004    fn session_retains_workspace_metadata_from_config_load() {
1005        let project = tempfile::tempdir().expect("project");
1006        let root = project.path();
1007        std::fs::write(
1008            root.join("package.json"),
1009            r#"{"name":"root","workspaces":["packages/*"]}"#,
1010        )
1011        .expect("write root package");
1012        std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1013        std::fs::write(
1014            root.join("packages/a/package.json"),
1015            r#"{"name":"pkg-a","type":"module"}"#,
1016        )
1017        .expect("write workspace package");
1018
1019        let session = AnalysisSession::load(root, None).expect("session loads");
1020
1021        assert!(
1022            session
1023                .workspaces()
1024                .iter()
1025                .any(|workspace| workspace.name == "pkg-a"),
1026            "session must retain workspace metadata discovered during config load"
1027        );
1028    }
1029
1030    #[test]
1031    fn warm_parse_cache_reuses_module_storage() {
1032        let (_project, session) = session_with_source("export function value() { return 1; }\n");
1033        let first = session.parse_modules(true);
1034        let second = session.parse_modules(false);
1035
1036        assert!(
1037            Arc::ptr_eq(&first.modules, &second.modules),
1038            "warm session queries must share parsed module storage"
1039        );
1040    }
1041
1042    #[test]
1043    fn shared_parsed_modules_reuse_public_session_storage() {
1044        let (_project, session) = session_with_source("export const value = 1;\n");
1045        let first = session.shared_parsed_modules(true);
1046        let second = session.shared_parsed_modules(false);
1047
1048        assert!(Arc::ptr_eq(&first, &second));
1049    }
1050
1051    #[test]
1052    fn parsed_parts_keep_owned_module_compatibility() {
1053        let (_project, session) = session_with_source("export const value = 1;\n");
1054        let parts: ParsedAnalysisSessionParts = session.parsed_parts(false);
1055
1056        let _: Vec<ModuleInfo> = parts.modules;
1057    }
1058
1059    #[test]
1060    fn shared_parsed_parts_reuse_public_session_storage() {
1061        let (_project, session) = session_with_source("export const value = 1;\n");
1062        let cached = session.shared_parsed_modules(true);
1063        let parts = session.shared_parsed_parts(false);
1064
1065        assert!(Arc::ptr_eq(&cached, &parts.modules));
1066    }
1067
1068    #[test]
1069    fn warm_complexity_artifacts_reuse_cached_module_storage() {
1070        let (_project, session) = session_with_source("export function value() { return 1; }\n");
1071        let cached = session.parse_modules(true);
1072        let artifacts = session
1073            .analyze_dead_code_with_reuse_artifacts(true, true, false)
1074            .expect("analysis succeeds");
1075        let retained = artifacts.modules.expect("complexity modules retained");
1076
1077        assert!(
1078            Arc::ptr_eq(&cached.modules, &retained),
1079            "warm complexity artifacts must share parsed module storage"
1080        );
1081    }
1082
1083    #[test]
1084    fn shared_and_owned_artifacts_preserve_output_bytes() {
1085        let (_project, session) = session_with_source(
1086            "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1087        );
1088        let owned = session
1089            .analyze_dead_code_with_artifacts(true, true)
1090            .expect("owned analysis succeeds");
1091        let shared = session
1092            .analyze_dead_code_with_shared_artifacts(true, true)
1093            .expect("shared analysis succeeds");
1094
1095        assert_eq!(
1096            serde_json::to_vec(&owned.results).expect("serialize owned results"),
1097            serde_json::to_vec(&shared.results).expect("serialize shared results")
1098        );
1099        assert_eq!(owned.file_hashes, shared.file_hashes);
1100        assert_eq!(
1101            owned
1102                .modules
1103                .as_deref()
1104                .unwrap_or_default()
1105                .iter()
1106                .map(|module| module.content_hash)
1107                .collect::<Vec<_>>(),
1108            shared
1109                .modules
1110                .as_deref()
1111                .unwrap_or_default()
1112                .iter()
1113                .map(|module| module.content_hash)
1114                .collect::<Vec<_>>()
1115        );
1116    }
1117
1118    #[test]
1119    fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1120        let project = tempfile::tempdir().expect("project");
1121        let root = project.path();
1122        std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1123        std::fs::write(
1124            root.join("package.json"),
1125            r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1126        )
1127        .expect("write package manifest");
1128        std::fs::write(
1129            root.join("app/routes/home.tsx"),
1130            r#"
1131import { useLoaderData } from "react-router";
1132export function loader() { return { opaque: "value" }; }
1133export default function Home() {
1134  const data = useLoaderData<typeof loader>();
1135  const copy = { ...data };
1136  return JSON.stringify(copy);
1137}
1138"#,
1139        )
1140        .expect("write route module");
1141
1142        let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1143        let cold_parse = cold_session.parsed_parts(false);
1144        assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1145        let cold = cold_session
1146            .analyze_dead_code()
1147            .expect("cold analysis succeeds");
1148
1149        let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1150        let warm_parse = warm_session.parsed_parts(false);
1151        assert!(
1152            warm_parse.cache_hits > 0,
1153            "second session must use disk cache"
1154        );
1155        let warm = warm_session
1156            .analyze_dead_code()
1157            .expect("warm analysis succeeds");
1158
1159        assert!(
1160            cold.results.unused_load_data_keys.is_empty(),
1161            "cold analysis must abstain for an opaque route-loader use"
1162        );
1163        assert_eq!(
1164            serde_json::to_vec(&cold.results).expect("serialize cold results"),
1165            serde_json::to_vec(&warm.results).expect("serialize warm results"),
1166            "warm route-loader analysis must match cold analysis"
1167        );
1168    }
1169
1170    #[test]
1171    fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1172        let project = tempfile::tempdir().expect("project");
1173        let root = project.path();
1174        std::fs::create_dir(root.join("src")).expect("create source directory");
1175        std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1176            .expect("write package manifest");
1177        for name in ["a.ts", "b.ts", "c.ts"] {
1178            std::fs::write(
1179                root.join("src").join(name),
1180                format!("export const {} = 1;\n", name.replace('.', "_")),
1181            )
1182            .expect("write source");
1183        }
1184        let session = AnalysisSession::load(root, None).expect("session loads");
1185        let removed_path = root.join("src/b.ts");
1186        let removed_id = session
1187            .files()
1188            .iter()
1189            .find(|file| file.path == removed_path)
1190            .expect("removed source discovered")
1191            .id;
1192        std::fs::remove_file(&removed_path).expect("remove source after discovery");
1193
1194        let parts = session.parsed_parts(false);
1195
1196        assert!(
1197            parts
1198                .modules
1199                .iter()
1200                .all(|module| module.file_id != removed_id),
1201            "unreadable file must not receive a placeholder module"
1202        );
1203        let diagnostic = parts
1204            .workspace_diagnostics
1205            .iter()
1206            .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1207            .expect("parsed session parts carry source read failure");
1208        assert_eq!(diagnostic.path, removed_path);
1209        assert!(
1210            session
1211                .current_workspace_diagnostics()
1212                .iter()
1213                .any(|diagnostic| {
1214                    diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1215                }),
1216            "session output carries parse-time source diagnostics"
1217        );
1218    }
1219}