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