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