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