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