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