Skip to main content

fallow_engine/
session.rs

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