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