Skip to main content

fallow_engine/
lib.rs

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