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