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