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;
32pub mod duplicates;
33mod error;
34mod feature_flags;
35pub mod flags;
36#[path = "git_env.rs"]
37mod git_env;
38pub mod guard;
39pub mod health;
40pub mod module_graph;
41pub mod plugins;
42pub mod project_analysis;
43pub mod project_config;
44mod public_api;
45mod results;
46mod security;
47pub mod session;
48pub mod source;
49mod suppress;
50pub mod trace;
51pub mod trace_chain;
52pub mod validate;
53pub mod vital_signs;
54
55/// Result alias for typed engine operations.
56pub type EngineResult<T> = Result<T, EngineError>;
57
58/// Error type exposed by the typed engine boundary.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct EngineError {
61    message: String,
62}
63
64impl EngineError {
65    /// Create an engine error from a user-facing message.
66    #[must_use]
67    pub fn new(message: impl Into<String>) -> Self {
68        Self {
69            message: message.into(),
70        }
71    }
72
73    /// User-facing error message from the backend.
74    #[must_use]
75    pub fn message(&self) -> &str {
76        &self.message
77    }
78}
79
80impl fmt::Display for EngineError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(&self.message)
83    }
84}
85
86impl std::error::Error for EngineError {}
87
88pub(crate) fn engine_error(err: impl fmt::Display) -> EngineError {
89    EngineError::new(err.to_string())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::{
96        project_analysis::ProjectAnalysisArtifactOptions,
97        project_config::{
98            ProjectConfigOptions, config_for_project, config_for_project_analysis,
99            resolve_cache_max_size_bytes,
100        },
101        session::AnalysisSession,
102    };
103    use fallow_config::ProductionAnalysis;
104    use fallow_types::output_format::OutputFormat;
105    use std::fs;
106    use std::path::PathBuf;
107
108    #[test]
109    fn engine_error_displays_message() {
110        let err = EngineError::new("config failed");
111
112        assert_eq!(err.message(), "config failed");
113        assert_eq!(err.to_string(), "config failed");
114    }
115
116    #[test]
117    fn engine_resolves_parse_cache_size_policy() {
118        let mut config = fallow_config::FallowConfig::default().resolve(
119            PathBuf::from("/repo"),
120            OutputFormat::Json,
121            1,
122            false,
123            true,
124            None,
125        );
126        assert_eq!(
127            resolve_cache_max_size_bytes(&config),
128            fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE
129        );
130
131        config.cache_max_size_mb = Some(3);
132        assert_eq!(resolve_cache_max_size_bytes(&config), 3 * 1024 * 1024);
133
134        config.cache_max_size_mb = Some(u32::MAX);
135        assert_eq!(
136            resolve_cache_max_size_bytes(&config),
137            (u32::MAX as usize).saturating_mul(1024 * 1024)
138        );
139    }
140
141    #[test]
142    fn engine_root_does_not_reexport_broad_surface_modules() {
143        let source = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/lib.rs"))
144            .expect("read engine lib");
145        let public_surface = source
146            .split("#[cfg(test)]")
147            .next()
148            .expect("engine lib has public surface before tests");
149        let forbidden_exports = [
150            "pub use error::",
151            "pub use flags::",
152            "pub use git_env::",
153            "pub use public_api::",
154            "pub use results::",
155            "pub use security::",
156            "pub use suppress::",
157            "health_shared_parse_data_from_artifacts",
158        ];
159
160        for forbidden in forbidden_exports {
161            assert!(
162                !public_surface.contains(forbidden),
163                "engine root must expose typed modules, not `{forbidden}`"
164            );
165        }
166    }
167
168    #[test]
169    fn engine_session_owns_dead_code_pipeline_sequence() {
170        let session_source =
171            fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
172                .expect("read engine session");
173        assert!(
174            !session_source.contains("analyze_with_owned_parse_result_from_discovery"),
175            "engine session must not delegate dead-code orchestration to the old core monolith"
176        );
177        for required_phase in [
178            "prepare_dead_code_backend_prelude",
179            "discover_dead_code_entry_points",
180            "try_load_dead_code_graph_cache",
181            "resolve_dead_code_imports",
182            "build_dead_code_graph",
183            "run_dead_code_detectors",
184        ] {
185            assert!(
186                session_source.contains(required_phase),
187                "engine session must explicitly sequence `{required_phase}`"
188            );
189        }
190    }
191
192    #[test]
193    fn engine_session_owns_analysis_discovery() {
194        let session_source =
195            fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
196                .expect("read engine session");
197        assert!(
198            session_source.contains("crate::discover::prepare_analysis_discovery"),
199            "engine session must build discovery through the engine discovery boundary"
200        );
201        assert!(
202            session_source.contains("prepare_analysis_discovery_with_workspaces"),
203            "engine session must reuse workspace metadata captured during config load"
204        );
205        assert!(
206            session_source.contains("workspace_discovery_ms.is_some()"),
207            "AnalysisSession::from_config must only reuse workspace metadata when ProjectConfig preloaded it"
208        );
209        assert!(
210            !session_source.contains("core_backend::prepare_analysis_discovery"),
211            "engine session must not delegate discovery orchestration to core_backend"
212        );
213    }
214
215    #[test]
216    fn analysis_session_loads_config_and_discovered_files() {
217        let temp = tempfile::tempdir().expect("tempdir");
218        let src = temp.path().join("src");
219        std::fs::create_dir(&src).expect("src dir");
220        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
221
222        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
223
224        assert_eq!(session.root(), temp.path());
225        assert!(session.config_path().is_none());
226        assert!(session.files().iter().any(|file| {
227            file.path
228                .strip_prefix(temp.path())
229                .is_ok_and(|path| path == Path::new("src/index.ts"))
230        }));
231    }
232
233    #[test]
234    fn analysis_session_applies_config_adjustment_before_discovery() {
235        let temp = tempfile::tempdir().expect("tempdir");
236        let src = temp.path().join("src");
237        std::fs::create_dir(&src).expect("src dir");
238        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
239        std::fs::write(src.join("index.test.ts"), "export const testValue = 1;\n")
240            .expect("test source file");
241
242        let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
243            config.production = true;
244        })
245        .expect("session loads");
246
247        let relative_paths: Vec<_> = session
248            .files()
249            .iter()
250            .filter_map(|file| file.path.strip_prefix(temp.path()).ok())
251            .collect();
252        assert!(relative_paths.contains(&Path::new("src/index.ts")));
253        assert!(!relative_paths.contains(&Path::new("src/index.test.ts")));
254    }
255
256    #[test]
257    fn analysis_session_config_adjustment_invalidates_preloaded_workspaces() {
258        let temp = tempfile::tempdir().expect("tempdir");
259        std::fs::write(
260            temp.path().join("package.json"),
261            r#"{"name":"root","workspaces":["packages/*"]}"#,
262        )
263        .expect("root package");
264        std::fs::create_dir_all(temp.path().join("packages/a")).expect("workspace dir");
265        std::fs::create_dir_all(temp.path().join("packages/ignored")).expect("ignored dir");
266        std::fs::write(
267            temp.path().join("packages/a/package.json"),
268            r#"{"name":"a","main":"src/index.ts"}"#,
269        )
270        .expect("workspace package");
271
272        let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
273            config.ignore_patterns = globset::GlobSetBuilder::new()
274                .add(globset::Glob::new("packages/ignored").expect("ignore glob"))
275                .build()
276                .expect("ignore set");
277        })
278        .expect("session loads");
279
280        assert!(
281            session
282                .workspaces()
283                .iter()
284                .all(|workspace| workspace.name != "ignored"),
285            "config mutations that affect workspace discovery must not reuse preloaded workspaces"
286        );
287        assert!(
288            !session
289                .workspace_diagnostics()
290                .iter()
291                .any(|diagnostic| diagnostic.path.ends_with("packages/ignored")),
292            "config mutations that affect workspace diagnostics must not reuse stale diagnostics"
293        );
294    }
295
296    #[test]
297    fn analysis_session_captures_workspace_diagnostics() {
298        let temp = tempfile::tempdir().expect("tempdir");
299        std::fs::write(
300            temp.path().join("package.json"),
301            r#"{"name":"diagnostic-root","workspaces":["packages/*"]}"#,
302        )
303        .expect("package json");
304        std::fs::create_dir_all(temp.path().join("packages/empty")).expect("workspace dir");
305        std::fs::create_dir(temp.path().join("src")).expect("src dir");
306        std::fs::write(
307            temp.path().join("src/index.ts"),
308            "export const value = 1;\n",
309        )
310        .expect("source file");
311
312        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
313
314        assert!(session.workspace_diagnostics().iter().any(|diagnostic| {
315            diagnostic.kind.id() == "glob-matched-no-package-json"
316                && diagnostic.path.ends_with("packages/empty")
317        }));
318    }
319
320    #[test]
321    fn analysis_session_from_resolved_config_discovers_workspaces() {
322        let temp = tempfile::tempdir().expect("tempdir");
323        std::fs::write(
324            temp.path().join("package.json"),
325            r#"{"name":"root","workspaces":["packages/*"]}"#,
326        )
327        .expect("root package");
328        std::fs::create_dir_all(temp.path().join("packages/a/src")).expect("workspace dir");
329        std::fs::write(
330            temp.path().join("packages/a/package.json"),
331            r#"{"name":"pkg-a","main":"src/index.ts"}"#,
332        )
333        .expect("workspace package");
334        std::fs::write(
335            temp.path().join("packages/a/src/index.ts"),
336            "export const value = 1;\n",
337        )
338        .expect("workspace source");
339
340        let config = fallow_config::FallowConfig::default().resolve(
341            temp.path().to_path_buf(),
342            OutputFormat::Json,
343            1,
344            false,
345            true,
346            None,
347        );
348        let session = AnalysisSession::from_resolved_config(config);
349
350        assert!(
351            session
352                .workspaces()
353                .iter()
354                .any(|workspace| workspace.name == "pkg-a"),
355            "resolved-config sessions must expose workspaces found during fallback discovery"
356        );
357    }
358
359    #[test]
360    fn analysis_session_can_be_consumed_into_pipeline_parts() {
361        let temp = tempfile::tempdir().expect("tempdir");
362        let src = temp.path().join("src");
363        std::fs::create_dir(&src).expect("src dir");
364        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
365
366        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
367        let parts = session.into_parts();
368
369        assert_eq!(parts.config.root, temp.path());
370        assert!(parts.config_path.is_none());
371        assert!(parts.files.iter().any(|file| {
372            file.path
373                .strip_prefix(temp.path())
374                .is_ok_and(|path| path == Path::new("src/index.ts"))
375        }));
376    }
377
378    #[test]
379    fn analysis_session_can_be_consumed_into_parsed_pipeline_parts() {
380        let temp = tempfile::tempdir().expect("tempdir");
381        let src = temp.path().join("src");
382        std::fs::create_dir(&src).expect("src dir");
383        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
384
385        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
386        std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
387        let parts = session.into_parsed_parts(false);
388
389        assert_eq!(parts.config.root, temp.path());
390        assert!(parts.config_path.is_none());
391        assert!(parts.modules.iter().any(|module| {
392            parts.files[module.file_id.0 as usize]
393                .path
394                .strip_prefix(temp.path())
395                .is_ok_and(|path| path == Path::new("src/index.ts"))
396        }));
397        assert!(parts.modules.iter().all(|module| {
398            !parts.files[module.file_id.0 as usize]
399                .path
400                .ends_with("late.ts")
401        }));
402    }
403
404    #[test]
405    fn analysis_session_reuses_complexity_parse_for_plain_parse() {
406        let temp = tempfile::tempdir().expect("tempdir");
407        let src = temp.path().join("src");
408        std::fs::create_dir(&src).expect("src dir");
409        std::fs::write(
410            src.join("index.ts"),
411            "export function value() { return 1; }\n",
412        )
413        .expect("source file");
414
415        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
416        let first = session.parsed_parts(true);
417        assert!(!first.modules.is_empty());
418
419        let second = session.parsed_parts(false);
420
421        assert!(!second.modules.is_empty());
422        assert!(second.parse_ms.abs() < f64::EPSILON);
423        assert!(second.parse_cpu_ms.abs() < f64::EPSILON);
424    }
425
426    #[test]
427    fn dead_code_reused_parse_path_uses_engine_pipeline() {
428        let temp = tempfile::tempdir().expect("tempdir");
429        let src = temp.path().join("src");
430        std::fs::create_dir(&src).expect("src dir");
431        std::fs::write(src.join("index.ts"), "import './util';\n").expect("entry file");
432        std::fs::write(src.join("util.ts"), "export const value = 1;\n").expect("source file");
433
434        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
435        let parts = session.into_parsed_parts(false);
436        let analysis = crate::dead_code::analyze_with_parse_result(&parts.config, &parts.modules)
437            .expect("reused parse analysis succeeds");
438
439        assert!(analysis.graph.is_some());
440        assert!(analysis.modules.is_none());
441        assert!(analysis.files.is_none());
442        assert!(
443            analysis
444                .file_hashes
445                .keys()
446                .any(|path| path.ends_with("util.ts"))
447        );
448    }
449
450    #[test]
451    fn analysis_session_reparses_when_cached_source_changes() {
452        let temp = tempfile::tempdir().expect("tempdir");
453        let src = temp.path().join("src");
454        std::fs::create_dir(&src).expect("src dir");
455        std::fs::write(
456            src.join("index.ts"),
457            "import { value } from './util';\nconsole.log(value);\n",
458        )
459        .expect("entry file");
460        let util_path = src.join("util.ts");
461        std::fs::write(&util_path, "export const value = 1;\n").expect("source file");
462
463        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
464        let first = session
465            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
466            .expect("first analysis succeeds");
467        assert!(first.dead_code.results.unused_exports.is_empty());
468
469        std::fs::write(
470            &util_path,
471            "export const value = 1;\nexport const addedUnused = 2;\n",
472        )
473        .expect("updated source file");
474
475        let second = session
476            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
477            .expect("second analysis succeeds");
478        assert!(
479            second
480                .dead_code
481                .results
482                .unused_exports
483                .iter()
484                .any(|finding| finding.export.export_name == "addedUnused")
485        );
486    }
487
488    #[test]
489    fn analysis_session_returns_combined_project_analysis() {
490        let temp = tempfile::tempdir().expect("tempdir");
491        let src = temp.path().join("src");
492        std::fs::create_dir(&src).expect("src dir");
493        let repeated =
494            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
495        std::fs::write(src.join("a.ts"), repeated).expect("source file");
496        std::fs::write(src.join("b.ts"), repeated).expect("source file");
497
498        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
499        let mut config = session.config().duplicates.clone();
500        config.min_tokens = 1;
501        config.min_lines = 1;
502
503        let analysis = session
504            .analyze_project_with(&config, true)
505            .expect("project analysis succeeds");
506
507        assert!(analysis.dead_code.modules.is_some());
508        assert!(analysis.dead_code.files.is_some());
509        assert!(!analysis.duplication.clone_groups.is_empty());
510    }
511
512    #[test]
513    fn analysis_session_reuses_discovery_for_dead_code() {
514        let temp = tempfile::tempdir().expect("tempdir");
515        let src = temp.path().join("src");
516        std::fs::create_dir(&src).expect("src dir");
517        std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
518
519        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
520        std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
521
522        let analysis = session.analyze_dead_code().expect("analysis succeeds");
523
524        assert!(
525            analysis
526                .results
527                .unused_files
528                .iter()
529                .all(|finding| !finding.file.path.ends_with("late.ts")),
530            "session analysis must not rediscover files added after session load"
531        );
532    }
533
534    #[test]
535    fn analysis_session_returns_retained_artifacts() {
536        let temp = tempfile::tempdir().expect("tempdir");
537        let src = temp.path().join("src");
538        std::fs::create_dir(&src).expect("src dir");
539        std::fs::write(
540            src.join("index.ts"),
541            "export function used() { return 1; }\nused();\n",
542        )
543        .expect("source file");
544
545        let config = config_for_project(temp.path(), None)
546            .expect("config")
547            .config;
548        let session = AnalysisSession::from_resolved_config(config);
549        let artifacts = session
550            .analyze_dead_code_with_artifacts(true, true)
551            .expect("analysis succeeds");
552
553        assert!(artifacts.graph.is_some());
554        assert!(artifacts.modules.is_some_and(|modules| !modules.is_empty()));
555        assert!(artifacts.files.is_some_and(|files| !files.is_empty()));
556    }
557
558    #[test]
559    fn analysis_session_returns_reuse_artifacts_with_fingerprints_and_scope() {
560        let temp = tempfile::tempdir().expect("tempdir");
561        let src = temp.path().join("src");
562        std::fs::create_dir(&src).expect("src dir");
563        let source = src.join("index.ts");
564        std::fs::write(&source, "export const value = 1;\n").expect("source file");
565
566        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
567        let mut changed_files = rustc_hash::FxHashSet::default();
568        changed_files.insert(source.clone());
569        let artifacts = session
570            .analyze_dead_code_with_session_artifacts(false, true, Some(changed_files))
571            .expect("analysis succeeds");
572
573        assert!(artifacts.analysis.graph.is_some());
574        assert!(
575            artifacts
576                .changed_files
577                .as_ref()
578                .is_some_and(|changed| changed.contains(&source))
579        );
580        assert!(
581            artifacts
582                .source_fingerprints
583                .get(&source)
584                .is_some_and(|fingerprint| fingerprint.file_size > 0)
585        );
586    }
587
588    #[test]
589    fn analysis_session_returns_project_artifacts_with_reuse_metadata() {
590        let temp = tempfile::tempdir().expect("tempdir");
591        let src = temp.path().join("src");
592        std::fs::create_dir(&src).expect("src dir");
593        let source = src.join("index.ts");
594        std::fs::write(&source, "export const value = 1;\n").expect("source file");
595
596        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
597        let mut changed_files = rustc_hash::FxHashSet::default();
598        changed_files.insert(source.clone());
599        let artifacts = session
600            .analyze_project_with_artifacts(
601                &session.config().duplicates,
602                ProjectAnalysisArtifactOptions {
603                    retain_complexity_artifacts: true,
604                    retain_graph: true,
605                    changed_files: Some(changed_files),
606                    collect_source_fingerprints: true,
607                },
608            )
609            .expect("project analysis succeeds");
610
611        assert!(artifacts.dead_code.graph.is_some());
612        assert!(
613            artifacts
614                .changed_files
615                .as_ref()
616                .is_some_and(|changed| changed.contains(&source))
617        );
618        assert!(
619            artifacts
620                .source_fingerprints
621                .as_ref()
622                .and_then(|fingerprints| fingerprints.get(&source))
623                .is_some_and(|fingerprint| fingerprint.file_size > 0)
624        );
625
626        let lightweight = session
627            .analyze_project_with_artifacts(
628                &session.config().duplicates,
629                ProjectAnalysisArtifactOptions::default(),
630            )
631            .expect("project analysis succeeds");
632        assert!(
633            lightweight.source_fingerprints.is_none(),
634            "source fingerprints should be opt-in for lightweight editor analysis"
635        );
636
637        let output = artifacts.into_output();
638        assert!(output.dead_code.modules.is_some());
639        assert!(output.dead_code.files.is_some());
640    }
641
642    #[test]
643    fn project_artifacts_focus_duplication_to_changed_files() {
644        let temp = tempfile::tempdir().expect("tempdir");
645        let src = temp.path().join("src");
646        std::fs::create_dir(&src).expect("src dir");
647        let repeated =
648            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
649        let a = src.join("a.ts");
650        std::fs::write(&a, repeated).expect("source file");
651        std::fs::write(src.join("b.ts"), repeated).expect("source file");
652
653        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
654        let mut config = session.config().duplicates.clone();
655        config.min_tokens = 1;
656        config.min_lines = 1;
657
658        let full = session
659            .analyze_project_with_artifacts(&config, ProjectAnalysisArtifactOptions::default())
660            .expect("project analysis succeeds");
661        assert!(!full.duplication.clone_groups.is_empty());
662
663        let mut unrelated = rustc_hash::FxHashSet::default();
664        unrelated.insert(src.join("unrelated.ts"));
665        let focused_empty = session
666            .analyze_project_with_artifacts(
667                &config,
668                ProjectAnalysisArtifactOptions {
669                    changed_files: Some(unrelated),
670                    ..ProjectAnalysisArtifactOptions::default()
671                },
672            )
673            .expect("project analysis succeeds");
674        assert!(focused_empty.duplication.clone_groups.is_empty());
675
676        let mut changed = rustc_hash::FxHashSet::default();
677        changed.insert(a);
678        let focused = session
679            .analyze_project_with_artifacts(
680                &config,
681                ProjectAnalysisArtifactOptions {
682                    changed_files: Some(changed),
683                    ..ProjectAnalysisArtifactOptions::default()
684                },
685            )
686            .expect("project analysis succeeds");
687        assert!(!focused.duplication.clone_groups.is_empty());
688    }
689
690    #[test]
691    fn analysis_session_runs_duplication_with_default_skip_metadata() {
692        let temp = tempfile::tempdir().expect("tempdir");
693        let src = temp.path().join("src");
694        let generated = temp.path().join("storybook-static");
695        std::fs::create_dir(&src).expect("src dir");
696        std::fs::create_dir(&generated).expect("generated dir");
697        let repeated =
698            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
699        std::fs::write(src.join("a.ts"), repeated).expect("source file");
700        std::fs::write(src.join("b.ts"), repeated).expect("source file");
701        std::fs::write(generated.join("generated.ts"), repeated).expect("generated file");
702
703        let session = AnalysisSession::load(temp.path(), None).expect("session loads");
704        let mut config = session.config().duplicates.clone();
705        config.min_tokens = 1;
706        config.min_lines = 1;
707
708        let analysis = session.find_duplicates_with_defaults(&config, None);
709
710        assert!(!analysis.report.clone_groups.is_empty());
711        assert!(analysis.default_ignore_skips.total > 0);
712    }
713
714    #[test]
715    fn trace_symbol_chain_uses_retained_engine_analysis() {
716        let temp = tempfile::tempdir().expect("tempdir");
717        let src = temp.path().join("src");
718        std::fs::create_dir(&src).expect("src dir");
719        std::fs::write(
720            src.join("util.ts"),
721            "export function helper() { return 1; }\n",
722        )
723        .expect("util source");
724        std::fs::write(
725            src.join("index.ts"),
726            "import { helper } from './util';\nexport const value = helper();\n",
727        )
728        .expect("index source");
729
730        let project_config = config_for_project_analysis(
731            temp.path(),
732            None,
733            ProjectConfigOptions {
734                output: OutputFormat::Json,
735                no_cache: true,
736                threads: 1,
737                production_override: None,
738                quiet: true,
739                analysis: ProductionAnalysis::DeadCode,
740            },
741        )
742        .expect("project config loads");
743        let session = AnalysisSession::from_config(project_config);
744        let trace = crate::trace_chain::trace_symbol_chain_with_session(
745            &session,
746            fallow_types::trace_chain::SymbolChainQuery {
747                file: "src/util.ts",
748                symbol: "helper",
749                depth: 1,
750                directions: fallow_types::trace_chain::TraceDirections {
751                    callers: true,
752                    callees: false,
753                },
754            },
755        )
756        .expect("trace succeeds")
757        .expect("trace target exists");
758
759        assert!(trace.symbol_found);
760        assert_eq!(trace.file, Path::new("src/util.ts"));
761        assert!(trace.callers.is_some_and(|callers| {
762            callers
763                .iter()
764                .any(|caller| caller.file == Path::new("src/index.ts"))
765        }));
766    }
767}