Skip to main content

fallow_api/runtime/
trace.rs

1use fallow_engine::session::AnalysisSession;
2use fallow_types::duplicates::DuplicationReport;
3use rustc_hash::FxHashSet;
4
5use crate::{
6    ProgrammaticAnalysisContext, ProgrammaticError, TraceCloneOptions,
7    TraceCloneProgrammaticOutput, TraceCloneTarget, TraceDependencyOptions,
8    TraceDependencyProgrammaticOutput, TraceExportOptions, TraceExportProgrammaticOutput,
9    TraceExportTargetOutput, TraceFileOptions, TraceFileProgrammaticOutput,
10};
11
12use super::{ProgrammaticResult, duplication, resolve_programmatic_analysis_context};
13
14struct TraceArtifacts {
15    graph: fallow_engine::module_graph::RetainedModuleGraph,
16    script_used_packages: FxHashSet<String>,
17}
18
19/// Trace why an export is considered used or unused.
20///
21/// # Errors
22///
23/// Returns a structured programmatic error for invalid options, config load
24/// failures, graph construction failures, or missing trace targets.
25pub fn run_trace_export(
26    options: &TraceExportOptions,
27) -> ProgrammaticResult<TraceExportProgrammaticOutput> {
28    validate_non_empty("file", &options.file)?;
29    validate_non_empty("export_name", &options.export_name)?;
30    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
31    resolved.install(|| {
32        let session = load_trace_session(&resolved)?;
33        let artifacts = trace_artifacts(&session)?;
34        // Resolve a top-level export first; on a miss fall back to a class /
35        // enum / store member trace so the MCP tool and Code Mode match the
36        // CLI's `--trace FILE:MEMBER` behavior instead of a hard not-found
37        // (issue #1744).
38        let output = if let Some(export) = fallow_engine::trace::trace_export(
39            &artifacts.graph,
40            session.root(),
41            &options.file,
42            &options.export_name,
43        ) {
44            TraceExportTargetOutput::Export(export)
45        } else if let Some(member) = fallow_engine::trace::trace_class_member(
46            &artifacts.graph,
47            session.root(),
48            &options.file,
49            &options.export_name,
50        ) {
51            TraceExportTargetOutput::Member(member)
52        } else {
53            return Err(ProgrammaticError::new(
54                format!(
55                    "export or member '{}' not found in '{}'",
56                    options.export_name, options.file
57                ),
58                2,
59            )
60            .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
61            .with_help(
62                "The name is neither a top-level export nor a class / enum / store member of this \
63                 file. Run trace_file on the file to list its exports, or project_info for the \
64                 project symbol set; confirm the file path is project-relative.",
65            )
66            .with_context("trace_export"));
67        };
68        Ok(TraceExportProgrammaticOutput { output })
69    })
70}
71
72/// Trace all graph edges for a file.
73///
74/// # Errors
75///
76/// Returns a structured programmatic error for invalid options, config load
77/// failures, graph construction failures, or missing trace targets.
78pub fn run_trace_file(
79    options: &TraceFileOptions,
80) -> ProgrammaticResult<TraceFileProgrammaticOutput> {
81    validate_non_empty("file", &options.file)?;
82    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
83    resolved.install(|| {
84        let session = load_trace_session(&resolved)?;
85        let artifacts = trace_artifacts(&session)?;
86        let output =
87            fallow_engine::trace::trace_file(&artifacts.graph, session.root(), &options.file)
88                .ok_or_else(|| {
89                    ProgrammaticError::new(
90                        format!("file '{}' not found in module graph", options.file),
91                        2,
92                    )
93                    .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
94                    .with_help(
95                        "The file is not in the analyzed module graph. Run project_info to list \
96                         discovered files; the path must be project-relative and not excluded by \
97                         ignore patterns or outside the analyzed roots.",
98                    )
99                    .with_context("trace_file")
100                })?;
101        Ok(TraceFileProgrammaticOutput { output })
102    })
103}
104
105/// Trace where a dependency is used.
106///
107/// # Errors
108///
109/// Returns a structured programmatic error for invalid options, config load, or
110/// graph construction failures.
111pub fn run_trace_dependency(
112    options: &TraceDependencyOptions,
113) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
114    validate_non_empty("package_name", &options.package_name)?;
115    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
116    resolved.install(|| {
117        let session = load_trace_session(&resolved)?;
118        let artifacts = trace_artifacts(&session)?;
119        let output = fallow_engine::trace::trace_dependency(
120            &artifacts.graph,
121            session.root(),
122            &options.package_name,
123            &artifacts.script_used_packages,
124        );
125        Ok(TraceDependencyProgrammaticOutput { output })
126    })
127}
128
129/// Trace duplicate-code groups by location or stable fingerprint.
130///
131/// # Errors
132///
133/// Returns a structured programmatic error for invalid options, config load
134/// failures, duplicate detection failures, or missing trace targets.
135pub fn run_trace_clone(
136    options: &TraceCloneOptions,
137) -> ProgrammaticResult<TraceCloneProgrammaticOutput> {
138    validate_trace_clone_target(&options.target)?;
139    let resolved = resolve_programmatic_analysis_context(&options.duplication.analysis)?;
140    resolved.install(|| {
141        let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
142        let dupes_config =
143            duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
144        let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
145        let report = session
146            .find_duplicates_with_defaults(&dupes_config, cache_dir)
147            .report;
148        let (trace, not_found) = match &options.target {
149            TraceCloneTarget::Location { file, line } => (
150                fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
151                format!("no clone found at {file}:{line}"),
152            ),
153            TraceCloneTarget::Fingerprint(fingerprint) => (
154                fallow_engine::trace::trace_clone_by_fingerprint(
155                    &report,
156                    session.root(),
157                    fingerprint,
158                ),
159                format!("no clone group with fingerprint {fingerprint}"),
160            ),
161        };
162        if trace.matched_instance.is_none() {
163            return Err(ProgrammaticError::new(not_found, 2)
164                .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
165                .with_help(
166                    "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
167                     a location must fall inside a reported clone instance, and a fingerprint must \
168                     be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
169                )
170                .with_context("trace_clone"));
171        }
172        Ok(TraceCloneProgrammaticOutput { output: trace })
173    })
174}
175
176/// Exercise the retained-graph trace family and compact JSON boundary without
177/// repeating project discovery, parsing, or graph construction.
178///
179/// # Errors
180///
181/// Returns a structured error when a fixture target is missing or compact JSON
182/// serialization fails.
183#[doc(hidden)]
184#[allow(
185    clippy::implicit_hasher,
186    reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
187)]
188pub fn benchmark_trace_graph_family_compact_json(
189    graph: &fallow_engine::module_graph::RetainedModuleGraph,
190    root: &std::path::Path,
191    script_used_packages: &FxHashSet<String>,
192) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
193    let export =
194        fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
195            .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
196    let export_reference_count = export.direct_references.len();
197    let export_json =
198        crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
199            output: TraceExportTargetOutput::Export(export),
200        })?;
201
202    let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
203        .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
204    let file_export_count = file.exports.len();
205    let file_imported_by_count = file.imported_by.len();
206    let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
207        output: file,
208    })?;
209
210    let dependency =
211        fallow_engine::trace::trace_dependency(graph, root, "trace-package", script_used_packages);
212    let dependency_import_count = dependency.import_count;
213    let dependency_json =
214        crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
215            output: dependency,
216        })?;
217
218    let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
219    Ok((
220        export_reference_count,
221        file_export_count,
222        file_imported_by_count,
223        dependency_import_count,
224        rendered_bytes,
225    ))
226}
227
228/// Stable facts returned by the clone trace benchmark boundary.
229#[doc(hidden)]
230#[derive(Debug, PartialEq, Eq)]
231pub struct TraceCloneBenchmarkResult {
232    /// Location identity returned by the location trace.
233    pub location_file: std::path::PathBuf,
234    /// Location line returned by the location trace.
235    pub location_line: usize,
236    /// Group fingerprint returned by the location trace.
237    pub location_fingerprint: String,
238    /// Group fingerprint returned by the fingerprint trace.
239    pub fingerprint_fingerprint: String,
240    /// Groups returned by the location trace.
241    pub location_group_count: usize,
242    /// Groups returned by the fingerprint trace.
243    pub fingerprint_group_count: usize,
244    /// Instances returned by the location trace.
245    pub location_instance_count: usize,
246    /// Instances returned by the fingerprint trace.
247    pub fingerprint_instance_count: usize,
248    /// Total compact JSON bytes for both trace responses.
249    pub rendered_bytes: usize,
250}
251
252/// Exercise both supported clone trace identities from one retained report.
253///
254/// # Errors
255///
256/// Returns a structured error when either identity misses or compact JSON
257/// serialization fails.
258#[doc(hidden)]
259pub fn benchmark_trace_clone_compact_json(
260    report: &DuplicationReport,
261    root: &std::path::Path,
262    file: &str,
263    line: usize,
264    fingerprint: &str,
265) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
266    let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
267    let matched_location = location_trace
268        .matched_instance
269        .as_ref()
270        .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
271    let location_file = matched_location.file.clone();
272    let location_line = matched_location.start_line;
273    let location_fingerprint = location_trace
274        .clone_groups
275        .first()
276        .map(|group| group.fingerprint.clone())
277        .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
278    let location_group_count = location_trace.clone_groups.len();
279    let location_instance_count = location_trace
280        .clone_groups
281        .iter()
282        .map(|group| group.instances.len())
283        .sum();
284    let location_json =
285        crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
286            output: location_trace,
287        })?;
288
289    let fingerprint_trace =
290        fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
291    if fingerprint_trace.matched_instance.is_none() {
292        return Err(benchmark_trace_target_missing(fingerprint));
293    }
294    let fingerprint_fingerprint = fingerprint_trace
295        .clone_groups
296        .first()
297        .map(|group| group.fingerprint.clone())
298        .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
299    let fingerprint_group_count = fingerprint_trace.clone_groups.len();
300    let fingerprint_instance_count = fingerprint_trace
301        .clone_groups
302        .iter()
303        .map(|group| group.instances.len())
304        .sum();
305    let fingerprint_json =
306        crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
307            output: fingerprint_trace,
308        })?;
309
310    let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
311    Ok(TraceCloneBenchmarkResult {
312        location_file,
313        location_line,
314        location_fingerprint,
315        fingerprint_fingerprint,
316        location_group_count,
317        fingerprint_group_count,
318        location_instance_count,
319        fingerprint_instance_count,
320        rendered_bytes,
321    })
322}
323
324fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
325    serde_json::to_vec(values)
326        .map(|json| json.len())
327        .map_err(|err| {
328            ProgrammaticError::new(
329                format!("failed to serialize benchmark trace JSON: {err}"),
330                2,
331            )
332            .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
333            .with_context("benchmark_trace")
334        })
335}
336
337fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
338    ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
339        .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
340        .with_context("benchmark_trace")
341}
342
343fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
344    if value.trim().is_empty() {
345        return Err(
346            ProgrammaticError::new(format!("{field} must not be empty"), 2)
347                .with_code("FALLOW_INVALID_TRACE_OPTIONS")
348                .with_context(field.to_string()),
349        );
350    }
351    Ok(())
352}
353
354fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
355    match target {
356        TraceCloneTarget::Location { file, line } => {
357            validate_non_empty("file", file)?;
358            if *line == 0 {
359                return Err(ProgrammaticError::new("line must be greater than 0", 2)
360                    .with_code("FALLOW_INVALID_TRACE_OPTIONS")
361                    .with_context("trace_clone.line"));
362            }
363        }
364        TraceCloneTarget::Fingerprint(fingerprint) => {
365            validate_non_empty("fingerprint", fingerprint)?;
366        }
367    }
368    Ok(())
369}
370
371fn load_trace_session(
372    resolved: &ProgrammaticAnalysisContext,
373) -> ProgrammaticResult<AnalysisSession> {
374    super::dead_code::load_dead_code_session(
375        &super::dead_code::default_dead_code_options_for_context(resolved),
376        resolved,
377    )
378}
379
380fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
381    let artifacts = session
382        .analyze_dead_code_with_session_artifacts(false, true, None)
383        .map_err(|err| {
384            ProgrammaticError::new(format!("trace analysis failed: {err}"), 2)
385                .with_code("FALLOW_TRACE_FAILED")
386                .with_context("trace")
387        })?;
388    let graph = artifacts.analysis.graph.ok_or_else(|| {
389        ProgrammaticError::new("trace requires a retained module graph", 2)
390            .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
391            .with_context("trace.graph")
392    })?;
393    Ok(TraceArtifacts {
394        graph,
395        script_used_packages: artifacts.analysis.script_used_packages,
396    })
397}
398
399#[cfg(test)]
400mod benchmark_tests {
401    use std::fmt::Write as _;
402    use std::fs;
403
404    use fallow_engine::duplicates::CloneFingerprintSet;
405
406    use super::*;
407
408    const FIXTURE_SIZE: usize = 4;
409
410    fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
411        let path = root.join(path);
412        fs::create_dir_all(path.parent().expect("fixture file has parent"))
413            .expect("fixture directory is created");
414        fs::write(path, source.as_ref()).expect("fixture file is written");
415    }
416
417    #[test]
418    fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
419        let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
420        let root = temp_dir.path().to_path_buf();
421        write_file(
422            &root,
423            "package.json",
424            r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
425        );
426        write_file(
427            &root,
428            "src/000-shared.ts",
429            "export const sharedValue = 42;\n",
430        );
431
432        let mut index_source = String::new();
433        for index in 0..FIXTURE_SIZE {
434            write_file(
435                &root,
436                &format!("src/consumer{index}.ts"),
437                format!(
438                    "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
439                ),
440            );
441            writeln!(
442                index_source,
443                "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
444            )
445            .expect("index source is built");
446        }
447        write_file(&root, "src/index.ts", index_source);
448
449        let session = AnalysisSession::load(&root, None).expect("trace session loads");
450        let target = session
451            .files()
452            .iter()
453            .find(|file| file.path.ends_with("src/000-shared.ts"))
454            .expect("trace target is discovered");
455        assert_eq!(
456            target.id.0, 0,
457            "the retained trace target must precede every non-matching importer"
458        );
459        let trace_root = session.root().to_path_buf();
460        let artifacts = session
461            .analyze_dead_code_with_artifacts(false, true)
462            .expect("trace graph analysis succeeds");
463        drop(session);
464        temp_dir.close().expect("temporary project is removed");
465
466        let result = benchmark_trace_graph_family_compact_json(
467            artifacts.graph.as_ref().expect("trace graph is retained"),
468            &trace_root,
469            &artifacts.script_used_packages,
470        )
471        .expect("retained trace graph serializes without project IO");
472        assert_eq!(result.0, FIXTURE_SIZE);
473        assert_eq!(result.1, 1);
474        assert_eq!(result.2, FIXTURE_SIZE);
475        assert_eq!(result.3, FIXTURE_SIZE);
476        assert!(result.4 > 0);
477    }
478
479    #[test]
480    fn clone_benchmark_boundary_uses_only_the_retained_report() {
481        let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
482        let root = temp_dir.path().to_path_buf();
483        write_file(
484            &root,
485            "package.json",
486            r#"{"name":"trace-clone-test","type":"module"}"#,
487        );
488        for index in 0..FIXTURE_SIZE {
489            write_file(
490                &root,
491                &format!("src/clone{index}.ts"),
492                format!(
493                    "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"
494                ),
495            );
496        }
497
498        let session = AnalysisSession::load(&root, None).expect("clone session loads");
499        let trace_root = session.root().to_path_buf();
500        let mut config = session.config().duplicates.clone();
501        config.min_tokens = 35;
502        config.min_lines = 5;
503        config.min_occurrences = FIXTURE_SIZE;
504        let report = session.find_duplicates_with_defaults(&config, None).report;
505        let group = report
506            .clone_groups
507            .iter()
508            .max_by_key(|group| group.instances.len())
509            .expect("clone group exists");
510        let target = group.instances.last().expect("clone instance exists");
511        let target_file = target
512            .file
513            .strip_prefix(&trace_root)
514            .expect("clone path is project-relative")
515            .to_string_lossy()
516            .replace('\\', "/");
517        let target_line = target.start_line;
518        let expected_fingerprint =
519            CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
520        drop(session);
521        temp_dir.close().expect("temporary project is removed");
522
523        let result = benchmark_trace_clone_compact_json(
524            &report,
525            &trace_root,
526            &target_file,
527            target_line,
528            &expected_fingerprint,
529        )
530        .expect("retained clone report serializes without project IO");
531        assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
532        assert_eq!(result.location_line, target_line);
533        assert_eq!(result.location_fingerprint, expected_fingerprint);
534        assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
535        assert_eq!(result.location_group_count, 1);
536        assert_eq!(result.fingerprint_group_count, 1);
537        assert_eq!(result.location_instance_count, FIXTURE_SIZE);
538        assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
539        assert!(result.rendered_bytes > 0);
540    }
541}