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