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