Skip to main content

fallow_engine/
lib.rs

1//! Typed analysis engine boundary for fallow consumers.
2//!
3//! `fallow-core` remains the internal orchestration backend. This crate owns
4//! the typed boundary that editor, API, and embedding surfaces can depend on
5//! without calling deprecated core entry points directly. Public modules should
6//! expose owned engine runners, typed result structs, or narrowly scoped aliases
7//! instead of broad core re-exports.
8
9#![warn(missing_docs)]
10#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
11#![cfg_attr(
12    test,
13    allow(
14        clippy::unwrap_used,
15        clippy::expect_used,
16        reason = "tests use unwrap and expect to keep fixture setup concise"
17    )
18)]
19
20use std::fmt;
21#[cfg(test)]
22use std::path::Path;
23
24/// Finding-count and finding-identity baselines for suppressing known issues
25/// across runs.
26pub mod baseline;
27pub mod changed_files;
28pub mod churn;
29pub mod codeowners;
30mod core_backend;
31pub mod cross_reference;
32mod css;
33pub mod dead_code;
34pub mod discover;
35pub mod duplicates;
36mod feature_flags;
37pub mod flags;
38pub(crate) mod graph {
39    pub use fallow_graph::graph::*;
40}
41#[path = "git_env.rs"]
42mod git_env;
43pub mod guard;
44pub mod health;
45#[cfg(test)]
46pub(crate) mod extract {
47    pub use fallow_types::extract::*;
48}
49#[cfg(test)]
50pub(crate) mod analyze {
51    pub mod test_support {
52        use fallow_types::discover::FileId;
53        use fallow_types::extract::ModuleInfo;
54
55        pub fn empty_module() -> ModuleInfo {
56            ModuleInfo::empty(FileId(1))
57        }
58    }
59}
60pub mod list_inventory;
61pub mod module_graph;
62pub mod plugins;
63pub mod project_analysis;
64pub mod project_config;
65mod public_api;
66pub mod repo_refs;
67#[cfg(test)]
68pub(crate) mod resolve {
69    pub use fallow_graph::resolve::*;
70}
71mod results;
72mod security;
73pub mod session;
74pub mod similar_code;
75pub mod source;
76mod suppress;
77pub mod thread_pool;
78pub mod trace;
79pub mod trace_chain;
80/// Input validation shared by CLI-facing entry points: git refs, root paths,
81/// and control-character rejection.
82pub mod validate;
83pub mod vital_signs;
84pub mod viz;
85pub mod workspace_scope;
86
87/// Result alias for typed engine operations.
88pub type EngineResult<T> = Result<T, EngineError>;
89
90/// Error type exposed by the typed engine boundary.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct EngineError {
93    message: String,
94    cancelled: bool,
95}
96
97impl EngineError {
98    /// Create an engine error from a user-facing message.
99    #[must_use]
100    fn new(message: impl Into<String>) -> Self {
101        Self {
102            message: message.into(),
103            cancelled: false,
104        }
105    }
106
107    /// Create the error an analysis returns when its caller cancelled it.
108    ///
109    /// `stage` names the pipeline boundary the run stopped at, so a caller can
110    /// tell how far the analysis got before it was stopped.
111    #[must_use]
112    pub fn cancelled(stage: &str) -> Self {
113        Self {
114            message: format!("analysis was cancelled before {stage}"),
115            cancelled: true,
116        }
117    }
118
119    /// Whether this error reports a caller-requested cancellation rather than
120    /// a failed analysis.
121    #[must_use]
122    pub const fn is_cancelled(&self) -> bool {
123        self.cancelled
124    }
125
126    /// User-facing error message from the backend.
127    #[must_use]
128    pub fn message(&self) -> &str {
129        &self.message
130    }
131}
132
133impl fmt::Display for EngineError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(&self.message)
136    }
137}
138
139impl std::error::Error for EngineError {}
140
141pub(crate) fn engine_error(err: impl fmt::Display) -> EngineError {
142    EngineError::new(err.to_string())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::{
149        project_analysis::ProjectAnalysisArtifactOptions,
150        project_config::{
151            ProjectConfigOptions, config_for_project, config_for_project_analysis,
152            resolve_cache_max_size_bytes,
153        },
154        session::AnalysisSession,
155    };
156    use fallow_config::ProductionAnalysis;
157    use fallow_types::output_format::OutputFormat;
158    use std::fs;
159    use std::path::PathBuf;
160
161    #[test]
162    fn engine_error_displays_message() {
163        let err = EngineError::new("config failed");
164
165        assert_eq!(err.message(), "config failed");
166        assert_eq!(err.to_string(), "config failed");
167    }
168
169    #[test]
170    fn engine_resolves_parse_cache_size_policy() {
171        let mut config = fallow_config::FallowConfig::default().resolve(
172            PathBuf::from("/repo"),
173            OutputFormat::Json,
174            1,
175            false,
176            true,
177            None,
178        );
179        assert_eq!(
180            resolve_cache_max_size_bytes(&config),
181            fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE
182        );
183
184        config.cache_max_size_mb = Some(3);
185        assert_eq!(resolve_cache_max_size_bytes(&config), 3 * 1024 * 1024);
186
187        config.cache_max_size_mb = Some(u32::MAX);
188        assert_eq!(
189            resolve_cache_max_size_bytes(&config),
190            (u32::MAX as usize).saturating_mul(1024 * 1024)
191        );
192    }
193
194    #[test]
195    fn engine_root_does_not_reexport_broad_surface_modules() {
196        let source = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/lib.rs"))
197            .expect("read engine lib");
198        let public_surface = source
199            .split("#[cfg(test)]")
200            .next()
201            .expect("engine lib has public surface before tests");
202        let forbidden_exports = [
203            "pub use error::",
204            "pub use flags::",
205            "pub use git_env::",
206            "pub use public_api::",
207            "pub use results::",
208            "pub use security::",
209            "pub use suppress::",
210            "health_shared_parse_data_from_artifacts",
211        ];
212
213        for forbidden in forbidden_exports {
214            assert!(
215                !public_surface.contains(forbidden),
216                "engine root must expose typed modules, not `{forbidden}`"
217            );
218        }
219    }
220
221    #[test]
222    fn engine_session_owns_dead_code_pipeline_sequence() {
223        let session_source =
224            fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
225                .expect("read engine session");
226        assert!(
227            !session_source.contains("analyze_with_owned_parse_result_from_discovery"),
228            "engine session must not delegate dead-code orchestration to the old core monolith"
229        );
230        for required_phase in [
231            "prepare_dead_code_backend_prelude",
232            "discover_dead_code_entry_points",
233            "try_load_dead_code_graph_cache",
234            "resolve_dead_code_imports",
235            "build_dead_code_graph",
236            "run_dead_code_detectors",
237        ] {
238            assert!(
239                session_source.contains(required_phase),
240                "engine session must explicitly sequence `{required_phase}`"
241            );
242        }
243    }
244
245    #[test]
246    fn engine_session_owns_analysis_discovery() {
247        let session_source =
248            fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
249                .expect("read engine session");
250        assert!(
251            session_source.contains("crate::discover::prepare_analysis_discovery"),
252            "engine session must build discovery through the engine discovery boundary"
253        );
254        assert!(
255            session_source.contains("prepare_analysis_discovery_with_workspaces"),
256            "engine session must reuse workspace metadata captured during config load"
257        );
258        assert!(
259            session_source.contains("workspace_discovery_ms.is_some()"),
260            "AnalysisSession::from_config must only reuse workspace metadata when ProjectConfig preloaded it"
261        );
262        assert!(
263            !session_source.contains("core_backend::prepare_analysis_discovery"),
264            "engine session must not delegate discovery orchestration to core_backend"
265        );
266    }
267
268    #[test]
269    fn analysis_session_loads_config_and_discovered_files() {
270        let temp = tempfile::tempdir().expect("tempdir");
271        let src = temp.path().join("src");
272        std::fs::create_dir(&src).expect("src dir");
273        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
274
275        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
276
277        assert_eq!(session.root(), temp.path());
278        assert!(session.config_path().is_none());
279        assert!(session.files().iter().any(|file| {
280            file.path
281                .strip_prefix(temp.path())
282                .is_ok_and(|path| path == Path::new("src/index.ts"))
283        }));
284    }
285
286    #[test]
287    fn analysis_session_applies_config_adjustment_before_discovery() {
288        let temp = tempfile::tempdir().expect("tempdir");
289        let src = temp.path().join("src");
290        std::fs::create_dir(&src).expect("src dir");
291        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
292        std::fs::write(src.join("index.test.ts"), "export const testValue = 1;\n")
293            .expect("test source file");
294
295        let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
296            config.production = true;
297        })
298        .expect("session loads");
299
300        let relative_paths: Vec<_> = session
301            .files()
302            .iter()
303            .filter_map(|file| file.path.strip_prefix(temp.path()).ok())
304            .collect();
305        assert!(relative_paths.contains(&Path::new("src/index.ts")));
306        assert!(!relative_paths.contains(&Path::new("src/index.test.ts")));
307    }
308
309    #[test]
310    fn analysis_session_config_adjustment_invalidates_preloaded_workspaces() {
311        let temp = tempfile::tempdir().expect("tempdir");
312        std::fs::write(
313            temp.path().join("package.json"),
314            r#"{"name":"root","workspaces":["packages/*"]}"#,
315        )
316        .expect("root package");
317        std::fs::create_dir_all(temp.path().join("packages/a")).expect("workspace dir");
318        std::fs::create_dir_all(temp.path().join("packages/ignored")).expect("ignored dir");
319        std::fs::write(
320            temp.path().join("packages/a/package.json"),
321            r#"{"name":"a","main":"src/index.ts"}"#,
322        )
323        .expect("workspace package");
324
325        let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
326            config.ignore_patterns = globset::GlobSetBuilder::new()
327                .add(globset::Glob::new("packages/ignored").expect("ignore glob"))
328                .build()
329                .expect("ignore set");
330        })
331        .expect("session loads");
332
333        assert!(
334            session
335                .workspaces()
336                .iter()
337                .all(|workspace| workspace.name != "ignored"),
338            "config mutations that affect workspace discovery must not reuse preloaded workspaces"
339        );
340        assert!(
341            !session
342                .workspace_diagnostics()
343                .iter()
344                .any(|diagnostic| diagnostic.path.ends_with("packages/ignored")),
345            "config mutations that affect workspace diagnostics must not reuse stale diagnostics"
346        );
347    }
348
349    #[test]
350    fn analysis_session_captures_workspace_diagnostics() {
351        let temp = tempfile::tempdir().expect("tempdir");
352        std::fs::write(
353            temp.path().join("package.json"),
354            r#"{"name":"diagnostic-root","workspaces":["packages/*"]}"#,
355        )
356        .expect("package json");
357        std::fs::create_dir_all(temp.path().join("packages/empty")).expect("workspace dir");
358        std::fs::create_dir(temp.path().join("src")).expect("src dir");
359        std::fs::write(
360            temp.path().join("src/index.ts"),
361            "export const value = 1;\n",
362        )
363        .expect("source file");
364
365        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
366
367        assert!(session.workspace_diagnostics().iter().any(|diagnostic| {
368            diagnostic.kind.id() == "glob-matched-no-package-json"
369                && diagnostic.path.ends_with("packages/empty")
370        }));
371    }
372
373    #[test]
374    fn analysis_session_from_resolved_config_discovers_workspaces() {
375        let temp = tempfile::tempdir().expect("tempdir");
376        std::fs::write(
377            temp.path().join("package.json"),
378            r#"{"name":"root","workspaces":["packages/*"]}"#,
379        )
380        .expect("root package");
381        std::fs::create_dir_all(temp.path().join("packages/a/src")).expect("workspace dir");
382        std::fs::write(
383            temp.path().join("packages/a/package.json"),
384            r#"{"name":"pkg-a","main":"src/index.ts"}"#,
385        )
386        .expect("workspace package");
387        std::fs::write(
388            temp.path().join("packages/a/src/index.ts"),
389            "export const value = 1;\n",
390        )
391        .expect("workspace source");
392
393        let config = fallow_config::FallowConfig::default().resolve(
394            temp.path().to_path_buf(),
395            OutputFormat::Json,
396            1,
397            false,
398            true,
399            None,
400        );
401        let session = AnalysisSession::from_resolved_config(config).expect("session");
402
403        assert!(
404            session
405                .workspaces()
406                .iter()
407                .any(|workspace| workspace.name == "pkg-a"),
408            "resolved-config sessions must expose workspaces found during fallback discovery"
409        );
410    }
411
412    #[test]
413    fn analysis_session_can_be_consumed_into_pipeline_parts() {
414        let temp = tempfile::tempdir().expect("tempdir");
415        let src = temp.path().join("src");
416        std::fs::create_dir(&src).expect("src dir");
417        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
418
419        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
420        let parts = session.into_parts();
421
422        assert_eq!(parts.config.root, temp.path());
423        assert!(parts.config_path.is_none());
424        assert!(parts.files.iter().any(|file| {
425            file.path
426                .strip_prefix(temp.path())
427                .is_ok_and(|path| path == Path::new("src/index.ts"))
428        }));
429    }
430
431    #[test]
432    fn analysis_session_can_be_consumed_into_parsed_pipeline_parts() {
433        let temp = tempfile::tempdir().expect("tempdir");
434        let src = temp.path().join("src");
435        std::fs::create_dir(&src).expect("src dir");
436        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
437
438        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
439        std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
440        let parts = session.into_parsed_parts(false);
441
442        assert_eq!(parts.config.root, temp.path());
443        assert!(parts.config_path.is_none());
444        assert!(parts.modules.iter().any(|module| {
445            parts.files[module.file_id.0 as usize]
446                .path
447                .strip_prefix(temp.path())
448                .is_ok_and(|path| path == Path::new("src/index.ts"))
449        }));
450        assert!(parts.modules.iter().all(|module| {
451            !parts.files[module.file_id.0 as usize]
452                .path
453                .ends_with("late.ts")
454        }));
455    }
456
457    #[test]
458    fn analysis_session_reuses_complexity_parse_for_plain_parse() {
459        let temp = tempfile::tempdir().expect("tempdir");
460        let src = temp.path().join("src");
461        std::fs::create_dir(&src).expect("src dir");
462        std::fs::write(
463            src.join("index.ts"),
464            "export function value() { return 1; }\n",
465        )
466        .expect("source file");
467
468        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
469        let first = session.parsed_parts(true);
470        assert!(!first.modules.is_empty());
471
472        let second = session.parsed_parts(false);
473
474        assert!(!second.modules.is_empty());
475        assert!(second.parse_ms.abs() < f64::EPSILON);
476        assert!(second.parse_cpu_ms.abs() < f64::EPSILON);
477    }
478
479    #[test]
480    fn dead_code_reused_parse_path_uses_engine_pipeline() {
481        let temp = tempfile::tempdir().expect("tempdir");
482        let src = temp.path().join("src");
483        std::fs::create_dir(&src).expect("src dir");
484        std::fs::write(src.join("index.ts"), "import './util';\n").expect("entry file");
485        std::fs::write(src.join("util.ts"), "export const value = 1;\n").expect("source file");
486
487        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
488        let parts = session.into_parsed_parts(false);
489        let analysis = crate::dead_code::analyze_with_parse_result(&parts.config, &parts.modules)
490            .expect("reused parse analysis succeeds");
491
492        assert!(analysis.graph.is_some());
493        assert!(analysis.modules.is_none());
494        assert!(analysis.files.is_none());
495        assert!(
496            analysis
497                .file_hashes
498                .keys()
499                .any(|path| path.ends_with("util.ts"))
500        );
501    }
502
503    #[test]
504    fn analysis_session_reparses_when_cached_source_changes() {
505        let temp = tempfile::tempdir().expect("tempdir");
506        let src = temp.path().join("src");
507        std::fs::create_dir(&src).expect("src dir");
508        std::fs::write(
509            src.join("index.ts"),
510            "import { value } from './util';\nconsole.log(value);\n",
511        )
512        .expect("entry file");
513        let util_path = src.join("util.ts");
514        std::fs::write(&util_path, "export const value = 1;\n").expect("source file");
515
516        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
517        let first = session
518            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
519            .expect("first analysis succeeds");
520        assert!(first.dead_code.results.unused_exports.is_empty());
521
522        std::fs::write(
523            &util_path,
524            "export const value = 1;\nexport const addedUnused = 2;\n",
525        )
526        .expect("updated source file");
527
528        let second = session
529            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
530            .expect("second analysis succeeds");
531        assert!(
532            second
533                .dead_code
534                .results
535                .unused_exports
536                .iter()
537                .any(|finding| finding.export.export_name == "addedUnused")
538        );
539    }
540
541    #[test]
542    fn analysis_session_returns_combined_project_analysis() {
543        let temp = tempfile::tempdir().expect("tempdir");
544        let src = temp.path().join("src");
545        std::fs::create_dir(&src).expect("src dir");
546        let repeated =
547            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
548        std::fs::write(src.join("a.ts"), repeated).expect("source file");
549        std::fs::write(src.join("b.ts"), repeated).expect("source file");
550
551        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
552        let mut config = session.config().duplicates.clone();
553        config.min_tokens = 1;
554        config.min_lines = 1;
555
556        let analysis = session
557            .analyze_project_with(&config, true)
558            .expect("project analysis succeeds");
559
560        assert!(analysis.dead_code.modules.is_some());
561        assert!(analysis.dead_code.files.is_some());
562        assert!(!analysis.duplication.clone_groups.is_empty());
563    }
564
565    #[test]
566    fn analysis_session_reuses_discovery_for_dead_code() {
567        let temp = tempfile::tempdir().expect("tempdir");
568        let src = temp.path().join("src");
569        std::fs::create_dir(&src).expect("src dir");
570        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
571
572        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
573        std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
574
575        let analysis = session.analyze_dead_code().expect("analysis succeeds");
576
577        assert!(
578            analysis
579                .results
580                .unused_files
581                .iter()
582                .all(|finding| !finding.file.path.ends_with("late.ts")),
583            "session analysis must not rediscover files added after session load"
584        );
585    }
586
587    #[test]
588    fn analysis_session_returns_retained_artifacts() {
589        let temp = tempfile::tempdir().expect("tempdir");
590        let src = temp.path().join("src");
591        std::fs::create_dir(&src).expect("src dir");
592        std::fs::write(
593            src.join("index.ts"),
594            "export function used() { return 1; }\nused();\n",
595        )
596        .expect("source file");
597
598        let config = config_for_project(temp.path(), None)
599            .expect("config")
600            .config;
601        let session = AnalysisSession::from_resolved_config(config).expect("session");
602        let artifacts = session
603            .analyze_dead_code_with_artifacts(true, true)
604            .expect("analysis succeeds");
605
606        assert!(artifacts.graph.is_some());
607        assert!(artifacts.modules.is_some_and(|modules| !modules.is_empty()));
608        assert!(artifacts.files.is_some_and(|files| !files.is_empty()));
609    }
610
611    #[test]
612    fn analysis_session_returns_reuse_artifacts_with_fingerprints_and_scope() {
613        let temp = tempfile::tempdir().expect("tempdir");
614        let src = temp.path().join("src");
615        std::fs::create_dir(&src).expect("src dir");
616        let source = src.join("index.ts");
617        std::fs::write(&source, "export const value = 1;\n").expect("source file");
618
619        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
620        let mut changed_files = rustc_hash::FxHashSet::default();
621        changed_files.insert(source.clone());
622        let artifacts = session
623            .analyze_dead_code_with_session_artifacts(false, true, Some(changed_files))
624            .expect("analysis succeeds");
625
626        assert!(artifacts.analysis.graph.is_some());
627        assert!(
628            artifacts
629                .changed_files
630                .as_ref()
631                .is_some_and(|changed| changed.contains(&source))
632        );
633        assert!(
634            artifacts
635                .source_fingerprints
636                .get(&source)
637                .is_some_and(|fingerprint| fingerprint.file_size > 0)
638        );
639    }
640
641    #[test]
642    fn analysis_session_returns_project_artifacts_with_reuse_metadata() {
643        let temp = tempfile::tempdir().expect("tempdir");
644        let src = temp.path().join("src");
645        std::fs::create_dir(&src).expect("src dir");
646        let source = src.join("index.ts");
647        std::fs::write(&source, "export const value = 1;\n").expect("source file");
648
649        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
650        let mut changed_files = rustc_hash::FxHashSet::default();
651        changed_files.insert(source.clone());
652        let artifacts = session
653            .analyze_project_with_artifacts(
654                &session.config().duplicates,
655                ProjectAnalysisArtifactOptions {
656                    retain_complexity_artifacts: true,
657                    retain_graph: true,
658                    changed_files: Some(changed_files),
659                    collect_source_fingerprints: true,
660                },
661            )
662            .expect("project analysis succeeds");
663
664        assert!(artifacts.dead_code.graph.is_some());
665        assert!(
666            artifacts
667                .changed_files
668                .as_ref()
669                .is_some_and(|changed| changed.contains(&source))
670        );
671        assert!(
672            artifacts
673                .source_fingerprints
674                .as_ref()
675                .and_then(|fingerprints| fingerprints.get(&source))
676                .is_some_and(|fingerprint| fingerprint.file_size > 0)
677        );
678
679        let lightweight = session
680            .analyze_project_with_artifacts(
681                &session.config().duplicates,
682                ProjectAnalysisArtifactOptions::default(),
683            )
684            .expect("project analysis succeeds");
685        assert!(
686            lightweight.source_fingerprints.is_none(),
687            "source fingerprints should be opt-in for lightweight editor analysis"
688        );
689
690        let output = artifacts.into_output();
691        assert!(output.dead_code.modules.is_some());
692        assert!(output.dead_code.files.is_some());
693    }
694
695    #[test]
696    fn project_artifacts_focus_duplication_to_changed_files() {
697        let temp = tempfile::tempdir().expect("tempdir");
698        let src = temp.path().join("src");
699        std::fs::create_dir(&src).expect("src dir");
700        let repeated =
701            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
702        let a = src.join("a.ts");
703        std::fs::write(&a, repeated).expect("source file");
704        std::fs::write(src.join("b.ts"), repeated).expect("source file");
705
706        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
707        let mut config = session.config().duplicates.clone();
708        config.min_tokens = 1;
709        config.min_lines = 1;
710
711        let full = session
712            .analyze_project_with_artifacts(&config, ProjectAnalysisArtifactOptions::default())
713            .expect("project analysis succeeds");
714        assert!(!full.duplication.clone_groups.is_empty());
715
716        let mut unrelated = rustc_hash::FxHashSet::default();
717        unrelated.insert(src.join("unrelated.ts"));
718        let focused_empty = session
719            .analyze_project_with_artifacts(
720                &config,
721                ProjectAnalysisArtifactOptions {
722                    changed_files: Some(unrelated),
723                    ..ProjectAnalysisArtifactOptions::default()
724                },
725            )
726            .expect("project analysis succeeds");
727        assert!(focused_empty.duplication.clone_groups.is_empty());
728
729        let mut changed = rustc_hash::FxHashSet::default();
730        changed.insert(a);
731        let focused = session
732            .analyze_project_with_artifacts(
733                &config,
734                ProjectAnalysisArtifactOptions {
735                    changed_files: Some(changed),
736                    ..ProjectAnalysisArtifactOptions::default()
737                },
738            )
739            .expect("project analysis succeeds");
740        assert!(!focused.duplication.clone_groups.is_empty());
741    }
742
743    #[test]
744    fn analysis_session_runs_duplication_with_default_skip_metadata() {
745        let temp = tempfile::tempdir().expect("tempdir");
746        let src = temp.path().join("src");
747        let generated = temp.path().join("storybook-static");
748        std::fs::create_dir(&src).expect("src dir");
749        std::fs::create_dir(&generated).expect("generated dir");
750        let repeated =
751            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
752        std::fs::write(src.join("a.ts"), repeated).expect("source file");
753        std::fs::write(src.join("b.ts"), repeated).expect("source file");
754        std::fs::write(generated.join("generated.ts"), repeated).expect("generated file");
755
756        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
757        let mut config = session.config().duplicates.clone();
758        config.min_tokens = 1;
759        config.min_lines = 1;
760
761        let analysis = session.find_duplicates_with_defaults(&config, None);
762
763        assert!(!analysis.report.clone_groups.is_empty());
764        assert!(analysis.default_ignore_skips.total > 0);
765    }
766
767    #[test]
768    fn trace_symbol_chain_uses_retained_engine_analysis() {
769        let temp = tempfile::tempdir().expect("tempdir");
770        let src = temp.path().join("src");
771        std::fs::create_dir(&src).expect("src dir");
772        std::fs::write(
773            src.join("util.ts"),
774            "export function helper() { return 1; }\n",
775        )
776        .expect("util source");
777        std::fs::write(
778            src.join("index.ts"),
779            "import { helper } from './util';\nexport const value = helper();\n",
780        )
781        .expect("index source");
782
783        let project_config = config_for_project_analysis(
784            temp.path(),
785            None,
786            ProjectConfigOptions {
787                output: OutputFormat::Json,
788                no_cache: true,
789                threads: 1,
790                production_override: None,
791                quiet: true,
792                analysis: ProductionAnalysis::DeadCode,
793                allow_remote_extends: false,
794            },
795        )
796        .expect("project config loads");
797        let session = AnalysisSession::from_config(project_config);
798        let trace = crate::trace_chain::trace_symbol_chain_with_session(
799            &session,
800            fallow_types::trace_chain::SymbolChainQuery {
801                file: "src/util.ts",
802                symbol: "helper",
803                depth: 1,
804                directions: fallow_types::trace_chain::TraceDirections {
805                    callers: true,
806                    callees: false,
807                },
808            },
809        )
810        .expect("trace succeeds")
811        .expect("trace target exists");
812
813        assert!(trace.symbol_found);
814        assert_eq!(trace.file, Path::new("src/util.ts"));
815        assert!(trace.callers.is_some_and(|callers| {
816            callers
817                .iter()
818                .any(|caller| caller.file == Path::new("src/index.ts"))
819        }));
820    }
821
822    fn workspace_fixture_path(name: &str) -> std::path::PathBuf {
823        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
824            .join("../../tests/fixtures")
825            .join(name)
826    }
827
828    fn trace_symbol_chain_fixture(
829        fixture: &str,
830        file: &str,
831        symbol: &str,
832        directions: fallow_types::trace_chain::TraceDirections,
833        depth: u32,
834    ) -> fallow_types::trace_chain::SymbolChainTrace {
835        let root = workspace_fixture_path(fixture);
836        let session = AnalysisSession::load(&root, None).expect("session loads");
837        crate::trace_chain::trace_symbol_chain_with_session(
838            &session,
839            fallow_types::trace_chain::SymbolChainQuery {
840                file,
841                symbol,
842                depth,
843                directions,
844            },
845        )
846        .expect("trace succeeds")
847        .expect("trace target exists")
848    }
849
850    fn symbol_chain_hop_files(
851        hops: &[fallow_types::trace_chain::ChainHop],
852    ) -> std::collections::BTreeSet<String> {
853        hops.iter()
854            .map(|hop| hop.file.to_string_lossy().replace('\\', "/"))
855            .collect()
856    }
857
858    #[test]
859    fn trace_symbol_chain_caller_set_matches_import_symbol_callers() {
860        let trace = trace_symbol_chain_fixture(
861            "e8-symbol-chain",
862            "src/format.ts",
863            "formatDate",
864            fallow_types::trace_chain::TraceDirections {
865                callers: true,
866                callees: false,
867            },
868            1,
869        );
870
871        assert!(trace.symbol_found, "formatDate is an export of format.ts");
872        assert!(
873            trace.best_effort,
874            "symbol-level chains are labeled best-effort"
875        );
876
877        let callers = trace.callers.expect("callers were requested");
878        let actual = symbol_chain_hop_files(&callers);
879        let expected: std::collections::BTreeSet<String> =
880            ["src/report.ts".to_string(), "src/middle.ts".to_string()]
881                .into_iter()
882                .collect();
883        assert_eq!(actual, expected);
884
885        for hop in &callers {
886            assert_eq!(hop.imported_as, "formatDate");
887            assert_eq!(hop.local_name, "formatDate");
888            assert_eq!(hop.depth, 1);
889            assert!(!hop.type_only);
890        }
891    }
892
893    #[test]
894    fn trace_symbol_chain_reports_unresolved_callees() {
895        let trace = trace_symbol_chain_fixture(
896            "e8-symbol-chain",
897            "src/report.ts",
898            "buildReport",
899            fallow_types::trace_chain::TraceDirections {
900                callers: false,
901                callees: true,
902            },
903            1,
904        );
905
906        assert!(trace.symbol_found, "buildReport is an export of report.ts");
907
908        let unresolved = trace
909            .unresolved_callees
910            .expect("callees were requested, so unresolved_callees is present");
911        let callees: Vec<&str> = unresolved
912            .iter()
913            .map(|callee| callee.callee.as_str())
914            .collect();
915
916        assert!(
917            callees.contains(&"localHelper"),
918            "the local helper callee must be reported as unresolved, got {callees:?}"
919        );
920        assert!(
921            callees.contains(&"parseInt"),
922            "the global callee must be reported as unresolved, got {callees:?}"
923        );
924        assert!(
925            !callees.contains(&"formatDate"),
926            "an imported callee resolves to an edge and is not unresolved, got {callees:?}"
927        );
928
929        let local_helper = unresolved
930            .iter()
931            .find(|callee| callee.callee == "localHelper")
932            .expect("local helper is unresolved");
933        assert_eq!(
934            local_helper.reason,
935            fallow_types::trace_chain::UnresolvedReason::LocalOrGlobal
936        );
937
938        let callees_hops = trace.callees.expect("callees were requested");
939        let resolved_files = symbol_chain_hop_files(&callees_hops);
940        assert!(
941            resolved_files.contains("src/format.ts"),
942            "the resolved import-symbol callee edge to format.ts must be present, got {resolved_files:?}"
943        );
944    }
945
946    /// A star collision, a real export, and a name nobody declares must be
947    /// three distinguishable outcomes: before this, the first two of those
948    /// three collapsed into one byte-identical "not found" answer.
949    #[test]
950    fn trace_symbol_chain_separates_ambiguous_from_unique_and_absent() {
951        let both_directions = fallow_types::trace_chain::TraceDirections {
952            callers: true,
953            callees: true,
954        };
955
956        let ambiguous = trace_symbol_chain_fixture(
957            "effective-export-ambiguous-star",
958            "src/barrel.ts",
959            "foo",
960            both_directions,
961            1,
962        );
963        assert!(
964            !ambiguous.symbol_found,
965            "an ambiguous name is genuinely not exported by the barrel"
966        );
967        let collision = ambiguous
968            .star_export_ambiguity
969            .expect("the barrel's star sources collide on foo");
970        let sources: Vec<String> = collision
971            .sources
972            .iter()
973            .map(|source| source.to_string_lossy().replace('\\', "/"))
974            .collect();
975        assert_eq!(sources, vec!["src/left.ts", "src/right.ts"]);
976        assert_eq!(
977            collision.namespaces,
978            vec![fallow_types::semantic::SemanticNamespace::Value]
979        );
980        assert!(
981            ambiguous.reason.contains("src/left.ts")
982                && ambiguous.reason.contains("src/right.ts")
983                && ambiguous.reason.contains("ambiguous"),
984            "the reason must name the colliding origins, got {}",
985            ambiguous.reason
986        );
987
988        let unique = trace_symbol_chain_fixture(
989            "effective-export-ambiguous-star",
990            "src/left.ts",
991            "foo",
992            both_directions,
993            1,
994        );
995        assert!(unique.symbol_found, "left.ts really exports foo");
996        assert!(
997            unique.star_export_ambiguity.is_none(),
998            "a declaring module is not itself ambiguous"
999        );
1000
1001        let absent = trace_symbol_chain_fixture(
1002            "effective-export-ambiguous-star",
1003            "src/barrel.ts",
1004            "nonExistent",
1005            both_directions,
1006            1,
1007        );
1008        assert!(!absent.symbol_found);
1009        assert!(
1010            absent.star_export_ambiguity.is_none(),
1011            "an unknown name has no collision to report"
1012        );
1013        assert!(
1014            absent.reason.starts_with("symbol not found as an export"),
1015            "the unknown-name reason is unchanged, got {}",
1016            absent.reason
1017        );
1018        assert_ne!(
1019            absent.reason, ambiguous.reason,
1020            "ambiguous and absent must not report the same reason"
1021        );
1022    }
1023
1024    /// `export type *` sources colliding over value declarations produce a
1025    /// collision that exists only in type space, and the trace must report it
1026    /// in that namespace rather than staying silent.
1027    #[test]
1028    fn trace_symbol_chain_reports_a_type_only_star_collision() {
1029        let ambiguous = trace_symbol_chain_fixture(
1030            "effective-export-ambiguous-type-only-star",
1031            "src/barrel.ts",
1032            "Foo",
1033            fallow_types::trace_chain::TraceDirections {
1034                callers: true,
1035                callees: true,
1036            },
1037            1,
1038        );
1039
1040        assert!(!ambiguous.symbol_found);
1041        let collision = ambiguous
1042            .star_export_ambiguity
1043            .expect("the barrel's type-only star sources collide on Foo");
1044        let sources: Vec<String> = collision
1045            .sources
1046            .iter()
1047            .map(|source| source.to_string_lossy().replace('\\', "/"))
1048            .collect();
1049        assert_eq!(sources, vec!["src/left.ts", "src/right.ts"]);
1050        assert_eq!(
1051            collision.namespaces,
1052            vec![fallow_types::semantic::SemanticNamespace::Type]
1053        );
1054        assert!(
1055            ambiguous.reason.contains("src/left.ts") && ambiguous.reason.contains("ambiguous"),
1056            "the reason must name the colliding origins, got {}",
1057            ambiguous.reason
1058        );
1059    }
1060
1061    #[test]
1062    fn trace_export_uses_retained_engine_analysis_for_star_reexport() {
1063        let temp = tempfile::tempdir().expect("tempdir");
1064        let src = temp.path().join("src");
1065        std::fs::create_dir(&src).expect("src dir");
1066        std::fs::write(
1067            src.join("merged.ts"),
1068            "export const Merged = 1;\nexport const unusedControl = 2;\n",
1069        )
1070        .expect("merged source");
1071        std::fs::write(src.join("barrel.ts"), "export * from './merged';\n")
1072            .expect("barrel source");
1073        std::fs::write(
1074            src.join("index.ts"),
1075            "import { Merged } from './barrel';\nconsole.log(Merged);\n",
1076        )
1077        .expect("index source");
1078
1079        let config = config_for_project(temp.path(), None)
1080            .expect("config")
1081            .config;
1082        let session = AnalysisSession::from_resolved_config(config).expect("session");
1083        let artifacts = session
1084            .analyze_dead_code_with_artifacts(false, true)
1085            .expect("analysis succeeds");
1086        let graph = artifacts.graph.as_ref().expect("graph is retained");
1087        let trace = crate::trace::trace_export(graph, session.root(), "src/merged.ts", "Merged")
1088            .expect("trace exists");
1089
1090        assert!(trace.is_used, "trace should agree the value export is used");
1091        assert_eq!(
1092            trace.direct_references.len(),
1093            1,
1094            "trace should include the consumer named import"
1095        );
1096    }
1097}