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