Skip to main content

fallow_engine/
session.rs

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