Skip to main content

fallow_engine/
session.rs

1//! Engine-owned analysis session orchestration.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8use fallow_config::{DuplicatesConfig, ResolvedConfig, WorkspaceInfo};
9use fallow_types::cache_rejection::CacheRejection;
10use fallow_types::discover::DiscoveredFile;
11use fallow_types::extract::{ModuleInfo, SourceParseDegradation, SourceReadFailure};
12#[cfg(test)]
13use fallow_types::results::AnalysisResults;
14use fallow_types::source_fingerprint::{SourceFingerprint, now_ns};
15use fallow_types::workspace::{WorkspaceDiagnostic, merge_workspace_diagnostics};
16use rustc_hash::{FxHashMap, FxHashSet};
17
18pub use crate::session_reuse::SessionParseCounts;
19use crate::session_reuse::{
20    MAX_INCREMENTAL_REPARSE_FILES, ParseCountCells, changed_file_indices, merge_reparsed_modules,
21};
22use crate::{
23    EngineResult, core_backend, duplicates,
24    project_analysis::{
25        ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
26    },
27    project_config::{ProjectConfig, config_for_project, default_project_config},
28    results::{
29        DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
30        SharedDeadCodeAnalysisArtifacts,
31    },
32    warm_parse::{WarmParse, WarmParseKey, WarmParseStore},
33};
34
35/// Reusable engine session for one resolved project.
36///
37/// The session owns the resolved config and discovered file set so future
38/// consumers can share graph-sensitive inputs without each surface recreating
39/// its own partial orchestration.
40#[derive(Debug)]
41pub struct AnalysisSession {
42    config: ResolvedConfig,
43    config_path: Option<PathBuf>,
44    config_inputs: fallow_config::ConfigInputs,
45    config_inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
46    discovery: crate::discover::AnalysisDiscovery,
47    workspaces: Vec<WorkspaceInfo>,
48    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
49    parsed_cache: Mutex<Option<ParsedModuleCache>>,
50    styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
51    cancellation: Option<Arc<AtomicBool>>,
52    warm_parse: Option<Arc<WarmParseStore>>,
53    /// Config load already resolved the workspaces, so a discovery refresh
54    /// keeps them.
55    preloaded_workspaces: bool,
56    parse_counts: ParseCountCells,
57    /// An incremental parse changed modules that the persisted parse cache
58    /// does not hold yet. [`AnalysisSession::flush_parse_cache`] writes them.
59    disk_cache_stale: AtomicBool,
60}
61
62#[derive(Debug)]
63struct ParsedModuleCache {
64    need_complexity: bool,
65    fingerprints: Vec<SourceFingerprint>,
66    /// The wall-clock time before the session read `fingerprints`. A
67    /// fingerprint that is not settled before this time can belong to a
68    /// later write in the same timestamp tick, so it does not prove that the
69    /// module is current.
70    read_started_ns: u64,
71    modules: Arc<[ModuleInfo]>,
72    /// The read failures of the parse, kept so that an incremental parse can
73    /// record the full set of the project again.
74    read_failures: Vec<SourceReadFailure>,
75    /// The parse degradations of the parse, kept for the same reason.
76    parse_degradations: Vec<SourceParseDegradation>,
77}
78
79/// Owned session parts for runners that need to continue an existing pipeline.
80#[derive(Debug)]
81pub struct AnalysisSessionParts {
82    /// Resolved project config the session was created with.
83    pub config: ResolvedConfig,
84    /// Path of the loaded config file; `None` when defaults were used.
85    pub config_path: Option<PathBuf>,
86    /// Files discovered under the session root.
87    pub files: Vec<DiscoveredFile>,
88    /// Workspace metadata discovered during config resolution.
89    pub workspaces: Vec<WorkspaceInfo>,
90    /// Diagnostics from workspace discovery (undeclared or invalid members).
91    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
92}
93
94/// Owned session parts after parsing the discovered files.
95#[derive(Debug)]
96pub struct ParsedAnalysisSessionParts {
97    /// Resolved project config the session was created with.
98    pub config: ResolvedConfig,
99    /// Path of the loaded config file; `None` when defaults were used.
100    pub config_path: Option<PathBuf>,
101    /// Files discovered under the session root.
102    pub files: Vec<DiscoveredFile>,
103    /// Parsed modules, one per discovered file.
104    pub modules: Vec<ModuleInfo>,
105    /// Workspace metadata discovered during config resolution.
106    pub workspaces: Vec<WorkspaceInfo>,
107    /// Diagnostics from workspace discovery (undeclared or invalid members).
108    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
109    /// Parse wall time in milliseconds.
110    pub parse_ms: f64,
111    /// Parse-cache write-back wall time in milliseconds.
112    pub cache_update_ms: f64,
113    /// Files served from the parse cache.
114    pub cache_hits: usize,
115    /// Files that had to be parsed fresh.
116    pub cache_misses: usize,
117    /// Summed parse CPU time across rayon workers in milliseconds.
118    pub parse_cpu_ms: f64,
119}
120
121#[derive(Debug)]
122pub(crate) struct SharedParsedAnalysisSessionParts {
123    pub(crate) config: ResolvedConfig,
124    pub(crate) files: Vec<DiscoveredFile>,
125    pub(crate) modules: Arc<[ModuleInfo]>,
126    pub workspaces: Vec<WorkspaceInfo>,
127    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
128    pub parse_ms: f64,
129    pub parse_cpu_ms: f64,
130}
131
132/// Reusable artifacts produced by one session-owned dead-code run.
133#[derive(Debug)]
134pub struct AnalysisSessionArtifacts {
135    /// Retained dead-code analysis output (results, graph, timings).
136    pub analysis: DeadCodeAnalysisArtifacts,
137    /// Diff scope the run was limited to, when one was resolved.
138    pub changed_files: Option<FxHashSet<PathBuf>>,
139    /// Per-file source fingerprints for downstream cache invalidation.
140    pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
141}
142
143impl AnalysisSession {
144    /// Load config and discover files for a project root.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error when config loading fails.
149    pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
150        let project_config = config_for_project(root, config_path)?;
151        Ok(Self::from_config(project_config))
152    }
153
154    /// Load config, apply one caller-supplied config adjustment, then discover
155    /// files for a project root.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error when config loading fails.
160    pub fn load_with_config(
161        root: &Path,
162        config_path: Option<&Path>,
163        configure: impl FnOnce(&mut ResolvedConfig),
164    ) -> EngineResult<Self> {
165        Self::load_with_config_options(
166            root,
167            config_path,
168            fallow_config::ConfigLoadOptions::default(),
169            configure,
170        )
171    }
172
173    /// Load config with an explicit inheritance trust policy, apply one
174    /// caller-supplied adjustment, then discover project files.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error when config loading fails.
179    pub fn load_with_config_options(
180        root: &Path,
181        config_path: Option<&Path>,
182        load_options: fallow_config::ConfigLoadOptions,
183        configure: impl FnOnce(&mut ResolvedConfig),
184    ) -> EngineResult<Self> {
185        let mut project_config = crate::project_config::config_for_project_with_load_options(
186            root,
187            config_path,
188            load_options,
189        )?;
190        configure(&mut project_config.config);
191        project_config.workspaces.clear();
192        project_config.workspace_diagnostics.clear();
193        project_config.workspace_discovery_ms = None;
194        Ok(Self::from_config(project_config))
195    }
196
197    /// Build a session from built-in defaults, ignoring project config files.
198    ///
199    /// This is intended for editor fallback paths that have already reported a
200    /// config-load warning but should still surface best-effort diagnostics.
201    #[must_use]
202    pub fn load_default(root: &Path) -> Self {
203        Self::load_default_with_config(root, |_| {})
204    }
205
206    /// Build a session from built-in defaults, apply one caller-supplied
207    /// adjustment, then discover project files.
208    #[must_use]
209    pub fn load_default_with_config(
210        root: &Path,
211        configure: impl FnOnce(&mut ResolvedConfig),
212    ) -> Self {
213        let mut project_config = default_project_config(root);
214        configure(&mut project_config.config);
215        Self::from_config(project_config)
216    }
217
218    /// Build a session from a previously resolved config.
219    #[must_use]
220    pub fn from_config(project_config: ProjectConfig) -> Self {
221        let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
222        let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
223        {
224            crate::discover::prepare_analysis_discovery_with_workspaces(
225                &project_config.config,
226                &project_config.workspaces,
227                workspace_discovery_ms,
228            )
229        } else {
230            crate::discover::prepare_analysis_discovery(&project_config.config)
231        };
232        let workspaces = if uses_preloaded_workspaces {
233            project_config.workspaces
234        } else {
235            discovery.workspaces().to_vec()
236        };
237        // Analysis-stage diagnostics are owned by the analyze pass, which
238        // refreshes the registry on every run; pinning them in the session
239        // snapshot would keep a stale entry alive after the cause is fixed
240        // (issue #2366). `current_workspace_diagnostics` reads them live.
241        //
242        // Source-discovery entries come from THIS walk's return value, not from
243        // the registry: combined mode runs the dead-code and duplication walks
244        // concurrently whenever a per-analysis `production` split stops them
245        // from sharing a file list, and each walk replaces the registry's
246        // source-discovery set for the root, so a registry read here would
247        // report whichever walk happened to write last (issue #2366).
248        let workspace_diagnostics = merge_workspace_diagnostics(
249            merge_workspace_diagnostics(
250                project_config.workspace_diagnostics,
251                fallow_config::workspace_diagnostics_for(&project_config.config.root)
252                    .into_iter()
253                    .filter(|diagnostic| {
254                        !diagnostic.kind.is_analysis_stage()
255                            && !diagnostic.kind.is_source_discovery()
256                    })
257                    .collect(),
258            ),
259            discovery.source_diagnostics().to_vec(),
260        );
261        Self {
262            config: project_config.config,
263            config_path: project_config.path,
264            config_inputs: project_config.inputs,
265            config_inputs_before_resolve: project_config.inputs_before_resolve,
266            discovery,
267            workspaces,
268            workspace_diagnostics,
269            parsed_cache: Mutex::new(None),
270            styling_cache: Mutex::new(None),
271            cancellation: None,
272            warm_parse: crate::warm_parse::installed(),
273            preloaded_workspaces: uses_preloaded_workspaces,
274            parse_counts: ParseCountCells::default(),
275            disk_cache_stale: AtomicBool::new(false),
276        }
277    }
278
279    /// Attach a caller-owned cancellation token to this session.
280    ///
281    /// Analyses that run through the session check the token at each pipeline
282    /// stage boundary and inside the per-file parse loop, and return
283    /// [`crate::EngineError::cancelled`] instead of a partial result once it is
284    /// set. A session without a token can never be cancelled, so existing
285    /// callers keep their current behavior.
286    ///
287    /// The stop is cooperative, and how long it takes is bounded by the
288    /// longest stage that holds no check, not by any promised latency. Only
289    /// the parse loop stops per file. Duplication detection (tokenization plus
290    /// suffix-array matching) and the dead-code detectors run to the end of the
291    /// stage once entered, and on a large repository either is seconds of work.
292    /// A caller that needs a bounded stop has to kill the process instead.
293    #[must_use]
294    pub fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
295        self.cancellation = Some(cancellation);
296        self
297    }
298
299    /// Use `store` for parsed modules across sessions, in place of the store
300    /// that the process installed with [`crate::warm_parse::install`].
301    /// `None` makes the session parse through the persisted cache only.
302    #[must_use]
303    pub fn with_warm_parse(mut self, store: Option<Arc<WarmParseStore>>) -> Self {
304        self.warm_parse = store;
305        self
306    }
307
308    /// Replace the cancellation token of a session that serves several runs.
309    ///
310    /// A long-lived session gets the token of each new run, so a set token of
311    /// an earlier run does not stop the next one.
312    pub fn set_cancellation(&mut self, cancellation: Arc<AtomicBool>) {
313        self.cancellation = Some(cancellation);
314    }
315
316    /// The parse work of this session since it was created.
317    #[must_use]
318    pub fn parse_counts(&self) -> SessionParseCounts {
319        self.parse_counts.snapshot()
320    }
321
322    /// Walk the project again and keep the parsed modules when the file set
323    /// did not change.
324    ///
325    /// A session that serves several runs calls this before each run, so a
326    /// created or deleted file reaches the analysis without a config reload.
327    /// When the file set changed, the file ids move, so the session writes its
328    /// parsed modules to the persisted parse cache and drops them. The next
329    /// parse then reads that cache. Returns whether the file set changed.
330    ///
331    /// The session also drops its modules when a fingerprint of the cached
332    /// parse cannot stand in for the file content, as on a platform without
333    /// ctime. A same-size edit with a restored mtime keeps such a
334    /// fingerprint, so only the persisted cache, which then compares content
335    /// hashes, can tell whether the module is current.
336    pub fn refresh_discovery(&mut self) -> bool {
337        let discovery = if self.preloaded_workspaces {
338            crate::discover::prepare_analysis_discovery_with_workspaces(
339                &self.config,
340                &self.workspaces,
341                0.0,
342            )
343        } else {
344            crate::discover::prepare_analysis_discovery(&self.config)
345        };
346        let same_files = discovery.files().len() == self.files().len()
347            && discovery
348                .files()
349                .iter()
350                .zip(self.files())
351                .all(|(fresh, known)| fresh.path == known.path);
352        if !same_files || !self.cached_fingerprints_are_trustworthy() {
353            self.flush_parse_cache();
354            *self
355                .parsed_cache
356                .get_mut()
357                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
358            self.clear_styling_cache();
359        }
360        if !self.preloaded_workspaces {
361            self.workspaces = discovery.workspaces().to_vec();
362        }
363        self.discovery = discovery;
364        !same_files
365    }
366
367    /// Whether each fingerprint of the cached parse can stand in for the file
368    /// content. No cached parse counts as trustworthy.
369    fn cached_fingerprints_are_trustworthy(&mut self) -> bool {
370        self.parsed_cache
371            .get_mut()
372            .unwrap_or_else(std::sync::PoisonError::into_inner)
373            .as_ref()
374            .is_none_or(|cache| {
375                cache
376                    .fingerprints
377                    .iter()
378                    .all(|fingerprint| fingerprint.is_trustworthy_without_content())
379            })
380    }
381
382    /// Write the modules of incremental parses to the persisted parse cache.
383    ///
384    /// Each module is stored with the fingerprint that the session read before
385    /// it parsed the file. A file that changed after that parse then misses
386    /// the cache instead of serving the older module. Does nothing when no
387    /// incremental parse happened since the last write, or when the cache is
388    /// off.
389    ///
390    /// The session does not write these modules when it is dropped. A caller
391    /// that parses again after files changed, such as a session that lives
392    /// across editor runs, calls this method before it drops the session. A
393    /// session that parses once needs no call: the full parse writes the
394    /// persisted cache itself. The editor server is the only caller now.
395    pub fn flush_parse_cache(&self) {
396        if self.config.no_cache || !self.disk_cache_stale.swap(false, Ordering::SeqCst) {
397            return;
398        }
399        let Ok(cache) = self.parsed_cache.lock() else {
400            return;
401        };
402        let Some(cache) = cache.as_ref() else {
403            return;
404        };
405        let cache_max_size_bytes =
406            crate::project_config::resolve_cache_max_size_bytes(&self.config);
407        let mut store = fallow_extract::cache::CacheStore::load(
408            &self.config.cache_dir,
409            &self.config.root,
410            self.config.cache_config_hash,
411            cache_max_size_bytes,
412        )
413        .ok();
414        let files = self.files();
415        write_parse_cache(
416            &self.config,
417            &mut store,
418            &ParseCacheWrite {
419                modules: &cache.modules,
420                files,
421                need_complexity: cache.need_complexity,
422                fingerprint_of: &|file: &DiscoveredFile| {
423                    cache.fingerprints.get(file.id.0 as usize).map_or_else(
424                        || SourceFingerprint::new(0, 0),
425                        |fingerprint| fingerprint.for_content_read_at(cache.read_started_ns),
426                    )
427                },
428            },
429        );
430    }
431
432    /// Parse the discovered files into the module cache of the session,
433    /// without analysis. A later run of this session then starts from warm
434    /// modules.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`crate::EngineError::cancelled`] when the token of the session
439    /// is set.
440    pub fn prewarm_parsed_modules(&self, need_complexity: bool) -> EngineResult<()> {
441        self.shared_parsed_modules_cancellable(need_complexity, "prewarm")
442            .map(drop)
443    }
444
445    fn clear_styling_cache(&self) {
446        if let Ok(mut cache) = self.styling_cache.lock() {
447            *cache = None;
448        }
449    }
450
451    /// Whether this session's caller has requested cancellation.
452    #[must_use]
453    pub fn is_cancelled(&self) -> bool {
454        self.cancellation
455            .as_ref()
456            .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
457    }
458
459    fn ensure_not_cancelled(&self, stage: &str) -> EngineResult<()> {
460        if self.is_cancelled() {
461            return Err(crate::EngineError::cancelled(stage));
462        }
463        Ok(())
464    }
465
466    /// Build a session from a resolved config when the caller already owns
467    /// command-specific config loading.
468    ///
469    /// # Errors
470    ///
471    /// Returns an engine error when root manifest loading fails during
472    /// workspace discovery, matching `ProjectConfig::load`.
473    pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
474        let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
475            crate::project_config::collect_workspace_metadata(&config)?;
476        Ok(Self::from_config(ProjectConfig {
477            inputs: fallow_config::ConfigInputs::default(),
478            inputs_before_resolve: fallow_config::ConfigInputsSnapshot::default(),
479            config,
480            path: None,
481            workspaces,
482            workspace_diagnostics,
483            workspace_discovery_ms: Some(workspace_discovery_ms),
484        }))
485    }
486
487    /// Resolved project root.
488    #[must_use]
489    pub fn root(&self) -> &Path {
490        &self.config.root
491    }
492
493    /// Resolved project config.
494    #[must_use]
495    pub fn config(&self) -> &ResolvedConfig {
496        &self.config
497    }
498
499    /// Config file path when one was loaded.
500    #[must_use]
501    pub fn config_path(&self) -> Option<&Path> {
502        self.config_path.as_deref()
503    }
504
505    /// The plugin files, rule packs and `autoDiscover` directories that
506    /// config resolution read. A session built from a resolved config
507    /// lists none.
508    #[must_use]
509    pub const fn config_inputs(&self) -> &fallow_config::ConfigInputs {
510        &self.config_inputs
511    }
512
513    /// The content of [`Self::config_inputs`] just before config resolution
514    /// read them. A snapshot after the load that differs from this one tells
515    /// that an input changed during the load.
516    #[must_use]
517    pub const fn config_inputs_before_resolve(&self) -> &fallow_config::ConfigInputsSnapshot {
518        &self.config_inputs_before_resolve
519    }
520
521    /// The estimated heap memory of the parsed modules that the session
522    /// keeps between calls, with the estimate of
523    /// [`crate::warm_parse::estimated_retained_bytes`]. Zero before the first
524    /// parse.
525    #[must_use]
526    pub fn retained_bytes_estimate(&self) -> u64 {
527        self.parsed_cache
528            .lock()
529            .ok()
530            .and_then(|cache| {
531                cache
532                    .as_ref()
533                    .map(|cache| crate::warm_parse::estimated_retained_bytes(&cache.fingerprints))
534            })
535            .unwrap_or(0)
536    }
537
538    /// Discovered files for this session.
539    #[must_use]
540    pub fn files(&self) -> &[DiscoveredFile] {
541        self.discovery.files()
542    }
543
544    /// Workspace packages discovered during config/session setup.
545    #[must_use]
546    pub fn workspaces(&self) -> &[WorkspaceInfo] {
547        &self.workspaces
548    }
549
550    /// Source metadata fingerprints for every discovered source file.
551    #[must_use]
552    fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
553        self.discovery
554            .files()
555            .iter()
556            .map(|file| {
557                let fingerprint = std::fs::metadata(&file.path).map_or_else(
558                    |_| SourceFingerprint::new(0, file.size_bytes),
559                    |metadata| SourceFingerprint::from_metadata(&metadata),
560                );
561                (file.path.clone(), fingerprint)
562            })
563            .collect()
564    }
565
566    /// Resolve files changed since a git ref against this session root.
567    ///
568    /// # Errors
569    ///
570    /// Returns an error when the ref is invalid, git is unavailable, or the
571    /// root is not part of a repository.
572    pub(crate) fn changed_files_since(
573        &self,
574        git_ref: &str,
575    ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
576        crate::changed_files::changed_files(&self.config.root, git_ref)
577    }
578
579    /// The discovery this session walked.
580    pub(crate) const fn discovery(&self) -> &crate::discover::AnalysisDiscovery {
581        &self.discovery
582    }
583
584    /// Workspace and source-discovery diagnostics captured for this session.
585    #[must_use]
586    pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
587        &self.workspace_diagnostics
588    }
589
590    /// Current diagnostics, including the source read failures the parse stage
591    /// discovers and the analysis-stage entries the analyze pass records, both
592    /// of which land in the registry after the session was created.
593    ///
594    /// The live read goes through
595    /// [`fallow_config::registry_diagnostics_to_fold`], which drops
596    /// walk-recorded entries for the same reason the constructor does: a
597    /// concurrent walk on the same root replaces that set, so importing it here
598    /// would make this session's list depend on which walk wrote last, and the
599    /// combined root's union would come out in a different ORDER between runs
600    /// of the same command (issue #2366). This session's own walk-recorded
601    /// entries are already in the snapshot, by value, from its own walk.
602    #[must_use]
603    pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
604        merge_workspace_diagnostics(
605            self.workspace_diagnostics.clone(),
606            fallow_config::registry_diagnostics_to_fold(&self.config.root),
607        )
608    }
609
610    pub(crate) fn styling_analysis_artifacts(
611        &self,
612    ) -> Arc<crate::health::StylingAnalysisArtifacts> {
613        if let Ok(cache) = self.styling_cache.lock()
614            && let Some(artifacts) = cache.as_ref()
615        {
616            return Arc::clone(artifacts);
617        }
618
619        let modules = self.shared_parsed_modules(false);
620        let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
621            self.files(),
622            &modules,
623            self.config(),
624        ));
625        if let Ok(mut cache) = self.styling_cache.lock() {
626            *cache = Some(Arc::clone(&artifacts));
627        }
628        artifacts
629    }
630
631    /// Consume the session and return the resolved config plus discovery data.
632    #[must_use]
633    pub fn into_parts(self) -> AnalysisSessionParts {
634        let workspace_diagnostics = self.current_workspace_diagnostics();
635        AnalysisSessionParts {
636            config: self.config,
637            config_path: self.config_path,
638            files: self.discovery.into_files(),
639            workspaces: self.workspaces,
640            workspace_diagnostics,
641        }
642    }
643
644    /// Consume the session, load the parser cache, and parse discovered files.
645    #[must_use]
646    pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
647        let AnalysisSessionParts {
648            config,
649            config_path,
650            files,
651            workspaces,
652            workspace_diagnostics,
653        } = self.into_parts();
654        let ParsedModules {
655            modules,
656            metrics,
657            source_diagnostics,
658            ..
659        } = parse_files_with_config(&config, &files, need_complexity, None);
660        ParsedAnalysisSessionParts {
661            config,
662            config_path,
663            files,
664            modules,
665            workspaces,
666            workspace_diagnostics: merge_workspace_diagnostics(
667                workspace_diagnostics,
668                source_diagnostics,
669            ),
670            parse_ms: metrics.parse_ms,
671            cache_update_ms: metrics.cache_ms,
672            cache_hits: metrics.cache_hits,
673            cache_misses: metrics.cache_misses,
674            parse_cpu_ms: metrics.parse_cpu_ms,
675        }
676    }
677
678    /// Parse discovered files without consuming the session.
679    #[must_use]
680    pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
681        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
682        self.parsed_parts_from_modules(modules.to_vec(), metrics)
683    }
684
685    /// Parse discovered files while retaining shared immutable module storage.
686    #[must_use]
687    pub(crate) fn shared_parsed_parts(
688        &self,
689        need_complexity: bool,
690    ) -> SharedParsedAnalysisSessionParts {
691        let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity, None);
692        SharedParsedAnalysisSessionParts {
693            config: self.config.clone(),
694            files: self.discovery.files().to_vec(),
695            modules,
696            workspaces: self.workspaces.clone(),
697            workspace_diagnostics: self.current_workspace_diagnostics(),
698            parse_ms: metrics.parse_ms,
699            parse_cpu_ms: metrics.parse_cpu_ms,
700        }
701    }
702
703    /// Return immutable parsed modules backed by the reusable session cache.
704    ///
705    /// Workspace-owned consumers use this additive path when they only need
706    /// parsed modules and can borrow discovery and config directly from the
707    /// session. Stable owned callers can continue using [`Self::parsed_parts`].
708    #[doc(hidden)]
709    #[must_use]
710    pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
711        self.parse_modules(need_complexity, None).modules
712    }
713
714    /// Parse the discovered files, stopping if the session's caller cancelled.
715    ///
716    /// The token is checked on both sides of the parse loop, so the truncated
717    /// module set a cancelled parse produces is never returned as a smaller
718    /// project. `stage` names the work that would have followed.
719    ///
720    /// # Errors
721    ///
722    /// Returns [`crate::EngineError::cancelled`] when the token is set. A
723    /// session without a token can never return it.
724    pub(crate) fn shared_parsed_modules_cancellable(
725        &self,
726        need_complexity: bool,
727        stage: &str,
728    ) -> EngineResult<Arc<[ModuleInfo]>> {
729        self.ensure_not_cancelled("parsing")?;
730        let modules = self
731            .parse_modules(need_complexity, self.cancellation.as_deref())
732            .modules;
733        self.ensure_not_cancelled(stage)?;
734        Ok(modules)
735    }
736
737    /// Parse discovered files without consuming the session or retaining parser
738    /// output in the session cache.
739    #[must_use]
740    pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
741        let read_started_ns = now_ns();
742        let fingerprints = self
743            .warm_parse
744            .as_ref()
745            .and_then(|_| source_fingerprints_for_files(self.files()));
746        if let Some(warm) = self.warm_parse(
747            need_complexity,
748            fingerprints.as_deref(),
749            read_started_ns,
750            None,
751        ) {
752            return self.parsed_parts_from_modules(warm.modules.to_vec(), warm.metrics);
753        }
754        let ParsedModules {
755            modules, metrics, ..
756        } = parse_files_with_config(&self.config, self.files(), need_complexity, None);
757        self.parsed_parts_from_modules(modules, metrics)
758    }
759
760    fn parsed_parts_from_modules(
761        &self,
762        modules: Vec<ModuleInfo>,
763        metrics: core_backend::ParseMetrics,
764    ) -> ParsedAnalysisSessionParts {
765        ParsedAnalysisSessionParts {
766            config: self.config.clone(),
767            config_path: self.config_path.clone(),
768            files: self.discovery.files().to_vec(),
769            modules,
770            workspaces: self.workspaces.clone(),
771            workspace_diagnostics: self.current_workspace_diagnostics(),
772            parse_ms: metrics.parse_ms,
773            cache_update_ms: metrics.cache_ms,
774            cache_hits: metrics.cache_hits,
775            cache_misses: metrics.cache_misses,
776            parse_cpu_ms: metrics.parse_cpu_ms,
777        }
778    }
779
780    /// Run dead-code analysis for this session.
781    ///
782    /// # Errors
783    ///
784    /// Returns an error if parsing or analysis fails.
785    pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
786        self.analyze_dead_code_with_artifacts(false, false)
787            .map(|output| DeadCodeAnalysis {
788                results: output.results,
789            })
790    }
791
792    /// Run dead-code analysis with retained complexity artifacts.
793    ///
794    /// # Errors
795    ///
796    /// Returns an error if parsing or analysis fails.
797    pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
798        self.analyze_dead_code_with_artifacts(true, false)
799            .map(|output| DeadCodeAnalysisOutput {
800                results: output.results,
801                modules: output.modules,
802                files: output.files,
803            })
804    }
805
806    /// Run dead-code analysis with retained modules, discovered files and graph.
807    ///
808    /// # Errors
809    ///
810    /// Returns an error if parsing or analysis fails.
811    pub fn analyze_dead_code_with_artifacts(
812        &self,
813        need_complexity: bool,
814        retain_graph: bool,
815    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
816        self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
817            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
818    }
819
820    /// Run dead-code analysis with shared immutable parser artifacts.
821    ///
822    /// Workspace-owned consumers use this additive path to retain warm parser
823    /// modules without deep-cloning the session cache. External callers can
824    /// continue using [`Self::analyze_dead_code_with_artifacts`].
825    ///
826    /// # Errors
827    ///
828    /// Returns an error if parsing or analysis fails.
829    #[doc(hidden)]
830    pub fn analyze_dead_code_with_shared_artifacts(
831        &self,
832        need_complexity: bool,
833        retain_graph: bool,
834    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
835        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
836    }
837
838    /// Run dead-code analysis while retaining discovered files for downstream
839    /// command stages that reuse discovery but do not need parser modules.
840    ///
841    /// # Errors
842    ///
843    /// Returns an error if parsing or analysis fails.
844    pub fn analyze_dead_code_retaining_files(
845        &self,
846        need_complexity: bool,
847        retain_graph: bool,
848    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
849        self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
850            .map(SharedDeadCodeAnalysisArtifacts::into_owned)
851    }
852
853    /// Run dead-code analysis from modules already parsed through this session.
854    ///
855    /// This preserves the session's resolved config and discovered file set for
856    /// follow-up analyses that reuse parser output without redoing discovery.
857    ///
858    /// # Errors
859    ///
860    /// Returns an error if graph construction or analysis fails.
861    pub fn analyze_dead_code_with_parsed_modules(
862        &self,
863        modules: &[ModuleInfo],
864    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
865        self.analyze_dead_code_with_shared_modules(Arc::from(modules))
866    }
867
868    /// Run dead-code analysis from shared immutable parser modules.
869    ///
870    /// # Errors
871    ///
872    /// Returns an error if graph construction or analysis fails.
873    #[doc(hidden)]
874    pub(crate) fn analyze_dead_code_with_shared_modules(
875        &self,
876        modules: Arc<[ModuleInfo]>,
877    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
878        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
879            config: &self.config,
880            discovery: &self.discovery,
881            modules,
882            metrics: reused_parse_metrics(),
883            collect_usages: true,
884            retain_graph: true,
885            retain_modules: false,
886            retain_files: false,
887            cancellation: self.cancellation.as_deref(),
888        })
889        .map(SharedDeadCodeAnalysisArtifacts::into_owned)
890    }
891
892    fn analyze_dead_code_with_reuse_artifacts(
893        &self,
894        need_complexity: bool,
895        retain_graph: bool,
896        retain_files: bool,
897    ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
898        self.ensure_not_cancelled("parsing")?;
899        let SharedParsedModules { modules, metrics } =
900            self.parse_modules(need_complexity, self.cancellation.as_deref());
901        // The parse loop no-ops the files it has not reached yet, so a token
902        // set during parsing leaves `modules` truncated. It must never reach
903        // the graph as if it were the whole project.
904        self.ensure_not_cancelled("the dead-code pipeline")?;
905        run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
906            config: &self.config,
907            discovery: &self.discovery,
908            modules,
909            metrics,
910            collect_usages: true,
911            retain_graph,
912            retain_modules: need_complexity,
913            retain_files,
914            cancellation: self.cancellation.as_deref(),
915        })
916    }
917
918    /// Run dead-code analysis and return the session-scoped reuse artifacts.
919    ///
920    /// Callers pass a changed-file set they have already resolved for the
921    /// command. The returned value keeps that set beside parser, graph, and
922    /// source-fingerprint data so downstream runners do not have to rebuild or
923    /// rediscover the same inputs.
924    ///
925    /// # Errors
926    ///
927    /// Returns an error if parsing or analysis fails.
928    pub fn analyze_dead_code_with_session_artifacts(
929        &self,
930        need_complexity: bool,
931        retain_graph: bool,
932        changed_files: Option<FxHashSet<PathBuf>>,
933    ) -> EngineResult<AnalysisSessionArtifacts> {
934        Ok(AnalysisSessionArtifacts {
935            analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
936            changed_files,
937            source_fingerprints: self.source_fingerprints(),
938        })
939    }
940
941    /// Run dead-code and duplication analysis for this session.
942    ///
943    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
944    /// parser artifacts needed by editor overlays such as inline complexity.
945    ///
946    /// # Errors
947    ///
948    /// Returns an error if dead-code parsing or analysis fails.
949    pub fn analyze_project_with(
950        &self,
951        duplicates_config: &DuplicatesConfig,
952        retain_complexity_artifacts: bool,
953    ) -> EngineResult<ProjectAnalysisOutput> {
954        self.analyze_project_with_artifacts(
955            duplicates_config,
956            ProjectAnalysisArtifactOptions {
957                retain_complexity_artifacts,
958                ..ProjectAnalysisArtifactOptions::default()
959            },
960        )
961        .map(ProjectAnalysisArtifacts::into_output)
962    }
963
964    /// Run dead-code and duplication analysis with retained session reuse data.
965    ///
966    /// This is the engine-owned project artifact boundary for callers that need
967    /// to hand one analysis result across audit, decision, editor, or follow-up
968    /// analysis surfaces without rediscovering session metadata.
969    ///
970    /// # Errors
971    ///
972    /// Returns an error if dead-code parsing or analysis fails.
973    pub fn analyze_project_with_artifacts(
974        &self,
975        duplicates_config: &DuplicatesConfig,
976        options: ProjectAnalysisArtifactOptions,
977    ) -> EngineResult<ProjectAnalysisArtifacts> {
978        self.ensure_not_cancelled("duplication detection")?;
979        let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
980        let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
981            let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
982            self.find_duplicates_touching_files_with_defaults(
983                duplicates_config,
984                &changed_files,
985                cache_dir,
986            )
987            .report
988        } else {
989            self.find_duplicates_with_defaults(duplicates_config, cache_dir)
990                .report
991        };
992        // Duplication detection is infallible, so a token set while it ran can
993        // only be reported here, before its report is handed on as a complete
994        // one.
995        self.ensure_not_cancelled("the dead-code half of project analysis")?;
996        let source_fingerprints = options
997            .collect_source_fingerprints
998            .then(|| self.source_fingerprints());
999        Ok(ProjectAnalysisArtifacts {
1000            dead_code: self.analyze_dead_code_with_artifacts(
1001                options.retain_complexity_artifacts,
1002                options.retain_graph,
1003            )?,
1004            duplication,
1005            changed_files: options.changed_files,
1006            source_fingerprints,
1007        })
1008    }
1009
1010    /// Run duplication detection and return report sidecar metadata.
1011    #[must_use]
1012    pub fn find_duplicates_with_defaults(
1013        &self,
1014        config: &DuplicatesConfig,
1015        cache_dir: Option<&Path>,
1016    ) -> DuplicationAnalysis {
1017        duplicates::find_duplicates_with_defaults(
1018            &self.config.root,
1019            self.files(),
1020            config,
1021            cache_dir,
1022        )
1023    }
1024
1025    /// Run focused duplication detection for a changed-file set.
1026    #[must_use]
1027    pub fn find_duplicates_touching_files_with_defaults(
1028        &self,
1029        config: &DuplicatesConfig,
1030        changed_files: &[PathBuf],
1031        cache_dir: Option<&Path>,
1032    ) -> DuplicationAnalysis {
1033        duplicates::find_duplicates_touching_files_with_defaults(
1034            &self.config.root,
1035            self.files(),
1036            config,
1037            changed_files,
1038            cache_dir,
1039        )
1040    }
1041
1042    /// Parse the discovered files, reusing the session's warm module cache.
1043    ///
1044    /// `cancellation` is threaded through to the per-file parse loop, so a set
1045    /// token truncates the returned modules. Only callers that convert a set
1046    /// token into an error may pass one.
1047    fn parse_modules(
1048        &self,
1049        need_complexity: bool,
1050        cancellation: Option<&AtomicBool>,
1051    ) -> SharedParsedModules {
1052        let read_started_ns = now_ns();
1053        let fingerprints = source_fingerprints_for_files(self.files());
1054        if let Some(fingerprints) = fingerprints.as_ref()
1055            && let Some(parsed) = self.reparse_changed_modules(
1056                need_complexity,
1057                fingerprints,
1058                read_started_ns,
1059                cancellation,
1060            )
1061        {
1062            return parsed;
1063        }
1064
1065        let (modules, metrics, has_complexity, modules_reused, problems) = if let Some(warm) = self
1066            .warm_parse(
1067                need_complexity,
1068                fingerprints.as_deref(),
1069                read_started_ns,
1070                cancellation,
1071            ) {
1072            let reused = if warm.reused { warm.modules.len() } else { 0 };
1073            (warm.modules, warm.metrics, true, reused, warm.problems)
1074        } else {
1075            let ParsedModules {
1076                modules,
1077                metrics,
1078                read_failures,
1079                parse_degradations,
1080                ..
1081            } = parse_files_with_config(&self.config, self.files(), need_complexity, cancellation);
1082            let problems = SourceProblems {
1083                read_failures,
1084                parse_degradations,
1085            };
1086            (modules.into(), metrics, need_complexity, 0, problems)
1087        };
1088        self.parse_counts.record(SessionParseCounts {
1089            modules_parsed: metrics.cache_misses,
1090            disk_cache_hits: metrics.cache_hits,
1091            modules_reused,
1092        });
1093        // A cancelled parse returns a truncated module set. Storing it would
1094        // serve that truncation to the next call as a warm cache hit, long
1095        // after the cancellation itself is forgotten.
1096        if !token_is_set(cancellation)
1097            && let Some(fingerprints) = fingerprints
1098            && let Ok(mut cache) = self.parsed_cache.lock()
1099        {
1100            *cache = Some(ParsedModuleCache {
1101                need_complexity: has_complexity,
1102                fingerprints,
1103                read_started_ns,
1104                modules: Arc::clone(&modules),
1105                read_failures: problems.read_failures,
1106                parse_degradations: problems.parse_degradations,
1107            });
1108            // The full parse wrote the persisted cache for these modules.
1109            self.disk_cache_stale.store(false, Ordering::SeqCst);
1110            self.clear_styling_cache();
1111        }
1112        SharedParsedModules { modules, metrics }
1113    }
1114
1115    /// Parse through the store of parsed modules across sessions.
1116    ///
1117    /// Returns `None` when the session has no store, when caching is off, or
1118    /// when a fingerprint cannot stand in for the file content. The caller
1119    /// then parses through the persisted cache only.
1120    ///
1121    /// The parse always computes complexity. The cost is
1122    /// about the same, and a later `health` session can then use the modules.
1123    /// The persisted cache and the in-session cache already serve modules with
1124    /// complexity to callers that need none. One case costs more: a persisted
1125    /// cache without complexity, from a CLI `dead-code` run, gives no hits, so
1126    /// the first parse through the store parses each file from source.
1127    fn warm_parse(
1128        &self,
1129        need_complexity: bool,
1130        fingerprints: Option<&[SourceFingerprint]>,
1131        read_started_ns: u64,
1132        cancellation: Option<&AtomicBool>,
1133    ) -> Option<WarmParsedModules> {
1134        let store = self.warm_parse.as_deref()?;
1135        if self.config.no_cache {
1136            return None;
1137        }
1138        let key = WarmParseKey {
1139            root: &self.config.root,
1140            cache_config_hash: self.config.cache_config_hash,
1141            files: self.files(),
1142            fingerprints: fingerprints?,
1143            read_started_ns,
1144        };
1145        if !key.is_reusable() {
1146            return None;
1147        }
1148        if let Some(parse) = store.get(&key, need_complexity) {
1149            // A parse records its read failures and parse degradations for the
1150            // project, and the session reads them back as workspace
1151            // diagnostics. Record the kept ones again, so the diagnostics of
1152            // this run are the same as after a parse.
1153            record_source_diagnostics(
1154                &self.config.root,
1155                &parse.read_failures,
1156                &parse.parse_degradations,
1157            );
1158            return Some(WarmParsedModules {
1159                modules: parse.modules,
1160                metrics: reused_parse_metrics(),
1161                reused: true,
1162                problems: SourceProblems {
1163                    read_failures: parse.read_failures.to_vec(),
1164                    parse_degradations: parse.parse_degradations.to_vec(),
1165                },
1166            });
1167        }
1168
1169        let parsed = parse_files_with_config(&self.config, self.files(), true, cancellation);
1170        store.record_parse(parsed.metrics.cache_misses, parsed.metrics.cache_hits);
1171        let modules: Arc<[ModuleInfo]> = parsed.modules.into();
1172        if !token_is_set(cancellation) {
1173            store.put(
1174                &key,
1175                true,
1176                WarmParse {
1177                    modules: Arc::clone(&modules),
1178                    read_failures: parsed.read_failures.clone().into(),
1179                    parse_degradations: parsed.parse_degradations.clone().into(),
1180                },
1181            );
1182        }
1183        Some(WarmParsedModules {
1184            modules,
1185            metrics: parsed.metrics,
1186            reused: false,
1187            problems: SourceProblems {
1188                read_failures: parsed.read_failures,
1189                parse_degradations: parsed.parse_degradations,
1190            },
1191        })
1192    }
1193
1194    /// The file positions that changed since the kept parse, with the read
1195    /// start and the modules of that parse.
1196    ///
1197    /// The fingerprints are compared under the lock. The unsettled files are
1198    /// read after it, so a concurrent caller does not wait on disk work.
1199    /// Returns `None` when there is no kept parse, when it lacks the requested
1200    /// complexity, or when the file set differs.
1201    fn changed_since_kept_parse(
1202        &self,
1203        need_complexity: bool,
1204        fingerprints: &[SourceFingerprint],
1205    ) -> Option<(Vec<usize>, u64, Arc<[ModuleInfo]>)> {
1206        let unsettled = std::cell::RefCell::new(Vec::new());
1207        let guard = self.parsed_cache.lock().ok()?;
1208        let cache = guard.as_ref()?;
1209        if need_complexity && !cache.need_complexity {
1210            return None;
1211        }
1212        let mut changed = changed_file_indices(
1213            &cache.fingerprints,
1214            cache.read_started_ns,
1215            fingerprints,
1216            &|index| {
1217                unsettled.borrow_mut().push(index);
1218                true
1219            },
1220        )?;
1221        let kept_read_started_ns = cache.read_started_ns;
1222        let kept_modules = Arc::clone(&cache.modules);
1223        drop(guard);
1224        changed.extend(unsettled.into_inner().into_iter().filter(|&index| {
1225            !self.files().get(index).is_some_and(|file| {
1226                crate::session_reuse::module_matches_source(&kept_modules, file.id, &file.path)
1227            })
1228        }));
1229        changed.sort_unstable();
1230        Some((changed, kept_read_started_ns, kept_modules))
1231    }
1232
1233    /// Parse only the files whose fingerprint changed since the cached parse,
1234    /// and keep the other cached modules. When no file changed, return the
1235    /// cached modules as they are.
1236    ///
1237    /// Returns `None` when the cache cannot serve the request: no cached
1238    /// parse, a cache without the requested complexity, a different file set,
1239    /// or more changed files than [`MAX_INCREMENTAL_REPARSE_FILES`]. The
1240    /// caller then parses every file through the persisted cache.
1241    fn reparse_changed_modules(
1242        &self,
1243        need_complexity: bool,
1244        fingerprints: &[SourceFingerprint],
1245        read_started_ns: u64,
1246        cancellation: Option<&AtomicBool>,
1247    ) -> Option<SharedParsedModules> {
1248        let (changed, kept_read_started_ns, kept_modules) =
1249            self.changed_since_kept_parse(need_complexity, fingerprints)?;
1250        let mut guard = self.parsed_cache.lock().ok()?;
1251        let cache = guard.as_mut()?;
1252        // A concurrent cache change during the reads falls back to a full
1253        // parse. This is rare and accepted.
1254        if !Arc::ptr_eq(&cache.modules, &kept_modules)
1255            || cache.read_started_ns != kept_read_started_ns
1256        {
1257            return None;
1258        }
1259        // Release the extra reference, so the merge below can update the
1260        // modules in place.
1261        drop(kept_modules);
1262        if changed.is_empty() {
1263            // Each recent file had the kept content, and the content was read
1264            // after `read_started_ns`, so the fingerprints now prove it from
1265            // that time on.
1266            cache.read_started_ns = read_started_ns;
1267            let modules = Arc::clone(&cache.modules);
1268            drop(guard);
1269            self.parse_counts.record(SessionParseCounts {
1270                modules_reused: modules.len(),
1271                ..SessionParseCounts::default()
1272            });
1273            return Some(SharedParsedModules {
1274                modules,
1275                metrics: reused_parse_metrics(),
1276            });
1277        }
1278        if changed.len() > MAX_INCREMENTAL_REPARSE_FILES {
1279            return None;
1280        }
1281        let files: Vec<DiscoveredFile> = changed
1282            .iter()
1283            .filter_map(|&index| self.files().get(index).cloned())
1284            .collect();
1285        let parse_start = Instant::now();
1286        let parsed = crate::source::parse_all_files(
1287            &files,
1288            None,
1289            cache.need_complexity,
1290            cancellation,
1291            &self.config.flags.patterns(),
1292        );
1293        // The caller turns a set token into an error. The cache keeps the
1294        // earlier complete modules, because the parse above may be truncated.
1295        if token_is_set(cancellation) {
1296            return Some(SharedParsedModules {
1297                modules: Arc::clone(&cache.modules),
1298                metrics: reused_parse_metrics(),
1299            });
1300        }
1301        let mut fresh = parsed.modules;
1302        for module in &mut fresh {
1303            module.prepare_analysis_facts();
1304        }
1305        let fresh_count = fresh.len();
1306        let reparsed: Vec<_> = files.iter().map(|file| file.id).collect();
1307        merge_reparsed_modules(&mut cache.modules, &reparsed, fresh);
1308        cache.fingerprints = fingerprints.to_vec();
1309        cache.read_started_ns = read_started_ns;
1310        cache
1311            .read_failures
1312            .retain(|failure| !reparsed.contains(&failure.file_id));
1313        cache.read_failures.extend(parsed.read_failures);
1314        cache
1315            .parse_degradations
1316            .retain(|degradation| !reparsed.contains(&degradation.file_id));
1317        cache.parse_degradations.extend(parsed.parse_degradations);
1318        record_source_diagnostics(
1319            &self.config.root,
1320            &cache.read_failures,
1321            &cache.parse_degradations,
1322        );
1323        self.parse_counts.record(SessionParseCounts {
1324            modules_parsed: parsed.cache_misses,
1325            disk_cache_hits: 0,
1326            modules_reused: cache.modules.len().saturating_sub(fresh_count),
1327        });
1328        let modules = Arc::clone(&cache.modules);
1329        drop(guard);
1330        self.disk_cache_stale.store(true, Ordering::SeqCst);
1331        self.clear_styling_cache();
1332        Some(SharedParsedModules {
1333            modules,
1334            metrics: core_backend::ParseMetrics {
1335                parse_ms: parse_start.elapsed().as_secs_f64() * 1000.0,
1336                cache_ms: 0.0,
1337                cache_hits: 0,
1338                cache_misses: parsed.cache_misses,
1339                parse_cpu_ms: parsed.parse_cpu_ms,
1340                cache_rejection: None,
1341                files_read: parsed.files_read,
1342                source_bytes_read: parsed.source_bytes_read,
1343                parse_cache_bytes_read: 0,
1344                css_masked_bytes: parsed.css_masked_bytes,
1345                parse_cache_load_ms: 0.0,
1346            },
1347        })
1348    }
1349}
1350
1351struct ParsedModules {
1352    modules: Vec<ModuleInfo>,
1353    metrics: core_backend::ParseMetrics,
1354    source_diagnostics: Vec<WorkspaceDiagnostic>,
1355    read_failures: Vec<SourceReadFailure>,
1356    parse_degradations: Vec<SourceParseDegradation>,
1357}
1358
1359struct SharedParsedModules {
1360    modules: Arc<[ModuleInfo]>,
1361    metrics: core_backend::ParseMetrics,
1362}
1363
1364/// Modules from [`AnalysisSession::warm_parse`]. They always have complexity.
1365struct WarmParsedModules {
1366    modules: Arc<[ModuleInfo]>,
1367    metrics: core_backend::ParseMetrics,
1368    /// The modules came from the store without parse work.
1369    reused: bool,
1370    problems: SourceProblems,
1371}
1372
1373/// The files of a parse that did not read or that parsed with errors.
1374struct SourceProblems {
1375    read_failures: Vec<SourceReadFailure>,
1376    parse_degradations: Vec<SourceParseDegradation>,
1377}
1378
1379fn token_is_set(cancellation: Option<&AtomicBool>) -> bool {
1380    cancellation.is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
1381}
1382
1383fn parse_files_with_config(
1384    config: &ResolvedConfig,
1385    files: &[DiscoveredFile],
1386    need_complexity: bool,
1387    cancellation: Option<&AtomicBool>,
1388) -> ParsedModules {
1389    let parse_start = Instant::now();
1390    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1391    let mut cache_rejection = None;
1392    let mut parse_cache_bytes_read = 0;
1393    let mut parse_cache_load_ms = 0.0;
1394    let mut cache = if config.no_cache {
1395        None
1396    } else {
1397        let load_start = Instant::now();
1398        let (loaded, bytes_read) = fallow_extract::cache::CacheStore::load_counting_bytes(
1399            &config.cache_dir,
1400            &config.root,
1401            config.cache_config_hash,
1402            cache_max_size_bytes,
1403        );
1404        parse_cache_bytes_read = bytes_read;
1405        parse_cache_load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
1406        match loaded {
1407            Ok(store) => Some(store),
1408            Err(rejection) => {
1409                cache_rejection = Some(rejection);
1410                None
1411            }
1412        }
1413    };
1414    let read_started_ns = now_ns();
1415    let parse_result = crate::source::parse_all_files(
1416        files,
1417        cache.as_ref(),
1418        need_complexity,
1419        cancellation,
1420        &config.flags.patterns(),
1421    );
1422    let source_diagnostics = record_source_diagnostics(
1423        &config.root,
1424        &parse_result.read_failures,
1425        &parse_result.parse_degradations,
1426    );
1427    let mut modules = parse_result.modules;
1428    for module in &mut modules {
1429        module.prepare_analysis_facts();
1430    }
1431    let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
1432    let cache_ms = if token_is_set(cancellation) {
1433        0.0
1434    } else {
1435        update_parse_cache_if_enabled(
1436            config,
1437            &mut cache,
1438            &ParseCacheWrite {
1439                modules: &modules,
1440                files,
1441                need_complexity,
1442                fingerprint_of: &|file: &DiscoveredFile| {
1443                    source_fingerprint(&file.path).for_content_read_at(read_started_ns)
1444                },
1445            },
1446        )
1447    };
1448    let metrics = core_backend::ParseMetrics {
1449        parse_ms,
1450        cache_ms,
1451        cache_hits: parse_result.cache_hits,
1452        cache_misses: parse_result.cache_misses,
1453        parse_cpu_ms: parse_result.parse_cpu_ms,
1454        cache_rejection,
1455        files_read: parse_result.files_read,
1456        source_bytes_read: parse_result.source_bytes_read,
1457        parse_cache_bytes_read,
1458        css_masked_bytes: parse_result.css_masked_bytes,
1459        parse_cache_load_ms,
1460    };
1461    ParsedModules {
1462        modules,
1463        metrics,
1464        source_diagnostics,
1465        read_failures: parse_result.read_failures,
1466        parse_degradations: parse_result.parse_degradations,
1467    }
1468}
1469
1470/// Record the read failures and parse degradations of a parse for the
1471/// project, and return them as workspace diagnostics.
1472fn record_source_diagnostics(
1473    root: &Path,
1474    read_failures: &[SourceReadFailure],
1475    parse_degradations: &[SourceParseDegradation],
1476) -> Vec<WorkspaceDiagnostic> {
1477    let mut diagnostics = fallow_config::record_source_read_failures(root, read_failures);
1478    diagnostics.extend(fallow_config::record_source_parse_degradations(
1479        root,
1480        parse_degradations,
1481    ));
1482    diagnostics
1483}
1484
1485fn reused_parse_metrics() -> core_backend::ParseMetrics {
1486    core_backend::ParseMetrics {
1487        parse_ms: 0.0,
1488        cache_ms: 0.0,
1489        cache_hits: 0,
1490        cache_misses: 0,
1491        parse_cpu_ms: 0.0,
1492        cache_rejection: None,
1493        files_read: 0,
1494        source_bytes_read: 0,
1495        parse_cache_bytes_read: 0,
1496        css_masked_bytes: 0,
1497        parse_cache_load_ms: 0.0,
1498    }
1499}
1500
1501fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
1502    files
1503        .iter()
1504        .map(|file| {
1505            std::fs::metadata(&file.path)
1506                .ok()
1507                .map(|metadata| SourceFingerprint::from_metadata(&metadata))
1508                .filter(|fingerprint| fingerprint.has_known_mtime())
1509        })
1510        .collect()
1511}
1512
1513fn update_parse_cache_if_enabled(
1514    config: &ResolvedConfig,
1515    cache: &mut Option<fallow_extract::cache::CacheStore>,
1516    write: &ParseCacheWrite<'_>,
1517) -> f64 {
1518    let start = Instant::now();
1519    write_parse_cache(config, cache, write);
1520    start.elapsed().as_secs_f64() * 1000.0
1521}
1522
1523/// Modules to store in the persisted parse cache, with the fingerprint that
1524/// each file gets in the cache.
1525struct ParseCacheWrite<'a> {
1526    modules: &'a [ModuleInfo],
1527    files: &'a [DiscoveredFile],
1528    need_complexity: bool,
1529    fingerprint_of: &'a dyn Fn(&DiscoveredFile) -> SourceFingerprint,
1530}
1531
1532fn write_parse_cache(
1533    config: &ResolvedConfig,
1534    cache: &mut Option<fallow_extract::cache::CacheStore>,
1535    write: &ParseCacheWrite<'_>,
1536) {
1537    if config.no_cache {
1538        return;
1539    }
1540
1541    let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
1542    let store = cache.get_or_insert_with(|| fallow_extract::cache::CacheStore::new(&config.root));
1543    if update_parse_cache(store, write)
1544        && let Err(error) = store.save(
1545            &config.cache_dir,
1546            config.cache_config_hash,
1547            cache_max_size_bytes,
1548        )
1549    {
1550        tracing::warn!("Failed to save cache: {error}");
1551    }
1552}
1553
1554/// Mirror of `fallow_core`'s `update_cache` for session-owned parsing: rewrite
1555/// an unchanged entry only when its metadata moved or when this run can add
1556/// complexity the entry lacks, and never let a complexity-blind run strip the
1557/// complexity a `health` run stored.
1558fn update_parse_cache(
1559    store: &mut fallow_extract::cache::CacheStore,
1560    write: &ParseCacheWrite<'_>,
1561) -> bool {
1562    let ParseCacheWrite {
1563        modules,
1564        files,
1565        need_complexity,
1566        fingerprint_of,
1567    } = *write;
1568    let mut dirty = false;
1569    for module in modules {
1570        if let Some(file) = files.get(module.file_id.0 as usize) {
1571            let fingerprint = fingerprint_of(file);
1572            if let Some(cached) = store.get_by_path_only(&file.path)
1573                && cached.content_hash == module.content_hash
1574            {
1575                let stale_metadata = cached.source_fingerprint() != fingerprint;
1576                let adds_complexity = need_complexity && !cached.complexity_extracted;
1577                if stale_metadata && !adds_complexity {
1578                    store.refresh_fingerprint(&file.path, fingerprint);
1579                    dirty = true;
1580                } else if adds_complexity {
1581                    let preserved_complexity = (!need_complexity && cached.complexity_extracted)
1582                        .then(|| cached.complexity.clone());
1583                    let mut refreshed = fallow_extract::cache::module_to_cached(
1584                        module,
1585                        fingerprint,
1586                        need_complexity,
1587                    );
1588                    if let Some(complexity) = preserved_complexity {
1589                        refreshed.complexity = complexity;
1590                        refreshed.complexity_extracted = true;
1591                    }
1592                    store.insert(&file.path, refreshed);
1593                    dirty = true;
1594                }
1595                continue;
1596            }
1597            store.insert(
1598                &file.path,
1599                fallow_extract::cache::module_to_cached(module, fingerprint, need_complexity),
1600            );
1601            dirty = true;
1602        }
1603    }
1604    store.retain_paths(files) || dirty
1605}
1606
1607fn source_fingerprint(path: &Path) -> SourceFingerprint {
1608    std::fs::metadata(path).map_or_else(
1609        |_| SourceFingerprint::new(0, 0),
1610        |metadata| SourceFingerprint::from_metadata(&metadata),
1611    )
1612}
1613
1614struct EngineDeadCodePipelineInput<'a> {
1615    config: &'a ResolvedConfig,
1616    discovery: &'a crate::discover::AnalysisDiscovery,
1617    modules: Arc<[ModuleInfo]>,
1618    metrics: core_backend::ParseMetrics,
1619    collect_usages: bool,
1620    retain_graph: bool,
1621    retain_modules: bool,
1622    retain_files: bool,
1623    cancellation: Option<&'a AtomicBool>,
1624}
1625
1626fn run_engine_owned_dead_code_pipeline(
1627    input: EngineDeadCodePipelineInput<'_>,
1628) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
1629    let EngineDeadCodePipelineInput {
1630        config,
1631        discovery,
1632        modules,
1633        metrics,
1634        collect_usages,
1635        retain_graph,
1636        retain_modules,
1637        retain_files,
1638        cancellation,
1639    } = input;
1640    let stopped = |stage: &str| -> EngineResult<()> {
1641        if token_is_set(cancellation) {
1642            return Err(crate::EngineError::cancelled(stage));
1643        }
1644        Ok(())
1645    };
1646    stopped("the dead-code prelude")?;
1647    let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
1648    let prelude_timings = prelude.timings();
1649    stopped("dead-code entry-point discovery")?;
1650    let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
1651    stopped("import resolution and graph construction")?;
1652    let (resolved, graph, graph_cache_rejection) =
1653        resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
1654    stopped("the dead-code detectors")?;
1655
1656    let mut detector = core_backend::run_dead_code_detectors(
1657        &prelude,
1658        &graph.graph,
1659        &resolved.project.modules,
1660        &modules,
1661        collect_usages,
1662        &entry_points,
1663    );
1664    // Stamp ids on the full set, before any filter removes a finding. The
1665    // tiebreak suffix depends on the other findings with the same identity.
1666    crate::dead_code::stamp_finding_ids(&mut detector.results, &config.root);
1667    // Security ids too, so every surface reads the id this pipeline set.
1668    crate::security::stamp_security_finding_ids(
1669        &mut detector.results.security_findings,
1670        &config.root,
1671    );
1672    crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
1673    // The detectors are the longest uninterruptible stage. Without this a
1674    // token set inside them yields a complete report, so the same request
1675    // would be answered with results or with `cancelled` depending only on
1676    // which side of the stage the flip landed.
1677    stopped("assembling the dead-code report")?;
1678    let profile =
1679        core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
1680            retain_timings: retain_graph,
1681            prelude: &prelude,
1682            prelude_timings,
1683            parse_metrics: metrics,
1684            module_count: modules.len(),
1685            entry_points: &entry_points,
1686            resolved: &resolved,
1687            graph: &graph,
1688            detector: &detector,
1689            file_count: discovery.files().len(),
1690            workspace_count: discovery.workspaces().len(),
1691            graph_cache_rejection,
1692        });
1693    let script_used_packages = prelude.script_used_packages();
1694    let trace_provenance = prelude.trace_provenance(&modules);
1695    prelude.finish();
1696    let file_hashes = collect_file_hashes(&modules, discovery.files());
1697
1698    Ok(SharedDeadCodeAnalysisArtifacts {
1699        results: detector.results,
1700        timings: profile.timings,
1701        graph: retain_graph.then_some(graph.graph),
1702        modules: retain_modules.then_some(modules),
1703        files: retain_files.then(|| discovery.files().to_vec()),
1704        script_used_packages,
1705        trace_provenance,
1706        file_hashes,
1707    })
1708}
1709
1710/// Reuse the persisted module graph, or rebuild it and carry the reason the
1711/// persisted one was refused.
1712///
1713/// The reason is the third element rather than a discarded `Option`: a warm run
1714/// that paid for a multi-megabyte decode and reused none of it is the case the
1715/// perf table exists to explain, and every engine-backed command reaches the
1716/// pipeline through here.
1717fn resolve_or_build_dead_code_graph(
1718    prelude: &core_backend::DeadCodeBackendPrelude,
1719    entry_points: &core_backend::DeadCodeEntryPoints,
1720    modules: &[ModuleInfo],
1721) -> (
1722    core_backend::DeadCodeResolvedModules,
1723    core_backend::DeadCodeGraphRun,
1724    Option<CacheRejection>,
1725) {
1726    let rejection =
1727        match core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules) {
1728            Ok((resolved, graph)) => return (resolved, graph, None),
1729            Err(rejection) => rejection,
1730        };
1731
1732    let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
1733    let graph =
1734        core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
1735    (resolved, graph, rejection)
1736}
1737
1738fn collect_file_hashes(
1739    modules: &[ModuleInfo],
1740    files: &[DiscoveredFile],
1741) -> FxHashMap<PathBuf, u64> {
1742    modules
1743        .iter()
1744        .filter_map(|module| {
1745            files
1746                .get(module.file_id.0 as usize)
1747                .map(|file| (file.path.clone(), module.content_hash))
1748        })
1749        .collect()
1750}
1751
1752pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1753    config: &ResolvedConfig,
1754    modules: &[ModuleInfo],
1755) -> EngineResult<DeadCodeAnalysisArtifacts> {
1756    let (workspaces, _diagnostics, workspaces_ms) =
1757        crate::project_config::collect_workspace_metadata(config)?;
1758    let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1759        config,
1760        &workspaces,
1761        workspaces_ms,
1762    );
1763    run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1764        config,
1765        discovery: &discovery,
1766        modules: Arc::from(modules),
1767        metrics: reused_parse_metrics(),
1768        collect_usages: true,
1769        retain_graph: true,
1770        retain_modules: false,
1771        retain_files: false,
1772        cancellation: None,
1773    })
1774    .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779    use std::fmt::Write as _;
1780    use std::time::Duration;
1781
1782    use super::*;
1783
1784    fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1785        let project = tempfile::tempdir().expect("project");
1786        let root = project.path();
1787        std::fs::create_dir(root.join("src")).expect("create source directory");
1788        std::fs::write(root.join("src/index.ts"), source).expect("write source");
1789        let session = AnalysisSession::load_default(root);
1790        (project, session)
1791    }
1792
1793    fn class_projection_session(root: &Path) -> AnalysisSession {
1794        AnalysisSession::load_default_with_config(root, |config| {
1795            config.rules.unused_class_members = fallow_config::Severity::Error;
1796        })
1797    }
1798
1799    fn session_class_projection(session: &AnalysisSession) -> Vec<String> {
1800        let analysis = session.analyze_dead_code().expect("class member analysis");
1801        assert!(analysis.results.unresolved_imports.is_empty());
1802        let mut members: Vec<_> = analysis
1803            .results
1804            .unused_class_members
1805            .iter()
1806            .filter(|finding| finding.member.parent_name == "C")
1807            .map(|finding| finding.member.member_name.clone())
1808            .collect();
1809        members.sort();
1810        members
1811    }
1812
1813    #[test]
1814    fn factory_property_proof_refresh_matches_fresh_sessions() {
1815        for (present, absent) in [
1816            (
1817                "import { C } from './classes'; const make = (): C => new C(); export const createUi = () => ({ page: make() });",
1818                "export const createUi = () => ({ page: { used() {} } });",
1819            ),
1820            (
1821                "import { Owner } from './owner'; export const createUi = () => { const owner = new Owner(); return { page: owner.widget }; };",
1822                "export const createUi = () => ({ page: { used() {} } });",
1823            ),
1824        ] {
1825            let project = tempfile::tempdir().expect("session fixture");
1826            let root = project.path();
1827            std::fs::write(
1828                root.join("package.json"),
1829                r#"{"name":"producer-refresh","type":"module","main":"index.ts"}"#,
1830            )
1831            .expect("package");
1832            std::fs::write(
1833                root.join("classes.ts"),
1834                "export class C { control() {} used() {} dead() {} }",
1835            )
1836            .expect("class");
1837            std::fs::write(
1838                root.join("owner.ts"),
1839                "import { C } from './classes'; export class Owner { widget: C = new C(); }",
1840            )
1841            .expect("owner");
1842            std::fs::write(root.join("index.ts"), "import { createUi } from './factory'; const ui = createUi(); ui.page.used(); import { C } from './classes'; new C().control();").expect("consumer");
1843            let path = root.join("factory.ts");
1844            std::fs::write(&path, present).expect("initial producer");
1845            let mut session = class_projection_session(root);
1846            assert_eq!(session_class_projection(&session), ["dead"]);
1847            for (source, expected) in [(absent, vec!["dead", "used"]), (present, vec!["dead"])] {
1848                std::fs::write(&path, source).expect("producer update");
1849                session.refresh_discovery();
1850                assert_eq!(session_class_projection(&session), expected);
1851                assert_eq!(
1852                    session_class_projection(&session),
1853                    session_class_projection(&class_projection_session(root))
1854                );
1855            }
1856        }
1857    }
1858
1859    #[test]
1860    fn callback_property_proof_refresh_matches_fresh_sessions() {
1861        for (present, absent) in [
1862            (
1863                "export function wrap<T>(callback: () => T) { return { value: callback() }; }",
1864                "export function wrap<T>(callback: () => T) { return { value: { used() {} } }; }",
1865            ),
1866            (
1867                "export { computed as wrap } from 'vue';",
1868                "export { computed as wrap } from 'foreign';",
1869            ),
1870        ] {
1871            let project = tempfile::tempdir().expect("callback session fixture");
1872            let root = project.path();
1873            std::fs::write(
1874                root.join("package.json"),
1875                r#"{"name":"callback-refresh","type":"module","main":"index.ts","dependencies":{"vue":"*","foreign":"*"}}"#,
1876            )
1877            .expect("package");
1878            std::fs::write(
1879                root.join("classes.ts"),
1880                "export class C { control() {} used() {} dead() {} }",
1881            )
1882            .expect("class");
1883            std::fs::write(root.join("index.ts"), "import { wrap } from './factory'; import { C } from './classes'; new C().control(); wrap(() => new C()).value.used();").expect("consumer");
1884            let path = root.join("factory.ts");
1885            std::fs::write(&path, present).expect("initial producer");
1886            let mut session = class_projection_session(root);
1887            assert_eq!(session_class_projection(&session), ["dead"]);
1888            for (source, expected) in [(absent, vec!["dead", "used"]), (present, vec!["dead"])] {
1889                std::fs::write(&path, source).expect("producer update");
1890                session.refresh_discovery();
1891                assert_eq!(session_class_projection(&session), expected);
1892                assert_eq!(
1893                    session_class_projection(&session),
1894                    session_class_projection(&class_projection_session(root))
1895                );
1896            }
1897        }
1898    }
1899
1900    #[test]
1901    fn callback_review_proof_refresh_matches_fresh_sessions() {
1902        for (present, absent) in [
1903            (
1904                "let current = new C(); wrap((ignored = 0) => current).value.used();",
1905                "let current = new C(); wrap((ignored = (current = { used() {} })) => current).value.used();",
1906            ),
1907            (
1908                "import { computed } from 'vue'; const getter = () => new C(); const alias = getter; const options = { get: alias, set: value => {} }; computed(options).value.used();",
1909                "import { computed } from 'vue'; const getter = () => new C(); const alias = getter; const options = { get: alias, set: value => {} }; options.get = () => ({ used() {} }); computed(options).value.used();",
1910            ),
1911            (
1912                "let current = new C(); wrap(() => current).value.used();",
1913                "let current = new C(); wrap(() => { current = { used() {} }; return current; }).value.used();",
1914            ),
1915            (
1916                "import { computed } from 'vue'; const options = { get: () => new C(), set: value => {} }; const alias = options; computed(alias).value.used();",
1917                "import { computed } from 'vue'; const options = { get: () => new C(), set: value => {} }; const alias = options; alias.get = () => ({ used() {} }); computed(options).value.used();",
1918            ),
1919        ] {
1920            let project = tempfile::tempdir().expect("callback review session fixture");
1921            let root = project.path();
1922            std::fs::write(root.join("package.json"), r#"{"name":"callback-review-refresh","type":"module","main":"index.ts","dependencies":{"vue":"*"}}"#).expect("package");
1923            std::fs::write(
1924                root.join("classes.ts"),
1925                "export class C { control() {} used() {} dead() {} }",
1926            )
1927            .expect("class");
1928            std::fs::write(
1929                root.join("helper.ts"),
1930                "export function wrap(fn) { return { value: fn() }; }",
1931            )
1932            .expect("helper");
1933            let path = root.join("index.ts");
1934            let source = |body: &str| {
1935                format!(
1936                    "import {{ C }} from './classes'; import {{ wrap }} from './helper'; new C().control(); {body}"
1937                )
1938            };
1939            std::fs::write(&path, source(present)).expect("initial source");
1940            let mut session = class_projection_session(root);
1941            assert_eq!(session_class_projection(&session), ["dead"]);
1942            for (body, expected) in [(absent, vec!["dead", "used"]), (present, vec!["dead"])] {
1943                std::fs::write(&path, source(body)).expect("source update");
1944                session.refresh_discovery();
1945                assert_eq!(session_class_projection(&session), expected);
1946                assert_eq!(
1947                    session_class_projection(&session),
1948                    session_class_projection(&class_projection_session(root))
1949                );
1950            }
1951        }
1952    }
1953
1954    #[test]
1955    fn indexed_source_proof_removal_and_restoration_matches_fresh_sessions() {
1956        for (label, present, absent, access) in [
1957            (
1958                "literal",
1959                "const items = [new C()];",
1960                "const items = [new Other()];",
1961                "items[0].used();",
1962            ),
1963            (
1964                "vue",
1965                "import { computed } from 'vue';const items = computed(() => [new C()]);",
1966                "import { computed } from 'foreign';const items = computed(() => [new C()]);",
1967                "items.value[0].used();",
1968            ),
1969        ] {
1970            let project = tempfile::tempdir().expect("session fixture");
1971            let root = project.path();
1972            std::fs::create_dir(root.join("src")).expect("source directory");
1973            std::fs::write(root.join("package.json"),
1974                r#"{"name":"session-array-proof","type":"module","main":"src/index.ts","dependencies":{"vue":"*","foreign":"*"}}"#)
1975                .expect("package manifest");
1976            std::fs::write(root.join("src/classes.ts"),
1977                "export class C { control() {} used() {} dead() {} } export class Other { used() {} }")
1978                .expect("classes");
1979            for provider in ["vue", "foreign"] {
1980                let directory = root.join("node_modules").join(provider);
1981                std::fs::create_dir_all(&directory).expect("provider directory");
1982                std::fs::write(
1983                    directory.join("package.json"),
1984                    format!(r#"{{"name":"{provider}","type":"module","main":"index.js"}}"#),
1985                )
1986                .expect("provider package");
1987                std::fs::write(
1988                    directory.join("index.js"),
1989                    "export const computed = getter => ({ get value() { return getter(); } });",
1990                )
1991                .expect("provider source");
1992            }
1993            let source = |producer: &str| {
1994                format!(
1995                    "import {{ C, Other }} from './classes';new C().control();\n{producer}\n{access}\n"
1996                )
1997            };
1998            let original = source(present);
1999            let path = root.join("src/index.ts");
2000            std::fs::write(&path, &original).expect("initial source");
2001            let mut retained = class_projection_session(root);
2002            for (phase, content, expected) in [
2003                ("initial", original.as_str(), vec!["dead"]),
2004                ("removed", source(absent).as_str(), vec!["dead", "used"]),
2005                ("restored", original.as_str(), vec!["dead"]),
2006            ] {
2007                std::fs::write(&path, content).expect("update proof source");
2008                retained.refresh_discovery();
2009                let actual = session_class_projection(&retained);
2010                assert_eq!(actual, expected, "{label}: {phase}");
2011                assert_eq!(
2012                    actual,
2013                    session_class_projection(&class_projection_session(root)),
2014                    "{label}: retained and fresh differ after {phase}"
2015                );
2016            }
2017        }
2018    }
2019
2020    /// A cancelled session reports the failure and names the boundary it
2021    /// stopped at, so a caller can tell a stopped run from a broken one.
2022    #[test]
2023    fn a_cancelled_session_returns_a_cancellation_error_not_an_empty_result() {
2024        let project = tempfile::tempdir().expect("project");
2025        let root = project.path();
2026        std::fs::create_dir(root.join("src")).expect("create source directory");
2027        std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2028        std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n").expect("orphan");
2029
2030        let baseline = AnalysisSession::load_default(root)
2031            .analyze_dead_code()
2032            .expect("an uncancelled session analyzes");
2033        assert!(
2034            !baseline.results.unused_files.is_empty(),
2035            "the fixture must have findings, so an empty result would be a plausible wrong answer"
2036        );
2037
2038        let error = AnalysisSession::load_default(root)
2039            .with_cancellation(Arc::new(AtomicBool::new(true)))
2040            .analyze_dead_code()
2041            .expect_err("a cancelled session must not return results");
2042        assert!(error.is_cancelled(), "unexpected error: {error}");
2043        assert!(error.message().contains("cancelled"));
2044    }
2045
2046    /// The engine pipeline is what every CLI command runs, and it reported no
2047    /// graph-cache reason at all: the loader produced one, the boundary threw
2048    /// it away, and the profile hardcoded `None`. A warm run that decoded a
2049    /// multi-megabyte graph and then rebuilt from scratch looked exactly like a
2050    /// first run, so the row that explains it could never print.
2051    #[test]
2052    fn a_refused_graph_cache_names_its_reason_in_the_engine_timings() {
2053        let project = tempfile::tempdir().expect("project");
2054        let root = project.path();
2055        std::fs::create_dir(root.join("src")).expect("create source directory");
2056        std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2057
2058        let cold = AnalysisSession::load_default(root)
2059            .analyze_dead_code_with_artifacts(false, true)
2060            .expect("cold run analyzes");
2061        assert_eq!(
2062            cold.timings
2063                .expect("cold timings retained")
2064                .graph_cache_rejection,
2065            Some(CacheRejection::Absent),
2066            "a first run has no persisted graph to refuse"
2067        );
2068
2069        std::fs::write(
2070            root.join("src/index.ts"),
2071            "export const entry = 1;\nexport const added = 2;\n",
2072        )
2073        .expect("edit the entry");
2074
2075        let warm = AnalysisSession::load_default(root)
2076            .analyze_dead_code_with_artifacts(false, true)
2077            .expect("warm run analyzes");
2078        assert_eq!(
2079            warm.timings
2080                .expect("warm timings retained")
2081                .graph_cache_rejection,
2082            Some(CacheRejection::FingerprintChanged),
2083            "the decoded graph was refused because a file changed, and the run must say so"
2084        );
2085    }
2086
2087    /// A session that is not given a token can never be cancelled, so every
2088    /// existing caller keeps its current behavior.
2089    #[test]
2090    fn a_session_without_a_token_is_never_cancelled() {
2091        let (_project, session) = session_with_source("export const unused = 1;\n");
2092        assert!(!session.is_cancelled());
2093        session
2094            .analyze_dead_code()
2095            .expect("a session without a token analyzes");
2096    }
2097
2098    /// Files per fixture for the parse-loop tests. Large enough that a parse
2099    /// takes long enough to be cancelled part way through, small enough to
2100    /// stay a unit test.
2101    const PARSE_LOOP_FILES: usize = 400;
2102
2103    fn parse_loop_project() -> tempfile::TempDir {
2104        let project = tempfile::tempdir().expect("project");
2105        let src = project.path().join("src");
2106        std::fs::create_dir_all(&src).expect("src dir");
2107        for module in 0..PARSE_LOOP_FILES {
2108            let mut source = String::new();
2109            for symbol in 0..20 {
2110                let _ = writeln!(
2111                    source,
2112                    "export const helper{symbol} = (input: number): number => {{\n  \
2113                     if (input > {symbol}) {{\n    return input * {symbol};\n  }}\n  \
2114                     return input - {symbol};\n}};"
2115                );
2116            }
2117            std::fs::write(src.join(format!("mod{module}.ts")), source).expect("module");
2118        }
2119        project
2120    }
2121
2122    /// A platform without ctime, such as Windows, gives fingerprints that
2123    /// can stand in for the content only after a content check. A kept
2124    /// session must not serve its modules to the next run on such a
2125    /// fingerprint, because a same-size edit with a restored mtime keeps it.
2126    // Unix only: other platforms expose no inode change time, so no
2127    // fingerprint is trustworthy and a refresh always drops the modules.
2128    #[cfg(unix)]
2129    #[test]
2130    fn a_refresh_drops_modules_whose_fingerprints_need_a_content_check() {
2131        let (_project, mut session) = session_with_source("export const kept = 1;\n");
2132        drop(session.parse_modules(false, None));
2133
2134        assert!(!session.refresh_discovery(), "the file set is the same");
2135        assert!(
2136            session.parsed_cache.lock().expect("parse cache").is_some(),
2137            "fingerprints with a known ctime keep the modules for the next run"
2138        );
2139
2140        if let Some(cache) = session
2141            .parsed_cache
2142            .get_mut()
2143            .expect("parse cache")
2144            .as_mut()
2145        {
2146            for fingerprint in &mut cache.fingerprints {
2147                fingerprint.ctime_ns = 0;
2148            }
2149        }
2150        assert!(!session.refresh_discovery(), "the file set is the same");
2151        assert!(
2152            session.parsed_cache.lock().expect("parse cache").is_none(),
2153            "the next run parses through the persisted cache, which checks the content"
2154        );
2155    }
2156
2157    /// A flush writes the kept modules to the persisted cache. A fingerprint
2158    /// that did not settle before the kept parse read the file must lose its
2159    /// ctime there, or the next run takes the metadata fast path on it.
2160    #[cfg(unix)]
2161    #[test]
2162    fn a_flush_stores_an_unsettled_fingerprint_without_its_ctime() {
2163        let (project, mut session) = session_with_source("export const kept = 1;\n");
2164        drop(session.parse_modules(false, None));
2165        let path = project.path().join("src/index.ts");
2166        let stored_ctime = |session: &AnalysisSession| {
2167            let _ = std::fs::remove_dir_all(&session.config.cache_dir);
2168            session.disk_cache_stale.store(true, Ordering::SeqCst);
2169            session.flush_parse_cache();
2170            let store = fallow_extract::cache::CacheStore::load(
2171                &session.config.cache_dir,
2172                &session.config.root,
2173                session.config.cache_config_hash,
2174                crate::project_config::resolve_cache_max_size_bytes(&session.config),
2175            )
2176            .expect("flush writes the parse cache");
2177            store
2178                .get_by_path_only(&path)
2179                .expect("flushed module")
2180                .ctime_ns
2181        };
2182
2183        // Set the read start relative to the file's own ctime, so the test
2184        // does not depend on how much time passed since the write.
2185        let set_read_started = |session: &mut AnalysisSession, offset: u64| {
2186            if let Some(cache) = session
2187                .parsed_cache
2188                .get_mut()
2189                .expect("parse cache")
2190                .as_mut()
2191            {
2192                let ctime = cache.fingerprints[0].ctime_ns;
2193                assert!(ctime > 0, "the platform reports a ctime");
2194                cache.read_started_ns = ctime.saturating_add(offset);
2195            }
2196        };
2197
2198        set_read_started(&mut session, 0);
2199        assert_eq!(
2200            stored_ctime(&session),
2201            0,
2202            "a fingerprint inside the window is not settled"
2203        );
2204
2205        set_read_started(
2206            &mut session,
2207            fallow_types::source_fingerprint::TIMESTAMP_SETTLE_WINDOW_NS,
2208        );
2209        assert!(
2210            stored_ctime(&session) > 0,
2211            "a settled fingerprint keeps its ctime"
2212        );
2213    }
2214
2215    /// A same-length write in the same timestamp tick as the previous write
2216    /// keeps mtime, ctime and size. The kept modules must not stand in for
2217    /// the new content when the fingerprint is that recent.
2218    #[cfg(unix)]
2219    #[test]
2220    fn a_same_tick_rewrite_does_not_reuse_the_kept_module() {
2221        let (project, mut session) = session_with_source("export const alpha = 1;\n");
2222        drop(session.parse_modules(false, None));
2223
2224        let path = project.path().join("src/index.ts");
2225        std::fs::write(&path, "export const bravo = 1;\n").expect("rewrite source");
2226        let same_tick = SourceFingerprint::from_metadata(
2227            &std::fs::metadata(&path).expect("metadata after rewrite"),
2228        );
2229        if let Some(cache) = session
2230            .parsed_cache
2231            .get_mut()
2232            .expect("parse cache")
2233            .as_mut()
2234        {
2235            cache.fingerprints = vec![same_tick];
2236        }
2237
2238        let modules = session.parse_modules(false, None).modules;
2239        let exports: Vec<String> = modules
2240            .iter()
2241            .flat_map(|module| module.exports.iter())
2242            .map(|export| export.name.to_string())
2243            .collect();
2244        assert_eq!(exports, vec!["bravo".to_string()]);
2245    }
2246
2247    /// A session over `root` that never reads or writes the on-disk parse
2248    /// cache, so repeated parses in one test all do the same work.
2249    fn uncached_session(root: &Path) -> AnalysisSession {
2250        let mut project_config = crate::project_config::default_project_config(root);
2251        project_config.config.no_cache = true;
2252        AnalysisSession::from_config(project_config)
2253    }
2254
2255    /// The parse loop is the one place cancellation stops work per item rather
2256    /// than at a stage boundary, so it is the one place the stop can be
2257    /// asserted from what was parsed instead of from how long the call took.
2258    ///
2259    /// A truncated parse is also the most dangerous thing cancellation
2260    /// produces: served from a cache it would read as a project with fewer
2261    /// modules long after the cancellation was forgotten. The second half
2262    /// asserts it is not retained.
2263    #[test]
2264    fn a_cancelled_parse_stops_partway_and_leaves_no_truncated_cache_behind() {
2265        let project = parse_loop_project();
2266        let root = project.path();
2267
2268        // Warm the page cache, so the timed run measures parsing.
2269        drop(uncached_session(root).parse_modules(false, None));
2270
2271        let started = Instant::now();
2272        let full = uncached_session(root).parse_modules(false, None);
2273        let full_parse = started.elapsed();
2274        let full_count = full.modules.len();
2275        assert_eq!(
2276            full_count, PARSE_LOOP_FILES,
2277            "the fixture must parse every generated module"
2278        );
2279        assert!(
2280            full_parse >= Duration::from_millis(20),
2281            "the fixture is too small to cancel part way through: {full_parse:?}"
2282        );
2283
2284        // Where the flip lands is a scheduling outcome, so this retries until
2285        // one attempt lands strictly inside the loop. Every attempt asserts the
2286        // property; the retry only chooses an attempt that measures the stop
2287        // part way through rather than at the entry guard.
2288        let mut partial = None;
2289        let mut cancelled_session = None;
2290        for attempt in 1..=6_u32 {
2291            let token = Arc::new(AtomicBool::new(false));
2292            // Build the session before arming the watchdog. Discovery runs in
2293            // the constructor, and a delay spent there would leave the token
2294            // already set when the loop starts.
2295            let session = uncached_session(root).with_cancellation(Arc::clone(&token));
2296            let watchdog = {
2297                let token = Arc::clone(&token);
2298                let delay = full_parse * attempt / 6;
2299                std::thread::spawn(move || {
2300                    std::thread::sleep(delay);
2301                    token.store(true, Ordering::SeqCst);
2302                })
2303            };
2304            let cancelled = session.parse_modules(false, Some(&token));
2305            watchdog.join().expect("watchdog thread");
2306
2307            let parsed = cancelled.modules.len();
2308            assert!(
2309                parsed < full_count,
2310                "the parse returned all {full_count} modules, so the loop never read the token"
2311            );
2312            cancelled_session = Some(session);
2313            if parsed > 0 {
2314                partial = Some(parsed);
2315                break;
2316            }
2317        }
2318        let parsed = partial.expect(
2319            "no attempt flipped the token while the loop was running, so this never measured a \
2320             stop part way through",
2321        );
2322        assert!(parsed < full_count);
2323
2324        assert!(
2325            cancelled_session
2326                .expect("a cancelled session")
2327                .parsed_cache
2328                .lock()
2329                .expect("parse cache")
2330                .is_none(),
2331            "a truncated parse must not be retained as this session's warm cache"
2332        );
2333        let recovered = uncached_session(root).parse_modules(false, None);
2334        assert_eq!(
2335            recovered.modules.len(),
2336            full_count,
2337            "a later uncancelled parse must still see the whole project"
2338        );
2339    }
2340
2341    #[test]
2342    fn session_retains_workspace_metadata_from_config_load() {
2343        let project = tempfile::tempdir().expect("project");
2344        let root = project.path();
2345        std::fs::write(
2346            root.join("package.json"),
2347            r#"{"name":"root","workspaces":["packages/*"]}"#,
2348        )
2349        .expect("write root package");
2350        std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
2351        std::fs::write(
2352            root.join("packages/a/package.json"),
2353            r#"{"name":"pkg-a","type":"module"}"#,
2354        )
2355        .expect("write workspace package");
2356
2357        let session = AnalysisSession::load(root, None).expect("session loads");
2358
2359        assert!(
2360            session
2361                .workspaces()
2362                .iter()
2363                .any(|workspace| workspace.name == "pkg-a"),
2364            "session must retain workspace metadata discovered during config load"
2365        );
2366    }
2367
2368    #[test]
2369    fn finding_ignore_filters_results_without_removing_graph_inputs() {
2370        let project = tempfile::tempdir().expect("project");
2371        let root = project.path();
2372        std::fs::create_dir(root.join("src")).expect("create source directory");
2373        std::fs::write(
2374            root.join("package.json"),
2375            r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
2376        )
2377        .expect("write package manifest");
2378        std::fs::write(
2379            root.join("vitest.config.ts"),
2380            "import './src/feature';\nexport default {};\n",
2381        )
2382        .expect("write vitest config");
2383        std::fs::write(
2384            root.join("src/feature.ts"),
2385            "export const feature = true;\n",
2386        )
2387        .expect("write reachable source");
2388        std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
2389            .expect("write hidden source");
2390
2391        let unfiltered = AnalysisSession::load(root, None)
2392            .expect("unfiltered session loads")
2393            .analyze_dead_code()
2394            .expect("unfiltered analysis succeeds");
2395        assert!(
2396            unfiltered
2397                .results
2398                .unused_files
2399                .iter()
2400                .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
2401        );
2402
2403        std::fs::write(
2404            root.join(".fallowrc.json"),
2405            r#"{"ignoreFindings":["src/hidden.ts"]}"#,
2406        )
2407        .expect("write fallow config");
2408        let session = AnalysisSession::load(root, None).expect("filtered session loads");
2409        let hidden_path = root.join("src/hidden.ts");
2410        assert!(session.files().iter().any(|file| file.path == hidden_path));
2411
2412        let filtered = session
2413            .analyze_dead_code_with_artifacts(false, true)
2414            .expect("filtered analysis succeeds");
2415        assert!(
2416            filtered
2417                .results
2418                .unused_files
2419                .iter()
2420                .all(|finding| finding.file.path != hidden_path)
2421        );
2422        assert!(
2423            filtered
2424                .graph
2425                .as_ref()
2426                .is_some_and(|graph| graph.module_count() == session.files().len())
2427        );
2428    }
2429
2430    #[test]
2431    fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
2432        use fallow_types::output_dead_code::UnusedFileFinding;
2433        use fallow_types::results::UnusedFile;
2434
2435        let project = tempfile::tempdir().expect("project");
2436        let config = serde_json::from_str::<fallow_config::FallowConfig>(
2437            r#"{"ignoreFindings":["**/*.ts"]}"#,
2438        )
2439        .expect("config parses")
2440        .resolve(
2441            project.path().to_path_buf(),
2442            fallow_config::OutputFormat::Human,
2443            1,
2444            true,
2445            true,
2446            None,
2447        );
2448        let outside = project
2449            .path()
2450            .parent()
2451            .expect("project has parent")
2452            .join("outside.ts");
2453        let mut results = AnalysisResults {
2454            unused_files: vec![
2455                UnusedFileFinding::with_actions(UnusedFile {
2456                    path: PathBuf::from(r"src\hidden.ts"),
2457                }),
2458                UnusedFileFinding::with_actions(UnusedFile {
2459                    path: outside.clone(),
2460                }),
2461            ],
2462            ..AnalysisResults::default()
2463        };
2464
2465        crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
2466
2467        assert_eq!(results.unused_files.len(), 1);
2468        assert_eq!(results.unused_files[0].file.path, outside);
2469    }
2470
2471    /// Two files under `src/`, with the default config, so the persisted
2472    /// parse cache is on.
2473    #[cfg(unix)]
2474    fn warm_store_project() -> tempfile::TempDir {
2475        let project = tempfile::tempdir().expect("project");
2476        let root = project.path();
2477        std::fs::create_dir(root.join("src")).expect("create source directory");
2478        std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("entry");
2479        std::fs::write(root.join("src/other.ts"), "export const other = 2;\n").expect("other");
2480        project
2481    }
2482
2483    #[cfg(unix)]
2484    fn warm_session(root: &Path, store: &Arc<WarmParseStore>) -> AnalysisSession {
2485        AnalysisSession::load_default(root).with_warm_parse(Some(Arc::clone(store)))
2486    }
2487
2488    #[cfg(unix)]
2489    fn exported_names(modules: &[ModuleInfo]) -> Vec<String> {
2490        let mut names: Vec<String> = modules
2491            .iter()
2492            .flat_map(|module| module.exports.iter().map(|export| export.name.to_string()))
2493            .collect();
2494        names.sort();
2495        names
2496    }
2497
2498    #[cfg(unix)]
2499    #[test]
2500    fn a_warm_store_serves_a_new_session_without_parse_work() {
2501        let project = warm_store_project();
2502        let store = Arc::new(WarmParseStore::new(
2503            crate::warm_parse::WarmParseLimits::default(),
2504        ));
2505
2506        let first = warm_session(project.path(), &store).parse_modules(false, None);
2507        let second = warm_session(project.path(), &store).parse_modules(false, None);
2508
2509        assert!(Arc::ptr_eq(&first.modules, &second.modules));
2510        assert_eq!(second.metrics.cache_hits + second.metrics.cache_misses, 0);
2511        let counts = store.counts();
2512        assert_eq!(counts.parse_runs, 1);
2513        assert_eq!(counts.modules_parsed, 2);
2514        assert_eq!(counts.modules_reused, 2);
2515    }
2516
2517    #[cfg(unix)]
2518    #[test]
2519    fn a_warm_store_serves_the_health_parse_of_a_new_session() {
2520        let project = warm_store_project();
2521        let store = Arc::new(WarmParseStore::new(
2522            crate::warm_parse::WarmParseLimits::default(),
2523        ));
2524
2525        drop(warm_session(project.path(), &store).parse_modules(false, None));
2526        let health = warm_session(project.path(), &store).parsed_parts_uncached(true);
2527
2528        assert_eq!(health.modules.len(), 2);
2529        assert_eq!(health.cache_hits + health.cache_misses, 0);
2530        assert_eq!(
2531            store.counts().parse_runs,
2532            1,
2533            "the first parse computed complexity"
2534        );
2535    }
2536
2537    #[cfg(unix)]
2538    #[test]
2539    fn an_edit_or_a_new_file_makes_the_next_session_parse_again() {
2540        let project = warm_store_project();
2541        let root = project.path();
2542        let store = Arc::new(WarmParseStore::new(
2543            crate::warm_parse::WarmParseLimits::default(),
2544        ));
2545        drop(warm_session(root, &store).parse_modules(false, None));
2546
2547        std::fs::write(root.join("src/other.ts"), "export const renamed = 22;\n").expect("edit");
2548        let edited = warm_session(root, &store).parse_modules(false, None);
2549        assert_eq!(exported_names(&edited.modules), ["entry", "renamed"]);
2550        assert_eq!(
2551            edited.metrics.cache_misses, 1,
2552            "only the edited file is parsed"
2553        );
2554
2555        std::fs::write(root.join("src/added.ts"), "export const added = 3;\n").expect("add");
2556        let added = warm_session(root, &store).parse_modules(false, None);
2557        assert_eq!(
2558            exported_names(&added.modules),
2559            ["added", "entry", "renamed"]
2560        );
2561
2562        let counts = store.counts();
2563        assert_eq!(counts.parse_runs, 3);
2564        assert_eq!(counts.modules_reused, 0);
2565        assert_eq!(store.len(), 2, "each file list keeps its latest parse only");
2566    }
2567
2568    #[cfg(unix)]
2569    #[test]
2570    fn a_session_without_the_persisted_cache_does_not_use_the_store() {
2571        let project = warm_store_project();
2572        let store = Arc::new(WarmParseStore::new(
2573            crate::warm_parse::WarmParseLimits::default(),
2574        ));
2575
2576        drop(
2577            uncached_session(project.path())
2578                .with_warm_parse(Some(Arc::clone(&store)))
2579                .parse_modules(false, None),
2580        );
2581
2582        assert_eq!(
2583            store.counts(),
2584            crate::warm_parse::WarmParseCounts::default()
2585        );
2586        assert!(store.is_empty());
2587    }
2588
2589    /// A parse records its read failures for the project. A session that
2590    /// takes kept modules must record them again, or a run between the two
2591    /// that cleared them would hide the failure.
2592    #[cfg(unix)]
2593    #[test]
2594    fn a_kept_parse_records_its_read_failures_again() {
2595        let project = warm_store_project();
2596        let root = project.path();
2597        std::fs::write(root.join("src/broken.ts"), [0xff, 0xfe, 0x00]).expect("invalid UTF-8");
2598        let store = Arc::new(WarmParseStore::new(
2599            crate::warm_parse::WarmParseLimits::default(),
2600        ));
2601        let read_failures = |session: &AnalysisSession| {
2602            session
2603                .current_workspace_diagnostics()
2604                .into_iter()
2605                .filter(|diagnostic| {
2606                    matches!(
2607                        diagnostic.kind,
2608                        fallow_types::workspace::WorkspaceDiagnosticKind::SourceReadFailure { .. }
2609                    )
2610                })
2611                .count()
2612        };
2613
2614        let first = warm_session(root, &store);
2615        drop(first.parse_modules(false, None));
2616        assert_eq!(read_failures(&first), 1);
2617
2618        drop(fallow_config::record_source_read_failures(root, &[]));
2619        let second = warm_session(root, &store);
2620        drop(second.parse_modules(false, None));
2621        assert_eq!(store.counts().modules_reused, 2);
2622        assert_eq!(read_failures(&second), 1);
2623    }
2624
2625    #[test]
2626    fn warm_parse_cache_reuses_module_storage() {
2627        let (_project, session) = session_with_source("export function value() { return 1; }\n");
2628        let first = session.parse_modules(true, None);
2629        let second = session.parse_modules(false, None);
2630
2631        assert!(
2632            Arc::ptr_eq(&first.modules, &second.modules),
2633            "warm session queries must share parsed module storage"
2634        );
2635    }
2636
2637    #[test]
2638    fn warm_styling_cache_reuses_artifact_allocation() {
2639        let project = tempfile::tempdir().expect("project");
2640        let root = project.path();
2641        std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
2642            .expect("write stylesheet");
2643        let session = AnalysisSession::load_default(root);
2644
2645        let first = session.styling_analysis_artifacts();
2646        let second = session.styling_analysis_artifacts();
2647
2648        assert!(
2649            Arc::ptr_eq(&first, &second),
2650            "warm styling queries must share the cached artifact allocation"
2651        );
2652    }
2653
2654    #[test]
2655    fn shared_parsed_modules_reuse_public_session_storage() {
2656        let (_project, session) = session_with_source("export const value = 1;\n");
2657        let first = session.shared_parsed_modules(true);
2658        let second = session.shared_parsed_modules(false);
2659
2660        assert!(Arc::ptr_eq(&first, &second));
2661    }
2662
2663    #[test]
2664    fn shared_parsed_parts_reuse_public_session_storage() {
2665        let (_project, session) = session_with_source("export const value = 1;\n");
2666        let cached = session.shared_parsed_modules(true);
2667        let parts = session.shared_parsed_parts(false);
2668
2669        assert!(Arc::ptr_eq(&cached, &parts.modules));
2670    }
2671
2672    #[test]
2673    fn warm_complexity_artifacts_reuse_cached_module_storage() {
2674        let (_project, session) = session_with_source("export function value() { return 1; }\n");
2675        let cached = session.parse_modules(true, None);
2676        let artifacts = session
2677            .analyze_dead_code_with_reuse_artifacts(true, true, false)
2678            .expect("analysis succeeds");
2679        let retained = artifacts.modules.expect("complexity modules retained");
2680
2681        assert!(
2682            Arc::ptr_eq(&cached.modules, &retained),
2683            "warm complexity artifacts must share parsed module storage"
2684        );
2685    }
2686
2687    #[test]
2688    fn shared_and_owned_artifacts_preserve_output_bytes() {
2689        let (_project, session) = session_with_source(
2690            "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
2691        );
2692        let owned = session
2693            .analyze_dead_code_with_artifacts(true, true)
2694            .expect("owned analysis succeeds");
2695        let shared = session
2696            .analyze_dead_code_with_shared_artifacts(true, true)
2697            .expect("shared analysis succeeds");
2698
2699        assert_eq!(
2700            serde_json::to_vec(&owned.results).expect("serialize owned results"),
2701            serde_json::to_vec(&shared.results).expect("serialize shared results")
2702        );
2703        assert_eq!(owned.file_hashes, shared.file_hashes);
2704        assert_eq!(
2705            owned
2706                .modules
2707                .as_deref()
2708                .unwrap_or_default()
2709                .iter()
2710                .map(|module| module.content_hash)
2711                .collect::<Vec<_>>(),
2712            shared
2713                .modules
2714                .as_deref()
2715                .unwrap_or_default()
2716                .iter()
2717                .map(|module| module.content_hash)
2718                .collect::<Vec<_>>()
2719        );
2720    }
2721
2722    #[test]
2723    fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
2724        let project = tempfile::tempdir().expect("project");
2725        let root = project.path();
2726        std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
2727        std::fs::write(
2728            root.join("package.json"),
2729            r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
2730        )
2731        .expect("write package manifest");
2732        std::fs::write(
2733            root.join("app/routes/home.tsx"),
2734            r#"
2735import { useLoaderData } from "react-router";
2736export function loader() { return { opaque: "value" }; }
2737export default function Home() {
2738  const data = useLoaderData<typeof loader>();
2739  const copy = { ...data };
2740  return JSON.stringify(copy);
2741}
2742"#,
2743        )
2744        .expect("write route module");
2745
2746        let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2747        let cold_parse = cold_session.parsed_parts(false);
2748        assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
2749        let cold = cold_session
2750            .analyze_dead_code()
2751            .expect("cold analysis succeeds");
2752
2753        let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2754        let warm_parse = warm_session.parsed_parts(false);
2755        assert!(
2756            warm_parse.cache_hits > 0,
2757            "second session must use disk cache"
2758        );
2759        let warm = warm_session
2760            .analyze_dead_code()
2761            .expect("warm analysis succeeds");
2762
2763        assert!(
2764            cold.results.unused_load_data_keys.is_empty(),
2765            "cold analysis must abstain for an opaque route-loader use"
2766        );
2767        assert_eq!(
2768            serde_json::to_vec(&cold.results).expect("serialize cold results"),
2769            serde_json::to_vec(&warm.results).expect("serialize warm results"),
2770            "warm route-loader analysis must match cold analysis"
2771        );
2772    }
2773
2774    #[test]
2775    fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
2776        let project = tempfile::tempdir().expect("project");
2777        let root = project.path();
2778        std::fs::create_dir(root.join("src")).expect("create source directory");
2779        std::fs::write(
2780            root.join("package.json"),
2781            r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
2782        )
2783        .expect("write package manifest");
2784        std::fs::write(
2785            root.join("src/dependency.ts"),
2786            "export function dependency() { return 'real'; }\n",
2787        )
2788        .expect("write dependency");
2789        std::fs::write(
2790            root.join("src/wrapper.ts"),
2791            "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
2792        )
2793        .expect("write wrapper");
2794        std::fs::write(
2795            root.join("src/index.ts"),
2796            "export { wrapper } from './wrapper';\n",
2797        )
2798        .expect("write entry point");
2799        std::fs::write(
2800            root.join("src/wrapper.test.ts"),
2801            r#"
2802import { vi } from "vitest";
2803vi.mock("./dependency", () => ({ dependency: () => "mock" }));
2804import { wrapper } from "./wrapper";
2805wrapper();
2806"#,
2807        )
2808        .expect("write test");
2809
2810        let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
2811        let dependency_id = cold_session
2812            .files()
2813            .iter()
2814            .find(|file| file.path == root.join("src/dependency.ts"))
2815            .expect("dependency discovered")
2816            .id;
2817        let cold = cold_session
2818            .analyze_dead_code_with_artifacts(false, true)
2819            .expect("cold analysis succeeds");
2820        let cold_exports = crate::module_graph::module_value_exports(
2821            cold.graph.as_ref().expect("cold graph retained"),
2822        );
2823        assert!(
2824            fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_ok(),
2825            "cold analysis must persist the graph cache"
2826        );
2827
2828        let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
2829        let warm = warm_session
2830            .analyze_dead_code_with_artifacts(false, true)
2831            .expect("warm analysis succeeds");
2832        let warm_exports = crate::module_graph::module_value_exports(
2833            warm.graph.as_ref().expect("warm graph retained"),
2834        );
2835
2836        let dependency = cold_exports
2837            .iter()
2838            .find(|export| export.file_id == dependency_id && export.name == "dependency")
2839            .expect("dependency export retained");
2840        assert!(!dependency.test_referenced);
2841        assert_eq!(warm_exports, cold_exports);
2842    }
2843
2844    #[test]
2845    fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
2846        let project = tempfile::tempdir().expect("project");
2847        let root = project.path();
2848        std::fs::create_dir(root.join("src")).expect("create source directory");
2849        std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
2850            .expect("write package manifest");
2851        for name in ["a.ts", "b.ts", "c.ts"] {
2852            std::fs::write(
2853                root.join("src").join(name),
2854                format!("export const {} = 1;\n", name.replace('.', "_")),
2855            )
2856            .expect("write source");
2857        }
2858        let session = AnalysisSession::load(root, None).expect("session loads");
2859        let removed_path = root.join("src/b.ts");
2860        let removed_id = session
2861            .files()
2862            .iter()
2863            .find(|file| file.path == removed_path)
2864            .expect("removed source discovered")
2865            .id;
2866        std::fs::remove_file(&removed_path).expect("remove source after discovery");
2867
2868        let parts = session.parsed_parts(false);
2869
2870        assert!(
2871            parts
2872                .modules
2873                .iter()
2874                .all(|module| module.file_id != removed_id),
2875            "unreadable file must not receive a placeholder module"
2876        );
2877        let diagnostic = parts
2878            .workspace_diagnostics
2879            .iter()
2880            .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2881            .expect("parsed session parts carry source read failure");
2882        assert_eq!(diagnostic.path, removed_path);
2883        assert!(
2884            session
2885                .current_workspace_diagnostics()
2886                .iter()
2887                .any(|diagnostic| {
2888                    diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
2889                }),
2890            "session output carries parse-time source diagnostics"
2891        );
2892    }
2893
2894    const MALFORMED_PNPM_WORKSPACE_YAML: &str =
2895        "catalog:\n  react: ^18.2.0\n{this is\nnot: valid: yaml: at: all\n";
2896    const VALID_PNPM_WORKSPACE_YAML: &str = "catalog:\n  react: ^18.2.0\n";
2897
2898    fn has_diagnostic_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> bool {
2899        diagnostics
2900            .iter()
2901            .any(|diagnostic| diagnostic.kind.id() == id)
2902    }
2903
2904    fn write_single_source_project(root: &Path, manifest: &str) {
2905        std::fs::create_dir(root.join("src")).expect("create source directory");
2906        std::fs::write(root.join("package.json"), manifest).expect("write package manifest");
2907        std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
2908            .expect("write source");
2909    }
2910
2911    /// Issue #2366: engine sessions (the MCP and LSP path) never re-stash the
2912    /// registry, so a session created after an earlier analysis in the same
2913    /// process must not keep that analysis's analysis-stage diagnostic once
2914    /// the cause is fixed: the analyze pass refreshes the entry and the
2915    /// session snapshot must not pin it.
2916    #[test]
2917    fn later_session_drops_stale_analysis_stage_diagnostic_after_cause_is_fixed() {
2918        let project = tempfile::tempdir().expect("project");
2919        let root = project.path();
2920        write_single_source_project(
2921            root,
2922            r#"{"name":"issue-2366-engine-session","private":true}"#,
2923        );
2924        std::fs::write(
2925            root.join("pnpm-workspace.yaml"),
2926            MALFORMED_PNPM_WORKSPACE_YAML,
2927        )
2928        .expect("write malformed workspace yaml");
2929
2930        let broken = AnalysisSession::load(root, None).expect("session loads");
2931        broken
2932            .analyze_dead_code()
2933            .expect("analysis on the malformed yaml succeeds");
2934        assert!(
2935            has_diagnostic_kind(
2936                &broken.current_workspace_diagnostics(),
2937                "malformed-pnpm-workspace-yaml"
2938            ),
2939            "the first session surfaces the malformed yaml: {:?}",
2940            broken.current_workspace_diagnostics()
2941        );
2942
2943        std::fs::write(root.join("pnpm-workspace.yaml"), VALID_PNPM_WORKSPACE_YAML)
2944            .expect("fix workspace yaml");
2945
2946        let fixed = AnalysisSession::load(root, None).expect("session loads");
2947        fixed
2948            .analyze_dead_code()
2949            .expect("analysis on the fixed yaml succeeds");
2950        let current = fixed.current_workspace_diagnostics();
2951        assert!(
2952            !has_diagnostic_kind(&current, "malformed-pnpm-workspace-yaml"),
2953            "a later session must not keep the stale analysis-stage entry (#2366): {current:?}"
2954        );
2955    }
2956
2957    /// Watch-mode rerun shape (issue #2366): the CLI reloads config, which
2958    /// re-stashes the workspace-discovery set, and builds a fresh session from
2959    /// the resolved config before re-analyzing. Once a text `bun.lock` exists
2960    /// the rerun must drop the bun.lockb skip diagnostic. Regression pin: the
2961    /// old stash wiped analysis-stage entries instead of preserving them, so
2962    /// this passes before and after the fix.
2963    #[test]
2964    fn watch_style_rerun_drops_bun_lockb_skip_once_text_lockfile_exists() {
2965        let project = tempfile::tempdir().expect("project");
2966        let root = project.path();
2967        write_single_source_project(
2968            root,
2969            r#"{"name":"issue-2366-watch-rerun","private":true,"overrides":{"ws":"^8.21.0"}}"#,
2970        );
2971        std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
2972            .expect("write bun.lockb placeholder");
2973        let config = fallow_config::FallowConfig::default().resolve(
2974            root.to_path_buf(),
2975            fallow_config::OutputFormat::Json,
2976            1,
2977            true,
2978            true,
2979            None,
2980        );
2981        let reload_config = || {
2982            let (_, diagnostics) =
2983                fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
2984                    .expect("workspace discovery succeeds");
2985            fallow_config::stash_workspace_diagnostics(root, diagnostics);
2986        };
2987
2988        reload_config();
2989        let first =
2990            AnalysisSession::from_resolved_config(config.clone()).expect("first session loads");
2991        first
2992            .analyze_dead_code()
2993            .expect("analysis with bun.lockb only succeeds");
2994        assert!(
2995            has_diagnostic_kind(
2996                &first.current_workspace_diagnostics(),
2997                "bun-lockb-override-resolution-skipped"
2998            ),
2999            "the first run surfaces the bun.lockb skip: {:?}",
3000            first.current_workspace_diagnostics()
3001        );
3002
3003        std::fs::write(
3004            root.join("bun.lock"),
3005            r#"{"lockfileVersion":1,"workspaces":{"":{"name":"issue-2366-watch-rerun"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}"#,
3006        )
3007        .expect("write text bun.lock");
3008
3009        reload_config();
3010        let rerun =
3011            AnalysisSession::from_resolved_config(config.clone()).expect("rerun session loads");
3012        rerun
3013            .analyze_dead_code()
3014            .expect("analysis with the text bun.lock succeeds");
3015        let current = rerun.current_workspace_diagnostics();
3016        assert!(
3017            !has_diagnostic_kind(&current, "bun-lockb-override-resolution-skipped"),
3018            "the rerun drops the skip once a text bun.lock exists (#2366): {current:?}"
3019        );
3020    }
3021
3022    /// Issue #2366: `current_workspace_diagnostics` reads the registry live so
3023    /// the parse-stage and analyze-stage entries that land after the session
3024    /// was created still reach the envelope, but it must not import another
3025    /// walk's skips along with them.
3026    ///
3027    /// Combined mode runs the dead-code and duplication walks on the same root
3028    /// under `rayon::join` whenever a per-analysis `production` split stops
3029    /// them from sharing a file list, and each walk replaces the registry's
3030    /// source-discovery set. A session that read that set back would answer
3031    /// "whichever walk wrote last", which decides where the other walk's skip
3032    /// lands in the combined root's union and made the array come out in a
3033    /// different ORDER between runs of the same command.
3034    #[test]
3035    fn session_keeps_its_own_walk_skips_and_ignores_another_walks_registry_write() {
3036        let project = tempfile::tempdir().expect("project");
3037        let root = project.path();
3038        write_single_source_project(
3039            root,
3040            r#"{"name":"issue-2366-parallel-walks","private":true}"#,
3041        );
3042        std::fs::write(root.join("src/huge.ts"), "// filler\n".repeat(400))
3043            .expect("write oversized source");
3044        let mut config = fallow_config::FallowConfig::default().resolve(
3045            root.to_path_buf(),
3046            fallow_config::OutputFormat::Json,
3047            1,
3048            true,
3049            true,
3050            None,
3051        );
3052        config.max_file_size_bytes = Some(1024);
3053
3054        let session = AnalysisSession::from_resolved_config(config).expect("session loads");
3055
3056        // The state a concurrent walk leaves behind: its own skip in this
3057        // root's registry entry. It writes that through the registry's
3058        // replace-in-one-operation call, which an architecture guard reserves
3059        // for the walk itself, so the append is the stand-in here.
3060        fallow_config::append_workspace_diagnostics(
3061            root,
3062            vec![WorkspaceDiagnostic::new(
3063                root,
3064                root.join("src/other-walk-only.ts"),
3065                fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
3066                    size_bytes: 4096,
3067                },
3068            )],
3069        );
3070
3071        let current = session.current_workspace_diagnostics();
3072        let skipped: Vec<&Path> = current
3073            .iter()
3074            .filter(|diagnostic| diagnostic.kind.id() == "skipped-large-file")
3075            .map(|diagnostic| diagnostic.path.as_path())
3076            .collect();
3077        assert_eq!(
3078            skipped.len(),
3079            1,
3080            "the session reports its own walk's skips only: {skipped:?}"
3081        );
3082        assert!(
3083            skipped[0].ends_with("src/huge.ts"),
3084            "the surviving skip is this walk's own: {skipped:?}"
3085        );
3086    }
3087
3088    /// Issue #2366: a config reload that happens AFTER the analyze pass, with
3089    /// no further pass to re-record, must not wipe the analysis-stage entry
3090    /// from the process registry. This is the long-lived-server shape: an MCP
3091    /// or LSP process analyzes once, a later request reloads config for a
3092    /// different analysis family, and a session built after that reload still
3093    /// reads the registry live. Pins the analysis-stage preserve in
3094    /// `stash_workspace_diagnostics`; without it this session reports nothing.
3095    #[test]
3096    fn config_reload_after_the_analyze_pass_keeps_the_bun_lockb_skip_readable() {
3097        let project = tempfile::tempdir().expect("project");
3098        let root = project.path();
3099        write_single_source_project(
3100            root,
3101            r#"{"name":"issue-2366-reload-preserve","private":true,"overrides":{"ws":"^8.21.0"}}"#,
3102        );
3103        std::fs::write(root.join("bun.lockb"), b"placeholder binary lockfile")
3104            .expect("write bun.lockb placeholder");
3105        let config = fallow_config::FallowConfig::default().resolve(
3106            root.to_path_buf(),
3107            fallow_config::OutputFormat::Json,
3108            1,
3109            true,
3110            true,
3111            None,
3112        );
3113        let reload_config = || {
3114            let (_, diagnostics) =
3115                fallow_config::discover_workspaces_with_diagnostics(root, &config.ignore_patterns)
3116                    .expect("workspace discovery succeeds");
3117            fallow_config::stash_workspace_diagnostics(root, diagnostics);
3118        };
3119
3120        reload_config();
3121        let analyzing =
3122            AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
3123        analyzing
3124            .analyze_dead_code()
3125            .expect("analysis with bun.lockb only succeeds");
3126
3127        // A later request reloads config for another analysis family and never
3128        // runs a second dead-code pass.
3129        reload_config();
3130
3131        let later = AnalysisSession::from_resolved_config(config.clone()).expect("session loads");
3132        let current = later.current_workspace_diagnostics();
3133        assert!(
3134            has_diagnostic_kind(&current, "bun-lockb-override-resolution-skipped"),
3135            "the reload must preserve the analysis-stage entry the pass recorded (#2366): \
3136             {current:?}"
3137        );
3138    }
3139}