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