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