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