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