1#![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
58pub type EngineResult<T> = Result<T, EngineError>;
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct EngineError {
64 message: String,
65}
66
67impl EngineError {
68 #[must_use]
70 pub fn new(message: impl Into<String>) -> Self {
71 Self {
72 message: message.into(),
73 }
74 }
75
76 #[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}