Skip to main content

fallow_engine/
lib.rs

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