1#![warn(missing_docs)]
10#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
11#![cfg_attr(
12 test,
13 allow(
14 clippy::unwrap_used,
15 clippy::expect_used,
16 reason = "tests use unwrap and expect to keep fixture setup concise"
17 )
18)]
19
20use std::fmt;
21#[cfg(test)]
22use std::path::Path;
23
24pub mod baseline;
27pub mod cache_status;
29pub mod changed_files;
30pub mod churn;
31pub mod clock;
32pub mod codeowners;
33mod core_backend;
34pub mod cross_reference;
35mod css;
36pub mod dead_code;
37pub mod discover;
38pub mod duplicates;
39mod feature_flags;
40pub mod flags;
41pub(crate) mod graph {
42 pub use fallow_graph::graph::*;
43}
44#[path = "git_env.rs"]
45mod git_env;
46pub mod guard;
47pub mod health;
48#[cfg(test)]
49pub(crate) mod extract {
50 pub use fallow_types::extract::*;
51}
52#[cfg(test)]
53pub(crate) mod analyze {
54 pub mod test_support {
55 use fallow_types::discover::FileId;
56 use fallow_types::extract::ModuleInfo;
57
58 pub fn empty_module() -> ModuleInfo {
59 ModuleInfo::empty(FileId(1))
60 }
61 }
62}
63pub mod list_inventory;
64pub mod module_graph;
65pub mod plugins;
66pub mod project_analysis;
67pub mod project_config;
68mod public_api;
69pub mod repo_refs;
70#[cfg(test)]
71pub(crate) mod resolve {
72 pub use fallow_graph::resolve::*;
73}
74mod results;
75mod security;
76pub mod session;
77pub mod similar_code;
78pub mod source;
79mod suppress;
80pub mod thread_pool;
81pub mod trace;
82pub mod trace_chain;
83pub mod trace_error;
84pub mod validate;
87pub mod vital_signs;
88pub mod viz;
89pub mod workspace_scope;
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 error::",
208 "pub use flags::",
209 "pub use git_env::",
210 "pub use public_api::",
211 "pub use results::",
212 "pub use security::",
213 "pub use suppress::",
214 "health_shared_parse_data_from_artifacts",
215 ];
216
217 for forbidden in forbidden_exports {
218 assert!(
219 !public_surface.contains(forbidden),
220 "engine root must expose typed modules, not `{forbidden}`"
221 );
222 }
223 }
224
225 #[test]
226 fn engine_session_owns_dead_code_pipeline_sequence() {
227 let session_source =
228 fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
229 .expect("read engine session");
230 assert!(
231 !session_source.contains("analyze_with_owned_parse_result_from_discovery"),
232 "engine session must not delegate dead-code orchestration to the old core monolith"
233 );
234 for required_phase in [
235 "prepare_dead_code_backend_prelude",
236 "discover_dead_code_entry_points",
237 "try_load_dead_code_graph_cache",
238 "resolve_dead_code_imports",
239 "build_dead_code_graph",
240 "run_dead_code_detectors",
241 ] {
242 assert!(
243 session_source.contains(required_phase),
244 "engine session must explicitly sequence `{required_phase}`"
245 );
246 }
247 }
248
249 #[test]
250 fn engine_session_owns_analysis_discovery() {
251 let session_source =
252 fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
253 .expect("read engine session");
254 assert!(
255 session_source.contains("crate::discover::prepare_analysis_discovery"),
256 "engine session must build discovery through the engine discovery boundary"
257 );
258 assert!(
259 session_source.contains("prepare_analysis_discovery_with_workspaces"),
260 "engine session must reuse workspace metadata captured during config load"
261 );
262 assert!(
263 session_source.contains("workspace_discovery_ms.is_some()"),
264 "AnalysisSession::from_config must only reuse workspace metadata when ProjectConfig preloaded it"
265 );
266 assert!(
267 !session_source.contains("core_backend::prepare_analysis_discovery"),
268 "engine session must not delegate discovery orchestration to core_backend"
269 );
270 }
271
272 #[test]
273 fn analysis_session_loads_config_and_discovered_files() {
274 let temp = tempfile::tempdir().expect("tempdir");
275 let src = temp.path().join("src");
276 std::fs::create_dir(&src).expect("src dir");
277 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
278
279 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
280
281 assert_eq!(session.root(), temp.path());
282 assert!(session.config_path().is_none());
283 assert!(session.files().iter().any(|file| {
284 file.path
285 .strip_prefix(temp.path())
286 .is_ok_and(|path| path == Path::new("src/index.ts"))
287 }));
288 }
289
290 #[test]
291 fn analysis_session_applies_config_adjustment_before_discovery() {
292 let temp = tempfile::tempdir().expect("tempdir");
293 let src = temp.path().join("src");
294 std::fs::create_dir(&src).expect("src dir");
295 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
296 std::fs::write(src.join("index.test.ts"), "export const testValue = 1;\n")
297 .expect("test source file");
298
299 let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
300 config.production = true;
301 })
302 .expect("session loads");
303
304 let relative_paths: Vec<_> = session
305 .files()
306 .iter()
307 .filter_map(|file| file.path.strip_prefix(temp.path()).ok())
308 .collect();
309 assert!(relative_paths.contains(&Path::new("src/index.ts")));
310 assert!(!relative_paths.contains(&Path::new("src/index.test.ts")));
311 }
312
313 #[test]
314 fn analysis_session_config_adjustment_invalidates_preloaded_workspaces() {
315 let temp = tempfile::tempdir().expect("tempdir");
316 std::fs::write(
317 temp.path().join("package.json"),
318 r#"{"name":"root","workspaces":["packages/*"]}"#,
319 )
320 .expect("root package");
321 std::fs::create_dir_all(temp.path().join("packages/a")).expect("workspace dir");
322 std::fs::create_dir_all(temp.path().join("packages/ignored")).expect("ignored dir");
323 std::fs::write(
324 temp.path().join("packages/a/package.json"),
325 r#"{"name":"a","main":"src/index.ts"}"#,
326 )
327 .expect("workspace package");
328
329 let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
330 config.ignore_patterns = globset::GlobSetBuilder::new()
331 .add(globset::Glob::new("packages/ignored").expect("ignore glob"))
332 .build()
333 .expect("ignore set");
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}