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