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