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