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