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, TraceExportOptions, TraceExportProgrammaticOutput,
9 TraceExportTargetOutput, TraceFileOptions, TraceFileProgrammaticOutput,
10};
11
12use super::{ProgrammaticResult, duplication, resolve_programmatic_analysis_context};
13
14struct TraceArtifacts {
15 graph: fallow_engine::module_graph::RetainedModuleGraph,
16 script_used_packages: FxHashSet<String>,
17}
18
19pub fn run_trace_export(
26 options: &TraceExportOptions,
27) -> ProgrammaticResult<TraceExportProgrammaticOutput> {
28 validate_non_empty("file", &options.file)?;
29 validate_non_empty("export_name", &options.export_name)?;
30 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
31 resolved.install(|| {
32 let session = load_trace_session(&resolved)?;
33 let artifacts = trace_artifacts(&session)?;
34 let output = if let Some(export) = fallow_engine::trace::trace_export(
39 &artifacts.graph,
40 session.root(),
41 &options.file,
42 &options.export_name,
43 ) {
44 TraceExportTargetOutput::Export(export)
45 } else if let Some(member) = fallow_engine::trace::trace_class_member(
46 &artifacts.graph,
47 session.root(),
48 &options.file,
49 &options.export_name,
50 ) {
51 TraceExportTargetOutput::Member(member)
52 } else {
53 return Err(ProgrammaticError::new(
54 format!(
55 "export or member '{}' not found in '{}'",
56 options.export_name, options.file
57 ),
58 2,
59 )
60 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
61 .with_help(
62 "The name is neither a top-level export nor a class / enum / store member of this \
63 file. Run trace_file on the file to list its exports, or project_info for the \
64 project symbol set; confirm the file path is project-relative.",
65 )
66 .with_context("trace_export"));
67 };
68 Ok(TraceExportProgrammaticOutput { output })
69 })
70}
71
72pub fn run_trace_file(
79 options: &TraceFileOptions,
80) -> ProgrammaticResult<TraceFileProgrammaticOutput> {
81 validate_non_empty("file", &options.file)?;
82 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
83 resolved.install(|| {
84 let session = load_trace_session(&resolved)?;
85 let artifacts = trace_artifacts(&session)?;
86 let output =
87 fallow_engine::trace::trace_file(&artifacts.graph, session.root(), &options.file)
88 .ok_or_else(|| {
89 ProgrammaticError::new(
90 format!("file '{}' not found in module graph", options.file),
91 2,
92 )
93 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
94 .with_help(
95 "The file is not in the analyzed module graph. Run project_info to list \
96 discovered files; the path must be project-relative and not excluded by \
97 ignore patterns or outside the analyzed roots.",
98 )
99 .with_context("trace_file")
100 })?;
101 Ok(TraceFileProgrammaticOutput { output })
102 })
103}
104
105pub fn run_trace_dependency(
112 options: &TraceDependencyOptions,
113) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
114 validate_non_empty("package_name", &options.package_name)?;
115 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
116 resolved.install(|| {
117 let session = load_trace_session(&resolved)?;
118 let artifacts = trace_artifacts(&session)?;
119 let output = fallow_engine::trace::trace_dependency(
120 &artifacts.graph,
121 session.root(),
122 &options.package_name,
123 &artifacts.script_used_packages,
124 );
125 Ok(TraceDependencyProgrammaticOutput { output })
126 })
127}
128
129pub fn run_trace_clone(
136 options: &TraceCloneOptions,
137) -> ProgrammaticResult<TraceCloneProgrammaticOutput> {
138 validate_trace_clone_target(&options.target)?;
139 let resolved = resolve_programmatic_analysis_context(&options.duplication.analysis)?;
140 resolved.install(|| {
141 let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
142 let dupes_config =
143 duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
144 let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
145 let report = session
146 .find_duplicates_with_defaults(&dupes_config, cache_dir)
147 .report;
148 let (trace, not_found) = match &options.target {
149 TraceCloneTarget::Location { file, line } => (
150 fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
151 format!("no clone found at {file}:{line}"),
152 ),
153 TraceCloneTarget::Fingerprint(fingerprint) => (
154 fallow_engine::trace::trace_clone_by_fingerprint(
155 &report,
156 session.root(),
157 fingerprint,
158 ),
159 format!("no clone group with fingerprint {fingerprint}"),
160 ),
161 };
162 if trace.matched_instance.is_none() {
163 return Err(ProgrammaticError::new(not_found, 2)
164 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
165 .with_help(
166 "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
167 a location must fall inside a reported clone instance, and a fingerprint must \
168 be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
169 )
170 .with_context("trace_clone"));
171 }
172 Ok(TraceCloneProgrammaticOutput { output: trace })
173 })
174}
175
176#[doc(hidden)]
184#[allow(
185 clippy::implicit_hasher,
186 reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
187)]
188pub fn benchmark_trace_graph_family_compact_json(
189 graph: &fallow_engine::module_graph::RetainedModuleGraph,
190 root: &std::path::Path,
191 script_used_packages: &FxHashSet<String>,
192) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
193 let export =
194 fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
195 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
196 let export_reference_count = export.direct_references.len();
197 let export_json =
198 crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
199 output: TraceExportTargetOutput::Export(export),
200 })?;
201
202 let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
203 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
204 let file_export_count = file.exports.len();
205 let file_imported_by_count = file.imported_by.len();
206 let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
207 output: file,
208 })?;
209
210 let dependency =
211 fallow_engine::trace::trace_dependency(graph, root, "trace-package", script_used_packages);
212 let dependency_import_count = dependency.import_count;
213 let dependency_json =
214 crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
215 output: dependency,
216 })?;
217
218 let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
219 Ok((
220 export_reference_count,
221 file_export_count,
222 file_imported_by_count,
223 dependency_import_count,
224 rendered_bytes,
225 ))
226}
227
228#[doc(hidden)]
230#[derive(Debug, PartialEq, Eq)]
231pub struct TraceCloneBenchmarkResult {
232 pub location_file: std::path::PathBuf,
234 pub location_line: usize,
236 pub location_fingerprint: String,
238 pub fingerprint_fingerprint: String,
240 pub location_group_count: usize,
242 pub fingerprint_group_count: usize,
244 pub location_instance_count: usize,
246 pub fingerprint_instance_count: usize,
248 pub rendered_bytes: usize,
250}
251
252#[doc(hidden)]
259pub fn benchmark_trace_clone_compact_json(
260 report: &DuplicationReport,
261 root: &std::path::Path,
262 file: &str,
263 line: usize,
264 fingerprint: &str,
265) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
266 let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
267 let matched_location = location_trace
268 .matched_instance
269 .as_ref()
270 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
271 let location_file = matched_location.file.clone();
272 let location_line = matched_location.start_line;
273 let location_fingerprint = location_trace
274 .clone_groups
275 .first()
276 .map(|group| group.fingerprint.clone())
277 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
278 let location_group_count = location_trace.clone_groups.len();
279 let location_instance_count = location_trace
280 .clone_groups
281 .iter()
282 .map(|group| group.instances.len())
283 .sum();
284 let location_json =
285 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
286 output: location_trace,
287 })?;
288
289 let fingerprint_trace =
290 fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
291 if fingerprint_trace.matched_instance.is_none() {
292 return Err(benchmark_trace_target_missing(fingerprint));
293 }
294 let fingerprint_fingerprint = fingerprint_trace
295 .clone_groups
296 .first()
297 .map(|group| group.fingerprint.clone())
298 .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
299 let fingerprint_group_count = fingerprint_trace.clone_groups.len();
300 let fingerprint_instance_count = fingerprint_trace
301 .clone_groups
302 .iter()
303 .map(|group| group.instances.len())
304 .sum();
305 let fingerprint_json =
306 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
307 output: fingerprint_trace,
308 })?;
309
310 let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
311 Ok(TraceCloneBenchmarkResult {
312 location_file,
313 location_line,
314 location_fingerprint,
315 fingerprint_fingerprint,
316 location_group_count,
317 fingerprint_group_count,
318 location_instance_count,
319 fingerprint_instance_count,
320 rendered_bytes,
321 })
322}
323
324fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
325 serde_json::to_vec(values)
326 .map(|json| json.len())
327 .map_err(|err| {
328 ProgrammaticError::new(
329 format!("failed to serialize benchmark trace JSON: {err}"),
330 2,
331 )
332 .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
333 .with_context("benchmark_trace")
334 })
335}
336
337fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
338 ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
339 .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
340 .with_context("benchmark_trace")
341}
342
343fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
344 if value.trim().is_empty() {
345 return Err(
346 ProgrammaticError::new(format!("{field} must not be empty"), 2)
347 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
348 .with_context(field.to_string()),
349 );
350 }
351 Ok(())
352}
353
354fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
355 match target {
356 TraceCloneTarget::Location { file, line } => {
357 validate_non_empty("file", file)?;
358 if *line == 0 {
359 return Err(ProgrammaticError::new("line must be greater than 0", 2)
360 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
361 .with_context("trace_clone.line"));
362 }
363 }
364 TraceCloneTarget::Fingerprint(fingerprint) => {
365 validate_non_empty("fingerprint", fingerprint)?;
366 }
367 }
368 Ok(())
369}
370
371fn load_trace_session(
372 resolved: &ProgrammaticAnalysisContext,
373) -> ProgrammaticResult<AnalysisSession> {
374 super::dead_code::load_dead_code_session(
375 &super::dead_code::default_dead_code_options_for_context(resolved),
376 resolved,
377 )
378}
379
380fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
381 let artifacts = session
382 .analyze_dead_code_with_session_artifacts(false, true, None)
383 .map_err(|err| {
384 ProgrammaticError::new(format!("trace analysis failed: {err}"), 2)
385 .with_code("FALLOW_TRACE_FAILED")
386 .with_context("trace")
387 })?;
388 let graph = artifacts.analysis.graph.ok_or_else(|| {
389 ProgrammaticError::new("trace requires a retained module graph", 2)
390 .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
391 .with_context("trace.graph")
392 })?;
393 Ok(TraceArtifacts {
394 graph,
395 script_used_packages: artifacts.analysis.script_used_packages,
396 })
397}
398
399#[cfg(test)]
400mod benchmark_tests {
401 use std::fmt::Write as _;
402 use std::fs;
403
404 use fallow_engine::duplicates::CloneFingerprintSet;
405
406 use super::*;
407
408 const FIXTURE_SIZE: usize = 4;
409
410 fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
411 let path = root.join(path);
412 fs::create_dir_all(path.parent().expect("fixture file has parent"))
413 .expect("fixture directory is created");
414 fs::write(path, source.as_ref()).expect("fixture file is written");
415 }
416
417 #[test]
418 fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
419 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
420 let root = temp_dir.path().to_path_buf();
421 write_file(
422 &root,
423 "package.json",
424 r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
425 );
426 write_file(
427 &root,
428 "src/000-shared.ts",
429 "export const sharedValue = 42;\n",
430 );
431
432 let mut index_source = String::new();
433 for index in 0..FIXTURE_SIZE {
434 write_file(
435 &root,
436 &format!("src/consumer{index}.ts"),
437 format!(
438 "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
439 ),
440 );
441 writeln!(
442 index_source,
443 "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
444 )
445 .expect("index source is built");
446 }
447 write_file(&root, "src/index.ts", index_source);
448
449 let session = AnalysisSession::load(&root, None).expect("trace session loads");
450 let target = session
451 .files()
452 .iter()
453 .find(|file| file.path.ends_with("src/000-shared.ts"))
454 .expect("trace target is discovered");
455 assert_eq!(
456 target.id.0, 0,
457 "the retained trace target must precede every non-matching importer"
458 );
459 let trace_root = session.root().to_path_buf();
460 let artifacts = session
461 .analyze_dead_code_with_artifacts(false, true)
462 .expect("trace graph analysis succeeds");
463 drop(session);
464 temp_dir.close().expect("temporary project is removed");
465
466 let result = benchmark_trace_graph_family_compact_json(
467 artifacts.graph.as_ref().expect("trace graph is retained"),
468 &trace_root,
469 &artifacts.script_used_packages,
470 )
471 .expect("retained trace graph serializes without project IO");
472 assert_eq!(result.0, FIXTURE_SIZE);
473 assert_eq!(result.1, 1);
474 assert_eq!(result.2, FIXTURE_SIZE);
475 assert_eq!(result.3, FIXTURE_SIZE);
476 assert!(result.4 > 0);
477 }
478
479 #[test]
480 fn clone_benchmark_boundary_uses_only_the_retained_report() {
481 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
482 let root = temp_dir.path().to_path_buf();
483 write_file(
484 &root,
485 "package.json",
486 r#"{"name":"trace-clone-test","type":"module"}"#,
487 );
488 for index in 0..FIXTURE_SIZE {
489 write_file(
490 &root,
491 &format!("src/clone{index}.ts"),
492 format!(
493 "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"
494 ),
495 );
496 }
497
498 let session = AnalysisSession::load(&root, None).expect("clone session loads");
499 let trace_root = session.root().to_path_buf();
500 let mut config = session.config().duplicates.clone();
501 config.min_tokens = 35;
502 config.min_lines = 5;
503 config.min_occurrences = FIXTURE_SIZE;
504 let report = session.find_duplicates_with_defaults(&config, None).report;
505 let group = report
506 .clone_groups
507 .iter()
508 .max_by_key(|group| group.instances.len())
509 .expect("clone group exists");
510 let target = group.instances.last().expect("clone instance exists");
511 let target_file = target
512 .file
513 .strip_prefix(&trace_root)
514 .expect("clone path is project-relative")
515 .to_string_lossy()
516 .replace('\\', "/");
517 let target_line = target.start_line;
518 let expected_fingerprint =
519 CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
520 drop(session);
521 temp_dir.close().expect("temporary project is removed");
522
523 let result = benchmark_trace_clone_compact_json(
524 &report,
525 &trace_root,
526 &target_file,
527 target_line,
528 &expected_fingerprint,
529 )
530 .expect("retained clone report serializes without project IO");
531 assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
532 assert_eq!(result.location_line, target_line);
533 assert_eq!(result.location_fingerprint, expected_fingerprint);
534 assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
535 assert_eq!(result.location_group_count, 1);
536 assert_eq!(result.fingerprint_group_count, 1);
537 assert_eq!(result.location_instance_count, FIXTURE_SIZE);
538 assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
539 assert!(result.rendered_bytes > 0);
540 }
541}