Skip to main content

fallow_engine/
session.rs

1//! Engine-owned analysis session orchestration.
2
3use std::path::{Path, PathBuf};
4use std::sync::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    },
23};
24
25/// Reusable engine session for one resolved project.
26///
27/// The session owns the resolved config and discovered file set so future
28/// consumers can share graph-sensitive inputs without each surface recreating
29/// its own partial orchestration.
30#[derive(Debug)]
31pub struct AnalysisSession {
32    config: ResolvedConfig,
33    config_path: Option<PathBuf>,
34    discovery: crate::discover::AnalysisDiscovery,
35    workspaces: Vec<WorkspaceInfo>,
36    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
37    parsed_cache: Mutex<Option<ParsedModuleCache>>,
38    styling_cache: Mutex<Option<crate::health::StylingAnalysisArtifacts>>,
39}
40
41#[derive(Debug)]
42struct ParsedModuleCache {
43    need_complexity: bool,
44    fingerprints: Vec<SourceFingerprint>,
45    modules: Vec<ModuleInfo>,
46}
47
48/// Owned session parts for runners that need to continue an existing pipeline.
49#[derive(Debug)]
50pub struct AnalysisSessionParts {
51    pub config: ResolvedConfig,
52    pub config_path: Option<PathBuf>,
53    pub files: Vec<DiscoveredFile>,
54    pub workspaces: Vec<WorkspaceInfo>,
55    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
56}
57
58/// Owned session parts after parsing the discovered files.
59#[derive(Debug)]
60pub struct ParsedAnalysisSessionParts {
61    pub config: ResolvedConfig,
62    pub config_path: Option<PathBuf>,
63    pub files: Vec<DiscoveredFile>,
64    pub modules: Vec<ModuleInfo>,
65    pub workspaces: Vec<WorkspaceInfo>,
66    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
67    pub parse_ms: f64,
68    pub cache_update_ms: f64,
69    pub cache_hits: usize,
70    pub cache_misses: usize,
71    pub parse_cpu_ms: f64,
72}
73
74/// Reusable artifacts produced by one session-owned dead-code run.
75#[derive(Debug)]
76pub struct AnalysisSessionArtifacts {
77    pub analysis: DeadCodeAnalysisArtifacts,
78    pub changed_files: Option<FxHashSet<PathBuf>>,
79    pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
80}
81
82impl AnalysisSession {
83    /// Load config and discover files for a project root.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error when config loading fails.
88    pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
89        let project_config = config_for_project(root, config_path)?;
90        Ok(Self::from_config(project_config))
91    }
92
93    /// Load config, apply one caller-supplied config adjustment, then discover
94    /// files for a project root.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error when config loading fails.
99    pub fn load_with_config(
100        root: &Path,
101        config_path: Option<&Path>,
102        configure: impl FnOnce(&mut ResolvedConfig),
103    ) -> EngineResult<Self> {
104        let mut project_config = config_for_project(root, config_path)?;
105        configure(&mut project_config.config);
106        project_config.workspaces.clear();
107        project_config.workspace_diagnostics.clear();
108        project_config.workspace_discovery_ms = None;
109        Ok(Self::from_config(project_config))
110    }
111
112    /// Build a session from built-in defaults, ignoring project config files.
113    ///
114    /// This is intended for editor fallback paths that have already reported a
115    /// config-load warning but should still surface best-effort diagnostics.
116    #[must_use]
117    pub fn load_default(root: &Path) -> Self {
118        Self::from_config(default_project_config(root))
119    }
120
121    /// Build a session from a previously resolved config.
122    #[must_use]
123    pub fn from_config(project_config: ProjectConfig) -> Self {
124        let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
125        let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
126        {
127            crate::discover::prepare_analysis_discovery_with_workspaces(
128                &project_config.config,
129                &project_config.workspaces,
130                workspace_discovery_ms,
131            )
132        } else {
133            crate::discover::prepare_analysis_discovery(&project_config.config)
134        };
135        let workspaces = if uses_preloaded_workspaces {
136            project_config.workspaces
137        } else {
138            discovery.workspaces().to_vec()
139        };
140        let workspace_diagnostics = merge_workspace_diagnostics(
141            project_config.workspace_diagnostics,
142            fallow_config::workspace_diagnostics_for(&project_config.config.root),
143        );
144        Self {
145            config: project_config.config,
146            config_path: project_config.path,
147            discovery,
148            workspaces,
149            workspace_diagnostics,
150            parsed_cache: Mutex::new(None),
151            styling_cache: Mutex::new(None),
152        }
153    }
154
155    /// Build a session from a resolved config when the caller already owns
156    /// command-specific config loading.
157    #[must_use]
158    pub fn from_resolved_config(config: ResolvedConfig) -> Self {
159        Self::from_config(ProjectConfig {
160            config,
161            path: None,
162            workspaces: Vec::new(),
163            workspace_diagnostics: Vec::new(),
164            workspace_discovery_ms: None,
165        })
166    }
167
168    /// Resolved project root.
169    #[must_use]
170    pub fn root(&self) -> &Path {
171        &self.config.root
172    }
173
174    /// Resolved project config.
175    #[must_use]
176    pub fn config(&self) -> &ResolvedConfig {
177        &self.config
178    }
179
180    /// Config file path when one was loaded.
181    #[must_use]
182    pub fn config_path(&self) -> Option<&Path> {
183        self.config_path.as_deref()
184    }
185
186    /// Discovered files for this session.
187    #[must_use]
188    pub fn files(&self) -> &[DiscoveredFile] {
189        self.discovery.files()
190    }
191
192    /// Workspace packages discovered during config/session setup.
193    #[must_use]
194    pub fn workspaces(&self) -> &[WorkspaceInfo] {
195        &self.workspaces
196    }
197
198    /// Source metadata fingerprints for every discovered source file.
199    #[must_use]
200    pub fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
201        self.discovery
202            .files()
203            .iter()
204            .map(|file| {
205                let fingerprint = std::fs::metadata(&file.path).map_or_else(
206                    |_| SourceFingerprint::new(0, file.size_bytes),
207                    |metadata| SourceFingerprint::from_metadata(&metadata),
208                );
209                (file.path.clone(), fingerprint)
210            })
211            .collect()
212    }
213
214    /// Resolve files changed since a git ref against this session root.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error when the ref is invalid, git is unavailable, or the
219    /// root is not part of a repository.
220    pub fn changed_files_since(
221        &self,
222        git_ref: &str,
223    ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
224        crate::changed_files::changed_files(&self.config.root, git_ref)
225    }
226
227    /// Workspace and source-discovery diagnostics captured for this session.
228    #[must_use]
229    pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
230        &self.workspace_diagnostics
231    }
232
233    pub(crate) fn styling_analysis_artifacts(&self) -> crate::health::StylingAnalysisArtifacts {
234        if let Ok(cache) = self.styling_cache.lock()
235            && let Some(artifacts) = cache.as_ref()
236        {
237            return artifacts.clone();
238        }
239
240        let artifacts =
241            crate::health::build_styling_analysis_artifacts(self.files(), self.config());
242        if let Ok(mut cache) = self.styling_cache.lock() {
243            *cache = Some(artifacts.clone());
244        }
245        artifacts
246    }
247
248    /// Consume the session and return the resolved config plus discovery data.
249    #[must_use]
250    pub fn into_parts(self) -> AnalysisSessionParts {
251        AnalysisSessionParts {
252            config: self.config,
253            config_path: self.config_path,
254            files: self.discovery.into_files(),
255            workspaces: self.workspaces,
256            workspace_diagnostics: self.workspace_diagnostics,
257        }
258    }
259
260    /// Consume the session, load the parser cache, and parse discovered files.
261    #[must_use]
262    pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
263        let AnalysisSessionParts {
264            config,
265            config_path,
266            files,
267            workspaces,
268            workspace_diagnostics,
269        } = self.into_parts();
270        let ParsedModules { modules, metrics } =
271            parse_files_with_config(&config, &files, need_complexity);
272        ParsedAnalysisSessionParts {
273            config,
274            config_path,
275            files,
276            modules,
277            workspaces,
278            workspace_diagnostics,
279            parse_ms: metrics.parse_ms,
280            cache_update_ms: metrics.cache_ms,
281            cache_hits: metrics.cache_hits,
282            cache_misses: metrics.cache_misses,
283            parse_cpu_ms: metrics.parse_cpu_ms,
284        }
285    }
286
287    /// Parse discovered files without consuming the session.
288    #[must_use]
289    pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
290        let ParsedModules { modules, metrics } = self.parse_modules(need_complexity);
291        self.parsed_parts_from_modules(modules, metrics)
292    }
293
294    /// Parse discovered files without consuming the session or retaining parser
295    /// output in the session cache.
296    #[must_use]
297    pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
298        let ParsedModules { modules, metrics } =
299            parse_files_with_config(&self.config, self.files(), need_complexity);
300        self.parsed_parts_from_modules(modules, metrics)
301    }
302
303    fn parsed_parts_from_modules(
304        &self,
305        modules: Vec<ModuleInfo>,
306        metrics: core_backend::ParseMetrics,
307    ) -> ParsedAnalysisSessionParts {
308        ParsedAnalysisSessionParts {
309            config: self.config.clone(),
310            config_path: self.config_path.clone(),
311            files: self.discovery.files().to_vec(),
312            modules,
313            workspaces: self.workspaces.clone(),
314            workspace_diagnostics: self.workspace_diagnostics.clone(),
315            parse_ms: metrics.parse_ms,
316            cache_update_ms: metrics.cache_ms,
317            cache_hits: metrics.cache_hits,
318            cache_misses: metrics.cache_misses,
319            parse_cpu_ms: metrics.parse_cpu_ms,
320        }
321    }
322
323    /// Run dead-code analysis for this session.
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if parsing or analysis fails.
328    pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
329        self.analyze_dead_code_with_artifacts(false, false)
330            .map(|output| DeadCodeAnalysis {
331                results: output.results,
332            })
333    }
334
335    /// Run dead-code analysis with retained complexity artifacts.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if parsing or analysis fails.
340    pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
341        self.analyze_dead_code_with_artifacts(true, false)
342            .map(|output| DeadCodeAnalysisOutput {
343                results: output.results,
344                modules: output.modules,
345                files: output.files,
346            })
347    }
348
349    /// Run dead-code analysis with retained modules, discovered files and graph.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if parsing or analysis fails.
354    pub fn analyze_dead_code_with_artifacts(
355        &self,
356        need_complexity: bool,
357        retain_graph: bool,
358    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
359        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
360    }
361
362    /// Run dead-code analysis while retaining discovered files for downstream
363    /// command stages that reuse discovery but do not need parser modules.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if parsing or analysis fails.
368    pub fn analyze_dead_code_retaining_files(
369        &self,
370        need_complexity: bool,
371        retain_graph: bool,
372    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
373        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
374    }
375
376    /// Run dead-code analysis from modules already parsed through this session.
377    ///
378    /// This preserves the session's resolved config and discovered file set for
379    /// follow-up analyses that reuse parser output without redoing discovery.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if graph construction or analysis fails.
384    pub fn analyze_dead_code_with_parsed_modules(
385        &self,
386        modules: &[ModuleInfo],
387    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
388        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
389            config: &self.config,
390            discovery: &self.discovery,
391            modules: modules.to_vec(),
392            metrics: reused_parse_metrics(),
393            collect_usages: true,
394            retain_graph: true,
395            retain_modules: false,
396            retain_files: false,
397        })
398    }
399
400    fn analyze_dead_code_with_reuse_artifacts(
401        &self,
402        need_complexity: bool,
403        retain_graph: bool,
404        retain_files: bool,
405    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
406        let ParsedModules { modules, metrics } = self.parse_modules(need_complexity);
407        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
408            config: &self.config,
409            discovery: &self.discovery,
410            modules,
411            metrics,
412            collect_usages: true,
413            retain_graph,
414            retain_modules: need_complexity,
415            retain_files,
416        })
417    }
418
419    /// Run dead-code analysis and return the session-scoped reuse artifacts.
420    ///
421    /// Callers pass a changed-file set they have already resolved for the
422    /// command. The returned value keeps that set beside parser, graph, and
423    /// source-fingerprint data so downstream runners do not have to rebuild or
424    /// rediscover the same inputs.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if parsing or analysis fails.
429    pub fn analyze_dead_code_with_session_artifacts(
430        &self,
431        need_complexity: bool,
432        retain_graph: bool,
433        changed_files: Option<FxHashSet<PathBuf>>,
434    ) -> EngineResult<AnalysisSessionArtifacts> {
435        Ok(AnalysisSessionArtifacts {
436            analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
437            changed_files,
438            source_fingerprints: self.source_fingerprints(),
439        })
440    }
441
442    /// Run duplication detection using the session's discovered files.
443    #[must_use]
444    pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
445        duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
446    }
447
448    /// Run duplication detection using custom duplicate options.
449    #[must_use]
450    pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
451        duplicates::find_duplicates(&self.config.root, self.files(), config)
452    }
453
454    /// Run dead-code and duplication analysis for this session.
455    ///
456    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
457    /// parser artifacts needed by editor overlays such as inline complexity.
458    ///
459    /// # Errors
460    ///
461    /// Returns an error if dead-code parsing or analysis fails.
462    pub fn analyze_project_with(
463        &self,
464        duplicates_config: &DuplicatesConfig,
465        retain_complexity_artifacts: bool,
466    ) -> EngineResult<ProjectAnalysisOutput> {
467        self.analyze_project_with_artifacts(
468            duplicates_config,
469            ProjectAnalysisArtifactOptions {
470                retain_complexity_artifacts,
471                ..ProjectAnalysisArtifactOptions::default()
472            },
473        )
474        .map(ProjectAnalysisArtifacts::into_output)
475    }
476
477    /// Run dead-code and duplication analysis with retained session reuse data.
478    ///
479    /// This is the engine-owned project artifact boundary for callers that need
480    /// to hand one analysis result across audit, decision, editor, or follow-up
481    /// analysis surfaces without rediscovering session metadata.
482    ///
483    /// # Errors
484    ///
485    /// Returns an error if dead-code parsing or analysis fails.
486    pub fn analyze_project_with_artifacts(
487        &self,
488        duplicates_config: &DuplicatesConfig,
489        options: ProjectAnalysisArtifactOptions,
490    ) -> EngineResult<ProjectAnalysisArtifacts> {
491        let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
492        let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
493            let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
494            self.find_duplicates_touching_files_with_defaults(
495                duplicates_config,
496                &changed_files,
497                cache_dir,
498            )
499            .report
500        } else {
501            self.find_duplicates_with_defaults(duplicates_config, cache_dir)
502                .report
503        };
504        let source_fingerprints = options
505            .collect_source_fingerprints
506            .then(|| self.source_fingerprints());
507        Ok(ProjectAnalysisArtifacts {
508            dead_code: self.analyze_dead_code_with_artifacts(
509                options.retain_complexity_artifacts,
510                options.retain_graph,
511            )?,
512            duplication,
513            changed_files: options.changed_files,
514            source_fingerprints,
515        })
516    }
517
518    /// Run duplication detection and return report sidecar metadata.
519    #[must_use]
520    pub fn find_duplicates_with_defaults(
521        &self,
522        config: &DuplicatesConfig,
523        cache_dir: Option<&Path>,
524    ) -> DuplicationAnalysis {
525        duplicates::find_duplicates_with_defaults(
526            &self.config.root,
527            self.files(),
528            config,
529            cache_dir,
530        )
531    }
532
533    /// Run focused duplication detection for a changed-file set.
534    #[must_use]
535    pub fn find_duplicates_touching_files_with_defaults(
536        &self,
537        config: &DuplicatesConfig,
538        changed_files: &[PathBuf],
539        cache_dir: Option<&Path>,
540    ) -> DuplicationAnalysis {
541        duplicates::find_duplicates_touching_files_with_defaults(
542            &self.config.root,
543            self.files(),
544            config,
545            changed_files,
546            cache_dir,
547        )
548    }
549
550    fn parse_modules(&self, need_complexity: bool) -> ParsedModules {
551        let fingerprints = source_fingerprints_for_files(self.files());
552        if let Some(fingerprints) = fingerprints.as_ref()
553            && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
554        {
555            return ParsedModules {
556                modules,
557                metrics: core_backend::ParseMetrics {
558                    parse_ms: 0.0,
559                    cache_ms: 0.0,
560                    cache_hits: 0,
561                    cache_misses: 0,
562                    parse_cpu_ms: 0.0,
563                },
564            };
565        }
566
567        let parsed = parse_files_with_config(&self.config, self.files(), need_complexity);
568        if let Some(fingerprints) = fingerprints
569            && let Ok(mut cache) = self.parsed_cache.lock()
570        {
571            *cache = Some(ParsedModuleCache {
572                need_complexity,
573                fingerprints,
574                modules: parsed.modules.clone(),
575            });
576        }
577        parsed
578    }
579
580    fn cached_modules(
581        &self,
582        need_complexity: bool,
583        fingerprints: &[SourceFingerprint],
584    ) -> Option<Vec<ModuleInfo>> {
585        let Ok(cache) = self.parsed_cache.lock() else {
586            return None;
587        };
588        let cache = cache.as_ref()?;
589        let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
590        if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
591            return Some(cache.modules.clone());
592        }
593        None
594    }
595}
596
597fn merge_workspace_diagnostics(
598    primary: Vec<WorkspaceDiagnostic>,
599    secondary: Vec<WorkspaceDiagnostic>,
600) -> Vec<WorkspaceDiagnostic> {
601    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
602    let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
603    for diagnostic in primary.into_iter().chain(secondary) {
604        let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
605        if seen.insert(key) {
606            merged.push(diagnostic);
607        }
608    }
609    merged
610}
611
612struct ParsedModules {
613    modules: Vec<ModuleInfo>,
614    metrics: core_backend::ParseMetrics,
615}
616
617fn parse_files_with_config(
618    config: &ResolvedConfig,
619    files: &[DiscoveredFile],
620    need_complexity: bool,
621) -> ParsedModules {
622    let parse_start = Instant::now();
623    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
624    let mut cache = if config.no_cache {
625        None
626    } else {
627        fallow_extract::cache::CacheStore::load(
628            &config.cache_dir,
629            config.cache_config_hash,
630            cache_max_size_bytes,
631        )
632    };
633    let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
634    let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
635    let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &parse_result.modules, files);
636    let metrics = core_backend::ParseMetrics {
637        parse_ms,
638        cache_ms,
639        cache_hits: parse_result.cache_hits,
640        cache_misses: parse_result.cache_misses,
641        parse_cpu_ms: parse_result.parse_cpu_ms,
642    };
643    ParsedModules {
644        modules: parse_result.modules,
645        metrics,
646    }
647}
648
649fn reused_parse_metrics() -> core_backend::ParseMetrics {
650    core_backend::ParseMetrics {
651        parse_ms: 0.0,
652        cache_ms: 0.0,
653        cache_hits: 0,
654        cache_misses: 0,
655        parse_cpu_ms: 0.0,
656    }
657}
658
659fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
660    files
661        .iter()
662        .map(|file| {
663            std::fs::metadata(&file.path)
664                .ok()
665                .map(|metadata| SourceFingerprint::from_metadata(&metadata))
666                .filter(|fingerprint| fingerprint.has_known_mtime())
667        })
668        .collect()
669}
670
671fn update_parse_cache_if_enabled(
672    config: &ResolvedConfig,
673    cache: &mut Option<fallow_extract::cache::CacheStore>,
674    modules: &[ModuleInfo],
675    files: &[DiscoveredFile],
676) -> f64 {
677    let start = Instant::now();
678    if config.no_cache {
679        return start.elapsed().as_secs_f64() * 1000.0;
680    }
681
682    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
683    let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
684    if update_parse_cache(store, modules, files)
685        && let Err(error) = store.save(
686            &config.cache_dir,
687            config.cache_config_hash,
688            cache_max_size_bytes,
689        )
690    {
691        tracing::warn!("Failed to save cache: {error}");
692    }
693    start.elapsed().as_secs_f64() * 1000.0
694}
695
696fn update_parse_cache(
697    store: &mut fallow_extract::cache::CacheStore,
698    modules: &[ModuleInfo],
699    files: &[DiscoveredFile],
700) -> bool {
701    let mut dirty = false;
702    for module in modules {
703        if let Some(file) = files.get(module.file_id.0 as usize) {
704            let fingerprint = source_fingerprint(&file.path);
705            if let Some(cached) = store.get_by_path_only(&file.path)
706                && cached.content_hash == module.content_hash
707            {
708                if cached.source_fingerprint() != fingerprint {
709                    let preserved_last_access = cached.last_access_secs;
710                    let mut refreshed =
711                        fallow_extract::cache::module_to_cached(module, fingerprint);
712                    refreshed.last_access_secs = preserved_last_access;
713                    store.insert(&file.path, refreshed);
714                    dirty = true;
715                }
716                continue;
717            }
718            store.insert(
719                &file.path,
720                fallow_extract::cache::module_to_cached(module, fingerprint),
721            );
722            dirty = true;
723        }
724    }
725    store.retain_paths(files) || dirty
726}
727
728fn source_fingerprint(path: &Path) -> SourceFingerprint {
729    std::fs::metadata(path).map_or_else(
730        |_| SourceFingerprint::new(0, 0),
731        |metadata| SourceFingerprint::from_metadata(&metadata),
732    )
733}
734
735struct EngineDeadCodePipelineInput<'a> {
736    config: &'a ResolvedConfig,
737    discovery: &'a crate::discover::AnalysisDiscovery,
738    modules: Vec<ModuleInfo>,
739    metrics: core_backend::ParseMetrics,
740    collect_usages: bool,
741    retain_graph: bool,
742    retain_modules: bool,
743    retain_files: bool,
744}
745
746fn run_engine_owned_dead_code_pipeline(
747    input: EngineDeadCodePipelineInput<'_>,
748) -> EngineResult<DeadCodeAnalysisArtifacts> {
749    let EngineDeadCodePipelineInput {
750        config,
751        discovery,
752        mut modules,
753        metrics,
754        collect_usages,
755        retain_graph,
756        retain_modules,
757        retain_files,
758    } = input;
759    let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
760    let prelude_timings = prelude.timings();
761    let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
762    let (resolved, graph) = if let Some((resolved, graph)) =
763        core_backend::try_load_dead_code_graph_cache(&prelude, &entry_points, &modules)
764    {
765        (resolved, graph)
766    } else {
767        let resolved = core_backend::resolve_dead_code_imports(&prelude, &modules);
768        let graph = core_backend::build_dead_code_graph(
769            &prelude,
770            &resolved.resolved,
771            &entry_points,
772            &modules,
773        );
774        (resolved, graph)
775    };
776
777    for module in &mut modules {
778        module.release_resolution_payload();
779    }
780
781    let detector = core_backend::run_dead_code_detectors(
782        &prelude,
783        &graph.graph,
784        &resolved.resolved,
785        &modules,
786        collect_usages,
787        &entry_points,
788    );
789    let profile = core_backend::dead_code_pipeline_profile(
790        retain_graph,
791        &prelude,
792        prelude_timings,
793        metrics,
794        modules.len(),
795        &entry_points,
796        &resolved,
797        &graph,
798        &detector,
799        discovery.files().len(),
800        discovery.workspaces().len(),
801    );
802    let script_used_packages = prelude.script_used_packages();
803    prelude.finish();
804    let file_hashes = collect_file_hashes(&modules, discovery.files());
805
806    Ok(DeadCodeAnalysisArtifacts {
807        results: detector.results,
808        timings: profile.timings,
809        graph: retain_graph.then_some(graph.graph),
810        modules: retain_modules.then_some(modules),
811        files: retain_files.then(|| discovery.files().to_vec()),
812        script_used_packages,
813        file_hashes,
814    })
815}
816
817fn collect_file_hashes(
818    modules: &[ModuleInfo],
819    files: &[DiscoveredFile],
820) -> FxHashMap<PathBuf, u64> {
821    modules
822        .iter()
823        .filter_map(|module| {
824            files
825                .get(module.file_id.0 as usize)
826                .map(|file| (file.path.clone(), module.content_hash))
827        })
828        .collect()
829}
830
831pub(crate) fn analyze_dead_code_with_parse_result_from_config(
832    config: &ResolvedConfig,
833    modules: &[ModuleInfo],
834) -> EngineResult<DeadCodeAnalysisArtifacts> {
835    let discovery = crate::discover::prepare_analysis_discovery(config);
836    run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
837        config,
838        discovery: &discovery,
839        modules: modules.to_vec(),
840        metrics: reused_parse_metrics(),
841        collect_usages: true,
842        retain_graph: true,
843        retain_modules: false,
844        retain_files: false,
845    })
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    #[test]
853    fn session_retains_workspace_metadata_from_config_load() {
854        let project = tempfile::tempdir().expect("project");
855        let root = project.path();
856        std::fs::write(
857            root.join("package.json"),
858            r#"{"name":"root","workspaces":["packages/*"]}"#,
859        )
860        .expect("write root package");
861        std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
862        std::fs::write(
863            root.join("packages/a/package.json"),
864            r#"{"name":"pkg-a","type":"module"}"#,
865        )
866        .expect("write workspace package");
867
868        let session = AnalysisSession::load(root, None).expect("session loads");
869
870        assert!(
871            session
872                .workspaces()
873                .iter()
874                .any(|workspace| workspace.name == "pkg-a"),
875            "session must retain workspace metadata discovered during config load"
876        );
877    }
878}