Skip to main content

fallow_engine/
session.rs

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