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