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