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