Skip to main content

fallow_api/runtime/
trace.rs

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
20/// Trace why an export is considered used or unused.
21///
22/// # Errors
23///
24/// Returns a structured programmatic error for invalid options, config load
25/// failures, graph construction failures, or missing trace targets.
26pub 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        // Resolve a top-level export first; on a miss fall back to a class /
36        // enum / store member trace so the MCP tool and Code Mode match the
37        // CLI's `--trace FILE:MEMBER` behavior instead of a hard not-found
38        // (issue #1744).
39        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
73/// Trace all graph edges for a file.
74///
75/// # Errors
76///
77/// Returns a structured programmatic error for invalid options, config load
78/// failures, graph construction failures, or missing trace targets.
79pub 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
106/// Trace the shortest import path between two modules.
107///
108/// An unreachable pair is a RESULT, not an error: the output reports
109/// `reachable: false` with zero hops. A missing or ambiguous endpoint is an error.
110///
111/// # Errors
112///
113/// Returns a structured programmatic error for invalid options, config load
114/// failures, graph construction failures, or a missing or ambiguous endpoint.
115pub 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
157/// Resolve a runtime stack trace's frames against the project graph.
158///
159/// A trace in which nothing resolves is a RESULT, not an error: the output
160/// reports every frame with the origin and resolution that explain why. Only
161/// an empty trace argument, a config load failure, or a failed analysis is an
162/// error.
163///
164/// # Errors
165///
166/// Returns a structured programmatic error for an empty or oversized trace,
167/// config load failures, or graph construction failures.
168pub fn run_trace_error(
169    options: &TraceErrorOptions,
170) -> ProgrammaticResult<TraceErrorProgrammaticOutput> {
171    if options.trace.trim().is_empty() {
172        // Typed like the oversized-trace refusal below rather than through
173        // `validate_non_empty`: both are the same tool refusing the same
174        // argument, so both carry a `code`, a `help` and a `context`.
175        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
214/// Trace where a dependency is used.
215///
216/// # Errors
217///
218/// Returns a structured programmatic error for invalid options, config load, or
219/// graph construction failures.
220pub 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
238/// Trace duplicate-code groups by location or stable fingerprint.
239///
240/// # Errors
241///
242/// Returns a structured programmatic error for invalid options, config load
243/// failures, duplicate detection failures, or missing trace targets.
244pub 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        // Duplication detection is infallible, so the cancelled run has to be
260        // reported before the report is traced as a complete one.
261        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/// Exercise the retained-graph trace family and compact JSON boundary without
291/// repeating project discovery, parsing, or graph construction.
292///
293/// # Errors
294///
295/// Returns a structured error when a fixture target is missing or compact JSON
296/// serialization fails.
297#[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/// Stable facts returned by the clone trace benchmark boundary.
343#[doc(hidden)]
344#[derive(Debug, PartialEq, Eq)]
345pub struct TraceCloneBenchmarkResult {
346    /// Location identity returned by the location trace.
347    pub location_file: std::path::PathBuf,
348    /// Location line returned by the location trace.
349    pub location_line: usize,
350    /// Group fingerprint returned by the location trace.
351    pub location_fingerprint: String,
352    /// Group fingerprint returned by the fingerprint trace.
353    pub fingerprint_fingerprint: String,
354    /// Groups returned by the location trace.
355    pub location_group_count: usize,
356    /// Groups returned by the fingerprint trace.
357    pub fingerprint_group_count: usize,
358    /// Instances returned by the location trace.
359    pub location_instance_count: usize,
360    /// Instances returned by the fingerprint trace.
361    pub fingerprint_instance_count: usize,
362    /// Total compact JSON bytes for both trace responses.
363    pub rendered_bytes: usize,
364}
365
366/// Exercise both supported clone trace identities from one retained report.
367///
368/// # Errors
369///
370/// Returns a structured error when either identity misses or compact JSON
371/// serialization fails.
372#[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}