Skip to main content

fallow_engine/
lib.rs

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