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