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