1use fallow_engine::session::AnalysisSession;
2use fallow_types::duplicates::DuplicationReport;
3use rustc_hash::FxHashSet;
4
5use crate::{
6 ProgrammaticAnalysisContext, ProgrammaticError, TraceCloneOptions,
7 TraceCloneProgrammaticOutput, TraceCloneTarget, TraceDependencyOptions,
8 TraceDependencyProgrammaticOutput, TraceErrorOptions, TraceErrorProgrammaticOutput,
9 TraceExportOptions, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOptions,
10 TraceFileProgrammaticOutput, TraceImportPathOptions, TraceImportPathProgrammaticOutput,
11};
12
13use super::{ProgrammaticResult, duplication, resolve_programmatic_analysis_context};
14
15struct TraceArtifacts {
16 graph: fallow_engine::module_graph::RetainedModuleGraph,
17 script_used_packages: FxHashSet<String>,
18 trace_provenance: fallow_engine::trace::TraceProvenance,
19 results: fallow_types::results::AnalysisResults,
22}
23
24pub fn run_trace_export(
31 options: &TraceExportOptions,
32) -> ProgrammaticResult<TraceExportProgrammaticOutput> {
33 validate_non_empty("file", &options.file)?;
34 validate_non_empty("export_name", &options.export_name)?;
35 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
36 resolved.install(|| {
37 let session = load_trace_session(&resolved)?;
38 let artifacts = trace_artifacts(&session)?;
39 let output = if let Some(export) = fallow_engine::trace::trace_export(
44 &artifacts.graph,
45 session.root(),
46 &options.file,
47 &options.export_name,
48 ) {
49 TraceExportTargetOutput::Export(export)
50 } else if let Some(member) = fallow_engine::trace::trace_class_member(
51 &artifacts.graph,
52 session.root(),
53 &options.file,
54 &options.export_name,
55 ) {
56 TraceExportTargetOutput::Member(member)
57 } else {
58 return Err(ProgrammaticError::new(
59 format!(
60 "export or member '{}' not found in '{}'",
61 options.export_name, options.file
62 ),
63 2,
64 )
65 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
66 .with_help(
67 "The name is neither a top-level export nor a class / enum / store member of this \
68 file. Run trace_file on the file to list its exports, or project_info for the \
69 project symbol set; confirm the file path is project-relative.",
70 )
71 .with_context("trace_export"));
72 };
73 Ok(TraceExportProgrammaticOutput { output })
74 })
75}
76
77pub fn run_trace_file(
84 options: &TraceFileOptions,
85) -> ProgrammaticResult<TraceFileProgrammaticOutput> {
86 validate_non_empty("file", &options.file)?;
87 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
88 resolved.install(|| {
89 let session = load_trace_session(&resolved)?;
90 let artifacts = trace_artifacts(&session)?;
91 let mut output =
92 fallow_engine::trace::trace_file(&artifacts.graph, session.root(), &options.file)
93 .ok_or_else(|| {
94 ProgrammaticError::new(
95 format!("file '{}' not found in module graph", options.file),
96 2,
97 )
98 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
99 .with_help(
100 "The file is not in the analyzed module graph. Run project_info to list \
101 discovered files; the path must be project-relative and not excluded by \
102 ignore patterns or outside the analyzed roots.",
103 )
104 .with_context("trace_file")
105 })?;
106 output.sources = artifacts.trace_provenance.file_sources(&output.file);
107 Ok(TraceFileProgrammaticOutput { output })
108 })
109}
110
111pub fn run_trace_import_path(
121 options: &TraceImportPathOptions,
122) -> ProgrammaticResult<TraceImportPathProgrammaticOutput> {
123 validate_non_empty("from", &options.from)?;
124 validate_non_empty("to", &options.to)?;
125 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
126 resolved.install(|| {
127 let session = load_trace_session(&resolved)?;
128 let artifacts = trace_artifacts(&session)?;
129 let trace = if options.eager_only {
130 fallow_engine::trace::trace_eager_import_path
131 } else {
132 fallow_engine::trace::trace_import_path
133 };
134 let output = trace(
135 &artifacts.graph,
136 session.root(),
137 &options.from,
138 &options.to,
139 )
140 .map_err(|endpoint| {
141 let label = endpoint.label();
142 let value = match endpoint {
143 fallow_engine::trace::ImportPathEndpoint::From
144 | fallow_engine::trace::ImportPathEndpoint::AmbiguousFrom => &options.from,
145 fallow_engine::trace::ImportPathEndpoint::To
146 | fallow_engine::trace::ImportPathEndpoint::AmbiguousTo => &options.to,
147 };
148 if endpoint.is_ambiguous() {
149 return ProgrammaticError::new(format!("'{value}' ({label}) matches multiple modules"), 2)
150 .with_code("FALLOW_TRACE_TARGET_AMBIGUOUS")
151 .with_help("Use the full project-relative path; run project_info to list discovered files.")
152 .with_context("trace_import_path");
153 }
154 ProgrammaticError::new(format!("'{value}' ({label}) not found in module graph"), 2)
155 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
156 .with_help(
157 "The module is not in the analyzed module graph. Run project_info to list \
158 discovered files; both paths must be project-relative and not excluded by \
159 ignore patterns or outside the analyzed roots.",
160 )
161 .with_context("trace_import_path")
162 })?;
163 Ok(TraceImportPathProgrammaticOutput { output })
164 })
165}
166
167pub fn run_trace_error(
179 options: &TraceErrorOptions,
180) -> ProgrammaticResult<TraceErrorProgrammaticOutput> {
181 if options.trace.trim().is_empty() {
182 return Err(ProgrammaticError::new("trace must not be empty", 2)
186 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
187 .with_help(
188 "Paste the stack trace text into `trace`, as your runtime printed it. \
189 trace_error resolves frames against the project graph, so it has nothing \
190 to resolve without them.",
191 )
192 .with_context("trace_error"));
193 }
194 if options.trace.len() as u64 > fallow_engine::trace_error::MAX_STACK_TRACE_BYTES {
195 let limit = fallow_engine::trace_error::MAX_STACK_TRACE_BYTES;
196 return Err(ProgrammaticError::new(
197 format!("stack trace exceeds the {limit}-byte limit"),
198 2,
199 )
200 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
201 .with_help(
202 "A stack trace is a handful of kilobytes. Pass the trace itself rather than a \
203 redirected log file, and cut it to the frames that matter.",
204 )
205 .with_context("trace_error"));
206 }
207 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
208 resolved.install(|| {
209 let session = load_trace_session(&resolved)?;
210 let output = fallow_engine::trace_error::trace_error_with_session(
211 &session,
212 &options.trace,
213 options.source.clone(),
214 )
215 .map_err(|err| {
216 ProgrammaticError::new(format!("stack-trace resolution failed: {err}"), 2)
217 .with_code("FALLOW_ANALYSIS_FAILED")
218 .with_context("trace_error")
219 })?;
220 Ok(TraceErrorProgrammaticOutput { output })
221 })
222}
223
224pub fn run_trace_dependency(
231 options: &TraceDependencyOptions,
232) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
233 validate_non_empty("package_name", &options.package_name)?;
234 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
235 resolved.install(|| {
236 let session = load_trace_session(&resolved)?;
237 let artifacts = trace_artifacts(&session)?;
238 let workspace_roots: Vec<&std::path::Path> = session
239 .workspaces()
240 .iter()
241 .map(|ws| ws.root.as_path())
242 .collect();
243 let mut output = fallow_engine::trace::trace_dependency(
244 &artifacts.graph,
245 session.root(),
246 &workspace_roots,
247 &session.config().ignore_patterns,
248 &options.package_name,
249 &artifacts.script_used_packages,
250 );
251 output.sources = artifacts
252 .trace_provenance
253 .dependency_sources(&options.package_name);
254 output.apply_tooling_credit(
255 artifacts
256 .trace_provenance
257 .tooling_credit(&options.package_name),
258 );
259 output.apply_unused_declarations(&artifacts.results, session.root());
260 Ok(TraceDependencyProgrammaticOutput { output })
261 })
262}
263
264pub fn run_trace_clone(
271 options: &TraceCloneOptions,
272) -> ProgrammaticResult<TraceCloneProgrammaticOutput> {
273 validate_trace_clone_target(&options.target)?;
274 let resolved = resolve_programmatic_analysis_context(&options.duplication.analysis)?;
275 resolved.install(|| {
276 resolved.ensure_not_cancelled("config load and file discovery")?;
277 let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
278 resolved.ensure_not_cancelled("duplication detection")?;
279 let dupes_config =
280 duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
281 let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
282 let report = session
283 .find_duplicates_with_defaults(&dupes_config, cache_dir)
284 .report;
285 resolved.ensure_not_cancelled("the clone trace")?;
288 let (trace, not_found) = match &options.target {
289 TraceCloneTarget::Location { file, line } => (
290 fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
291 format!("no clone found at {file}:{line}"),
292 ),
293 TraceCloneTarget::Fingerprint(fingerprint) => (
294 fallow_engine::trace::trace_clone_by_fingerprint(
295 &report,
296 session.root(),
297 fingerprint,
298 ),
299 format!("no clone group with fingerprint {fingerprint}"),
300 ),
301 };
302 if trace.matched_instance.is_none() {
303 return Err(ProgrammaticError::new(not_found, 2)
304 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
305 .with_help(
306 "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
307 a location must fall inside a reported clone instance, and a fingerprint must \
308 be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
309 )
310 .with_context("trace_clone"));
311 }
312 Ok(TraceCloneProgrammaticOutput { output: trace })
313 })
314}
315
316#[doc(hidden)]
324#[allow(
325 clippy::implicit_hasher,
326 reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
327)]
328pub fn benchmark_trace_graph_family_compact_json(
329 graph: &fallow_engine::module_graph::RetainedModuleGraph,
330 root: &std::path::Path,
331 script_used_packages: &FxHashSet<String>,
332) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
333 let export =
334 fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
335 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
336 let export_reference_count = export.direct_references.len();
337 let export_json =
338 crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
339 output: TraceExportTargetOutput::Export(export),
340 })?;
341
342 let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
343 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
344 let file_export_count = file.exports.len();
345 let file_imported_by_count = file.imported_by.len();
346 let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
347 output: file,
348 })?;
349
350 let dependency = fallow_engine::trace::trace_dependency(
351 graph,
352 root,
353 &[],
354 &fallow_config::IgnorePatternSet::empty(),
355 "trace-package",
356 script_used_packages,
357 );
358 let dependency_import_count = dependency.import_count;
359 let dependency_json =
360 crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
361 output: dependency,
362 })?;
363
364 let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
365 Ok((
366 export_reference_count,
367 file_export_count,
368 file_imported_by_count,
369 dependency_import_count,
370 rendered_bytes,
371 ))
372}
373
374#[doc(hidden)]
376#[derive(Debug, PartialEq, Eq)]
377pub struct TraceCloneBenchmarkResult {
378 pub location_file: std::path::PathBuf,
380 pub location_line: usize,
382 pub location_fingerprint: String,
384 pub fingerprint_fingerprint: String,
386 pub location_group_count: usize,
388 pub fingerprint_group_count: usize,
390 pub location_instance_count: usize,
392 pub fingerprint_instance_count: usize,
394 pub rendered_bytes: usize,
396}
397
398#[doc(hidden)]
405pub fn benchmark_trace_clone_compact_json(
406 report: &DuplicationReport,
407 root: &std::path::Path,
408 file: &str,
409 line: usize,
410 fingerprint: &str,
411) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
412 let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
413 let matched_location = location_trace
414 .matched_instance
415 .as_ref()
416 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
417 let location_file = matched_location.file.clone();
418 let location_line = matched_location.start_line;
419 let location_fingerprint = location_trace
420 .clone_groups
421 .first()
422 .map(|group| group.fingerprint.clone())
423 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
424 let location_group_count = location_trace.clone_groups.len();
425 let location_instance_count = location_trace
426 .clone_groups
427 .iter()
428 .map(|group| group.instances.len())
429 .sum();
430 let location_json =
431 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
432 output: location_trace,
433 })?;
434
435 let fingerprint_trace =
436 fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
437 if fingerprint_trace.matched_instance.is_none() {
438 return Err(benchmark_trace_target_missing(fingerprint));
439 }
440 let fingerprint_fingerprint = fingerprint_trace
441 .clone_groups
442 .first()
443 .map(|group| group.fingerprint.clone())
444 .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
445 let fingerprint_group_count = fingerprint_trace.clone_groups.len();
446 let fingerprint_instance_count = fingerprint_trace
447 .clone_groups
448 .iter()
449 .map(|group| group.instances.len())
450 .sum();
451 let fingerprint_json =
452 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
453 output: fingerprint_trace,
454 })?;
455
456 let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
457 Ok(TraceCloneBenchmarkResult {
458 location_file,
459 location_line,
460 location_fingerprint,
461 fingerprint_fingerprint,
462 location_group_count,
463 fingerprint_group_count,
464 location_instance_count,
465 fingerprint_instance_count,
466 rendered_bytes,
467 })
468}
469
470fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
471 serde_json::to_vec(values)
472 .map(|json| json.len())
473 .map_err(|err| {
474 ProgrammaticError::new(
475 format!("failed to serialize benchmark trace JSON: {err}"),
476 2,
477 )
478 .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
479 .with_context("benchmark_trace")
480 })
481}
482
483fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
484 ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
485 .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
486 .with_context("benchmark_trace")
487}
488
489fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
490 if value.trim().is_empty() {
491 return Err(
492 ProgrammaticError::new(format!("{field} must not be empty"), 2)
493 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
494 .with_context(field.to_string()),
495 );
496 }
497 Ok(())
498}
499
500fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
501 match target {
502 TraceCloneTarget::Location { file, line } => {
503 validate_non_empty("file", file)?;
504 if *line == 0 {
505 return Err(ProgrammaticError::new("line must be greater than 0", 2)
506 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
507 .with_context("trace_clone.line"));
508 }
509 }
510 TraceCloneTarget::Fingerprint(fingerprint) => {
511 validate_non_empty("fingerprint", fingerprint)?;
512 }
513 }
514 Ok(())
515}
516
517fn load_trace_session(
518 resolved: &ProgrammaticAnalysisContext,
519) -> ProgrammaticResult<AnalysisSession> {
520 super::dead_code::load_dead_code_session(
521 &super::dead_code::default_dead_code_options_for_context(resolved),
522 resolved,
523 )
524}
525
526fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
527 let artifacts = session
528 .analyze_dead_code_with_session_artifacts(false, true, None)
529 .map_err(|err| {
530 super::dead_code::map_engine_error(
531 &err,
532 "trace analysis failed",
533 "FALLOW_TRACE_FAILED",
534 "trace",
535 )
536 })?;
537 let graph = artifacts.analysis.graph.ok_or_else(|| {
538 ProgrammaticError::new("trace requires a retained module graph", 2)
539 .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
540 .with_context("trace.graph")
541 })?;
542 Ok(TraceArtifacts {
543 graph,
544 script_used_packages: artifacts.analysis.script_used_packages,
545 trace_provenance: artifacts.analysis.trace_provenance,
546 results: artifacts.analysis.results,
547 })
548}
549
550#[cfg(test)]
551mod benchmark_tests {
552 use std::fmt::Write as _;
553 use std::fs;
554
555 use fallow_engine::duplicates::CloneFingerprintSet;
556
557 use super::*;
558
559 const FIXTURE_SIZE: usize = 4;
560
561 fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
562 let path = root.join(path);
563 fs::create_dir_all(path.parent().expect("fixture file has parent"))
564 .expect("fixture directory is created");
565 fs::write(path, source.as_ref()).expect("fixture file is written");
566 }
567
568 #[test]
569 fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
570 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
571 let root = temp_dir.path().to_path_buf();
572 write_file(
573 &root,
574 "package.json",
575 r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
576 );
577 write_file(
578 &root,
579 "src/000-shared.ts",
580 "export const sharedValue = 42;\n",
581 );
582
583 let mut index_source = String::new();
584 for index in 0..FIXTURE_SIZE {
585 write_file(
586 &root,
587 &format!("src/consumer{index}.ts"),
588 format!(
589 "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
590 ),
591 );
592 writeln!(
593 index_source,
594 "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
595 )
596 .expect("index source is built");
597 }
598 write_file(&root, "src/index.ts", index_source);
599
600 let session = AnalysisSession::load(&root, None).expect("trace session loads");
601 let target = session
602 .files()
603 .iter()
604 .find(|file| file.path.ends_with("src/000-shared.ts"))
605 .expect("trace target is discovered");
606 assert_eq!(
607 target.id.0, 0,
608 "the retained trace target must precede every non-matching importer"
609 );
610 let trace_root = session.root().to_path_buf();
611 let artifacts = session
612 .analyze_dead_code_with_artifacts(false, true)
613 .expect("trace graph analysis succeeds");
614 drop(session);
615 temp_dir.close().expect("temporary project is removed");
616
617 let result = benchmark_trace_graph_family_compact_json(
618 artifacts.graph.as_ref().expect("trace graph is retained"),
619 &trace_root,
620 &artifacts.script_used_packages,
621 )
622 .expect("retained trace graph serializes without project IO");
623 assert_eq!(result.0, FIXTURE_SIZE);
624 assert_eq!(result.1, 1);
625 assert_eq!(result.2, FIXTURE_SIZE);
626 assert_eq!(result.3, FIXTURE_SIZE);
627 assert!(result.4 > 0);
628 }
629
630 #[test]
631 fn clone_benchmark_boundary_uses_only_the_retained_report() {
632 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
633 let root = temp_dir.path().to_path_buf();
634 write_file(
635 &root,
636 "package.json",
637 r#"{"name":"trace-clone-test","type":"module"}"#,
638 );
639 for index in 0..FIXTURE_SIZE {
640 write_file(
641 &root,
642 &format!("src/clone{index}.ts"),
643 format!(
644 "export function normalizeRecords(records: Array<{{ active: boolean; value: number }}>) {{\n const active = records.filter((record) => record.active);\n const values = active.map((record) => record.value);\n const total = values.reduce((sum, value) => sum + value, 0);\n const average = values.length === 0 ? 0 : total / values.length;\n const maximum = values.reduce((current, value) => Math.max(current, value), 0);\n return {{ total, average, maximum, count: values.length }};\n}}\n\nexport const cloneId = {index};\n"
645 ),
646 );
647 }
648
649 let session = AnalysisSession::load(&root, None).expect("clone session loads");
650 let trace_root = session.root().to_path_buf();
651 let mut config = session.config().duplicates.clone();
652 config.min_tokens = 35;
653 config.min_lines = 5;
654 config.min_occurrences = FIXTURE_SIZE;
655 let report = session.find_duplicates_with_defaults(&config, None).report;
656 let group = report
657 .clone_groups
658 .iter()
659 .max_by_key(|group| group.instances.len())
660 .expect("clone group exists");
661 let target = group.instances.last().expect("clone instance exists");
662 let target_file = target
663 .file
664 .strip_prefix(&trace_root)
665 .expect("clone path is project-relative")
666 .to_string_lossy()
667 .replace('\\', "/");
668 let target_line = target.start_line;
669 let expected_fingerprint =
670 CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
671 drop(session);
672 temp_dir.close().expect("temporary project is removed");
673
674 let result = benchmark_trace_clone_compact_json(
675 &report,
676 &trace_root,
677 &target_file,
678 target_line,
679 &expected_fingerprint,
680 )
681 .expect("retained clone report serializes without project IO");
682 assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
683 assert_eq!(result.location_line, target_line);
684 assert_eq!(result.location_fingerprint, expected_fingerprint);
685 assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
686 assert_eq!(result.location_group_count, 1);
687 assert_eq!(result.fingerprint_group_count, 1);
688 assert_eq!(result.location_instance_count, FIXTURE_SIZE);
689 assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
690 assert!(result.rendered_bytes > 0);
691 }
692}