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