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 resolved.ensure_not_cancelled("config load and file discovery")?;
142 let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
143 resolved.ensure_not_cancelled("duplication detection")?;
144 let dupes_config =
145 duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
146 let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
147 let report = session
148 .find_duplicates_with_defaults(&dupes_config, cache_dir)
149 .report;
150 resolved.ensure_not_cancelled("the clone trace")?;
153 let (trace, not_found) = match &options.target {
154 TraceCloneTarget::Location { file, line } => (
155 fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
156 format!("no clone found at {file}:{line}"),
157 ),
158 TraceCloneTarget::Fingerprint(fingerprint) => (
159 fallow_engine::trace::trace_clone_by_fingerprint(
160 &report,
161 session.root(),
162 fingerprint,
163 ),
164 format!("no clone group with fingerprint {fingerprint}"),
165 ),
166 };
167 if trace.matched_instance.is_none() {
168 return Err(ProgrammaticError::new(not_found, 2)
169 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
170 .with_help(
171 "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
172 a location must fall inside a reported clone instance, and a fingerprint must \
173 be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
174 )
175 .with_context("trace_clone"));
176 }
177 Ok(TraceCloneProgrammaticOutput { output: trace })
178 })
179}
180
181#[doc(hidden)]
189#[allow(
190 clippy::implicit_hasher,
191 reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
192)]
193pub fn benchmark_trace_graph_family_compact_json(
194 graph: &fallow_engine::module_graph::RetainedModuleGraph,
195 root: &std::path::Path,
196 script_used_packages: &FxHashSet<String>,
197) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
198 let export =
199 fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
200 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
201 let export_reference_count = export.direct_references.len();
202 let export_json =
203 crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
204 output: TraceExportTargetOutput::Export(export),
205 })?;
206
207 let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
208 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
209 let file_export_count = file.exports.len();
210 let file_imported_by_count = file.imported_by.len();
211 let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
212 output: file,
213 })?;
214
215 let dependency =
216 fallow_engine::trace::trace_dependency(graph, root, "trace-package", script_used_packages);
217 let dependency_import_count = dependency.import_count;
218 let dependency_json =
219 crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
220 output: dependency,
221 })?;
222
223 let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
224 Ok((
225 export_reference_count,
226 file_export_count,
227 file_imported_by_count,
228 dependency_import_count,
229 rendered_bytes,
230 ))
231}
232
233#[doc(hidden)]
235#[derive(Debug, PartialEq, Eq)]
236pub struct TraceCloneBenchmarkResult {
237 pub location_file: std::path::PathBuf,
239 pub location_line: usize,
241 pub location_fingerprint: String,
243 pub fingerprint_fingerprint: String,
245 pub location_group_count: usize,
247 pub fingerprint_group_count: usize,
249 pub location_instance_count: usize,
251 pub fingerprint_instance_count: usize,
253 pub rendered_bytes: usize,
255}
256
257#[doc(hidden)]
264pub fn benchmark_trace_clone_compact_json(
265 report: &DuplicationReport,
266 root: &std::path::Path,
267 file: &str,
268 line: usize,
269 fingerprint: &str,
270) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
271 let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
272 let matched_location = location_trace
273 .matched_instance
274 .as_ref()
275 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
276 let location_file = matched_location.file.clone();
277 let location_line = matched_location.start_line;
278 let location_fingerprint = location_trace
279 .clone_groups
280 .first()
281 .map(|group| group.fingerprint.clone())
282 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
283 let location_group_count = location_trace.clone_groups.len();
284 let location_instance_count = location_trace
285 .clone_groups
286 .iter()
287 .map(|group| group.instances.len())
288 .sum();
289 let location_json =
290 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
291 output: location_trace,
292 })?;
293
294 let fingerprint_trace =
295 fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
296 if fingerprint_trace.matched_instance.is_none() {
297 return Err(benchmark_trace_target_missing(fingerprint));
298 }
299 let fingerprint_fingerprint = fingerprint_trace
300 .clone_groups
301 .first()
302 .map(|group| group.fingerprint.clone())
303 .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
304 let fingerprint_group_count = fingerprint_trace.clone_groups.len();
305 let fingerprint_instance_count = fingerprint_trace
306 .clone_groups
307 .iter()
308 .map(|group| group.instances.len())
309 .sum();
310 let fingerprint_json =
311 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
312 output: fingerprint_trace,
313 })?;
314
315 let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
316 Ok(TraceCloneBenchmarkResult {
317 location_file,
318 location_line,
319 location_fingerprint,
320 fingerprint_fingerprint,
321 location_group_count,
322 fingerprint_group_count,
323 location_instance_count,
324 fingerprint_instance_count,
325 rendered_bytes,
326 })
327}
328
329fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
330 serde_json::to_vec(values)
331 .map(|json| json.len())
332 .map_err(|err| {
333 ProgrammaticError::new(
334 format!("failed to serialize benchmark trace JSON: {err}"),
335 2,
336 )
337 .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
338 .with_context("benchmark_trace")
339 })
340}
341
342fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
343 ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
344 .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
345 .with_context("benchmark_trace")
346}
347
348fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
349 if value.trim().is_empty() {
350 return Err(
351 ProgrammaticError::new(format!("{field} must not be empty"), 2)
352 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
353 .with_context(field.to_string()),
354 );
355 }
356 Ok(())
357}
358
359fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
360 match target {
361 TraceCloneTarget::Location { file, line } => {
362 validate_non_empty("file", file)?;
363 if *line == 0 {
364 return Err(ProgrammaticError::new("line must be greater than 0", 2)
365 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
366 .with_context("trace_clone.line"));
367 }
368 }
369 TraceCloneTarget::Fingerprint(fingerprint) => {
370 validate_non_empty("fingerprint", fingerprint)?;
371 }
372 }
373 Ok(())
374}
375
376fn load_trace_session(
377 resolved: &ProgrammaticAnalysisContext,
378) -> ProgrammaticResult<AnalysisSession> {
379 super::dead_code::load_dead_code_session(
380 &super::dead_code::default_dead_code_options_for_context(resolved),
381 resolved,
382 )
383}
384
385fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
386 let artifacts = session
387 .analyze_dead_code_with_session_artifacts(false, true, None)
388 .map_err(|err| {
389 super::dead_code::map_engine_error(
390 &err,
391 "trace analysis failed",
392 "FALLOW_TRACE_FAILED",
393 "trace",
394 )
395 })?;
396 let graph = artifacts.analysis.graph.ok_or_else(|| {
397 ProgrammaticError::new("trace requires a retained module graph", 2)
398 .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
399 .with_context("trace.graph")
400 })?;
401 Ok(TraceArtifacts {
402 graph,
403 script_used_packages: artifacts.analysis.script_used_packages,
404 })
405}
406
407#[cfg(test)]
408mod benchmark_tests {
409 use std::fmt::Write as _;
410 use std::fs;
411
412 use fallow_engine::duplicates::CloneFingerprintSet;
413
414 use super::*;
415
416 const FIXTURE_SIZE: usize = 4;
417
418 fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
419 let path = root.join(path);
420 fs::create_dir_all(path.parent().expect("fixture file has parent"))
421 .expect("fixture directory is created");
422 fs::write(path, source.as_ref()).expect("fixture file is written");
423 }
424
425 #[test]
426 fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
427 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
428 let root = temp_dir.path().to_path_buf();
429 write_file(
430 &root,
431 "package.json",
432 r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
433 );
434 write_file(
435 &root,
436 "src/000-shared.ts",
437 "export const sharedValue = 42;\n",
438 );
439
440 let mut index_source = String::new();
441 for index in 0..FIXTURE_SIZE {
442 write_file(
443 &root,
444 &format!("src/consumer{index}.ts"),
445 format!(
446 "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
447 ),
448 );
449 writeln!(
450 index_source,
451 "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
452 )
453 .expect("index source is built");
454 }
455 write_file(&root, "src/index.ts", index_source);
456
457 let session = AnalysisSession::load(&root, None).expect("trace session loads");
458 let target = session
459 .files()
460 .iter()
461 .find(|file| file.path.ends_with("src/000-shared.ts"))
462 .expect("trace target is discovered");
463 assert_eq!(
464 target.id.0, 0,
465 "the retained trace target must precede every non-matching importer"
466 );
467 let trace_root = session.root().to_path_buf();
468 let artifacts = session
469 .analyze_dead_code_with_artifacts(false, true)
470 .expect("trace graph analysis succeeds");
471 drop(session);
472 temp_dir.close().expect("temporary project is removed");
473
474 let result = benchmark_trace_graph_family_compact_json(
475 artifacts.graph.as_ref().expect("trace graph is retained"),
476 &trace_root,
477 &artifacts.script_used_packages,
478 )
479 .expect("retained trace graph serializes without project IO");
480 assert_eq!(result.0, FIXTURE_SIZE);
481 assert_eq!(result.1, 1);
482 assert_eq!(result.2, FIXTURE_SIZE);
483 assert_eq!(result.3, FIXTURE_SIZE);
484 assert!(result.4 > 0);
485 }
486
487 #[test]
488 fn clone_benchmark_boundary_uses_only_the_retained_report() {
489 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
490 let root = temp_dir.path().to_path_buf();
491 write_file(
492 &root,
493 "package.json",
494 r#"{"name":"trace-clone-test","type":"module"}"#,
495 );
496 for index in 0..FIXTURE_SIZE {
497 write_file(
498 &root,
499 &format!("src/clone{index}.ts"),
500 format!(
501 "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"
502 ),
503 );
504 }
505
506 let session = AnalysisSession::load(&root, None).expect("clone session loads");
507 let trace_root = session.root().to_path_buf();
508 let mut config = session.config().duplicates.clone();
509 config.min_tokens = 35;
510 config.min_lines = 5;
511 config.min_occurrences = FIXTURE_SIZE;
512 let report = session.find_duplicates_with_defaults(&config, None).report;
513 let group = report
514 .clone_groups
515 .iter()
516 .max_by_key(|group| group.instances.len())
517 .expect("clone group exists");
518 let target = group.instances.last().expect("clone instance exists");
519 let target_file = target
520 .file
521 .strip_prefix(&trace_root)
522 .expect("clone path is project-relative")
523 .to_string_lossy()
524 .replace('\\', "/");
525 let target_line = target.start_line;
526 let expected_fingerprint =
527 CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
528 drop(session);
529 temp_dir.close().expect("temporary project is removed");
530
531 let result = benchmark_trace_clone_compact_json(
532 &report,
533 &trace_root,
534 &target_file,
535 target_line,
536 &expected_fingerprint,
537 )
538 .expect("retained clone report serializes without project IO");
539 assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
540 assert_eq!(result.location_line, target_line);
541 assert_eq!(result.location_fingerprint, expected_fingerprint);
542 assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
543 assert_eq!(result.location_group_count, 1);
544 assert_eq!(result.fingerprint_group_count, 1);
545 assert_eq!(result.location_instance_count, FIXTURE_SIZE);
546 assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
547 assert!(result.rendered_bytes > 0);
548 }
549}