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