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