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