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