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