Skip to main content

fallow_engine/
session.rs

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