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