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