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