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        resolved.ensure_not_cancelled("config load and file discovery")?;
142        let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
143        resolved.ensure_not_cancelled("duplication detection")?;
144        let dupes_config =
145            duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
146        let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
147        let report = session
148            .find_duplicates_with_defaults(&dupes_config, cache_dir)
149            .report;
150        // Duplication detection is infallible, so the cancelled run has to be
151        // reported before the report is traced as a complete one.
152        resolved.ensure_not_cancelled("the clone trace")?;
153        let (trace, not_found) = match &options.target {
154            TraceCloneTarget::Location { file, line } => (
155                fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
156                format!("no clone found at {file}:{line}"),
157            ),
158            TraceCloneTarget::Fingerprint(fingerprint) => (
159                fallow_engine::trace::trace_clone_by_fingerprint(
160                    &report,
161                    session.root(),
162                    fingerprint,
163                ),
164                format!("no clone group with fingerprint {fingerprint}"),
165            ),
166        };
167        if trace.matched_instance.is_none() {
168            return Err(ProgrammaticError::new(not_found, 2)
169                .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
170                .with_help(
171                    "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
172                     a location must fall inside a reported clone instance, and a fingerprint must \
173                     be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
174                )
175                .with_context("trace_clone"));
176        }
177        Ok(TraceCloneProgrammaticOutput { output: trace })
178    })
179}
180
181/// Exercise the retained-graph trace family and compact JSON boundary without
182/// repeating project discovery, parsing, or graph construction.
183///
184/// # Errors
185///
186/// Returns a structured error when a fixture target is missing or compact JSON
187/// serialization fails.
188#[doc(hidden)]
189#[allow(
190    clippy::implicit_hasher,
191    reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
192)]
193pub fn benchmark_trace_graph_family_compact_json(
194    graph: &fallow_engine::module_graph::RetainedModuleGraph,
195    root: &std::path::Path,
196    script_used_packages: &FxHashSet<String>,
197) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
198    let export =
199        fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
200            .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
201    let export_reference_count = export.direct_references.len();
202    let export_json =
203        crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
204            output: TraceExportTargetOutput::Export(export),
205        })?;
206
207    let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
208        .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
209    let file_export_count = file.exports.len();
210    let file_imported_by_count = file.imported_by.len();
211    let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
212        output: file,
213    })?;
214
215    let dependency =
216        fallow_engine::trace::trace_dependency(graph, root, "trace-package", script_used_packages);
217    let dependency_import_count = dependency.import_count;
218    let dependency_json =
219        crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
220            output: dependency,
221        })?;
222
223    let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
224    Ok((
225        export_reference_count,
226        file_export_count,
227        file_imported_by_count,
228        dependency_import_count,
229        rendered_bytes,
230    ))
231}
232
233/// Stable facts returned by the clone trace benchmark boundary.
234#[doc(hidden)]
235#[derive(Debug, PartialEq, Eq)]
236pub struct TraceCloneBenchmarkResult {
237    /// Location identity returned by the location trace.
238    pub location_file: std::path::PathBuf,
239    /// Location line returned by the location trace.
240    pub location_line: usize,
241    /// Group fingerprint returned by the location trace.
242    pub location_fingerprint: String,
243    /// Group fingerprint returned by the fingerprint trace.
244    pub fingerprint_fingerprint: String,
245    /// Groups returned by the location trace.
246    pub location_group_count: usize,
247    /// Groups returned by the fingerprint trace.
248    pub fingerprint_group_count: usize,
249    /// Instances returned by the location trace.
250    pub location_instance_count: usize,
251    /// Instances returned by the fingerprint trace.
252    pub fingerprint_instance_count: usize,
253    /// Total compact JSON bytes for both trace responses.
254    pub rendered_bytes: usize,
255}
256
257/// Exercise both supported clone trace identities from one retained report.
258///
259/// # Errors
260///
261/// Returns a structured error when either identity misses or compact JSON
262/// serialization fails.
263#[doc(hidden)]
264pub fn benchmark_trace_clone_compact_json(
265    report: &DuplicationReport,
266    root: &std::path::Path,
267    file: &str,
268    line: usize,
269    fingerprint: &str,
270) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
271    let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
272    let matched_location = location_trace
273        .matched_instance
274        .as_ref()
275        .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
276    let location_file = matched_location.file.clone();
277    let location_line = matched_location.start_line;
278    let location_fingerprint = location_trace
279        .clone_groups
280        .first()
281        .map(|group| group.fingerprint.clone())
282        .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
283    let location_group_count = location_trace.clone_groups.len();
284    let location_instance_count = location_trace
285        .clone_groups
286        .iter()
287        .map(|group| group.instances.len())
288        .sum();
289    let location_json =
290        crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
291            output: location_trace,
292        })?;
293
294    let fingerprint_trace =
295        fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
296    if fingerprint_trace.matched_instance.is_none() {
297        return Err(benchmark_trace_target_missing(fingerprint));
298    }
299    let fingerprint_fingerprint = fingerprint_trace
300        .clone_groups
301        .first()
302        .map(|group| group.fingerprint.clone())
303        .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
304    let fingerprint_group_count = fingerprint_trace.clone_groups.len();
305    let fingerprint_instance_count = fingerprint_trace
306        .clone_groups
307        .iter()
308        .map(|group| group.instances.len())
309        .sum();
310    let fingerprint_json =
311        crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
312            output: fingerprint_trace,
313        })?;
314
315    let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
316    Ok(TraceCloneBenchmarkResult {
317        location_file,
318        location_line,
319        location_fingerprint,
320        fingerprint_fingerprint,
321        location_group_count,
322        fingerprint_group_count,
323        location_instance_count,
324        fingerprint_instance_count,
325        rendered_bytes,
326    })
327}
328
329fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
330    serde_json::to_vec(values)
331        .map(|json| json.len())
332        .map_err(|err| {
333            ProgrammaticError::new(
334                format!("failed to serialize benchmark trace JSON: {err}"),
335                2,
336            )
337            .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
338            .with_context("benchmark_trace")
339        })
340}
341
342fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
343    ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
344        .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
345        .with_context("benchmark_trace")
346}
347
348fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
349    if value.trim().is_empty() {
350        return Err(
351            ProgrammaticError::new(format!("{field} must not be empty"), 2)
352                .with_code("FALLOW_INVALID_TRACE_OPTIONS")
353                .with_context(field.to_string()),
354        );
355    }
356    Ok(())
357}
358
359fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
360    match target {
361        TraceCloneTarget::Location { file, line } => {
362            validate_non_empty("file", file)?;
363            if *line == 0 {
364                return Err(ProgrammaticError::new("line must be greater than 0", 2)
365                    .with_code("FALLOW_INVALID_TRACE_OPTIONS")
366                    .with_context("trace_clone.line"));
367            }
368        }
369        TraceCloneTarget::Fingerprint(fingerprint) => {
370            validate_non_empty("fingerprint", fingerprint)?;
371        }
372    }
373    Ok(())
374}
375
376fn load_trace_session(
377    resolved: &ProgrammaticAnalysisContext,
378) -> ProgrammaticResult<AnalysisSession> {
379    super::dead_code::load_dead_code_session(
380        &super::dead_code::default_dead_code_options_for_context(resolved),
381        resolved,
382    )
383}
384
385fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
386    let artifacts = session
387        .analyze_dead_code_with_session_artifacts(false, true, None)
388        .map_err(|err| {
389            super::dead_code::map_engine_error(
390                &err,
391                "trace analysis failed",
392                "FALLOW_TRACE_FAILED",
393                "trace",
394            )
395        })?;
396    let graph = artifacts.analysis.graph.ok_or_else(|| {
397        ProgrammaticError::new("trace requires a retained module graph", 2)
398            .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
399            .with_context("trace.graph")
400    })?;
401    Ok(TraceArtifacts {
402        graph,
403        script_used_packages: artifacts.analysis.script_used_packages,
404    })
405}
406
407#[cfg(test)]
408mod benchmark_tests {
409    use std::fmt::Write as _;
410    use std::fs;
411
412    use fallow_engine::duplicates::CloneFingerprintSet;
413
414    use super::*;
415
416    const FIXTURE_SIZE: usize = 4;
417
418    fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
419        let path = root.join(path);
420        fs::create_dir_all(path.parent().expect("fixture file has parent"))
421            .expect("fixture directory is created");
422        fs::write(path, source.as_ref()).expect("fixture file is written");
423    }
424
425    #[test]
426    fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
427        let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
428        let root = temp_dir.path().to_path_buf();
429        write_file(
430            &root,
431            "package.json",
432            r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
433        );
434        write_file(
435            &root,
436            "src/000-shared.ts",
437            "export const sharedValue = 42;\n",
438        );
439
440        let mut index_source = String::new();
441        for index in 0..FIXTURE_SIZE {
442            write_file(
443                &root,
444                &format!("src/consumer{index}.ts"),
445                format!(
446                    "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
447                ),
448            );
449            writeln!(
450                index_source,
451                "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
452            )
453            .expect("index source is built");
454        }
455        write_file(&root, "src/index.ts", index_source);
456
457        let session = AnalysisSession::load(&root, None).expect("trace session loads");
458        let target = session
459            .files()
460            .iter()
461            .find(|file| file.path.ends_with("src/000-shared.ts"))
462            .expect("trace target is discovered");
463        assert_eq!(
464            target.id.0, 0,
465            "the retained trace target must precede every non-matching importer"
466        );
467        let trace_root = session.root().to_path_buf();
468        let artifacts = session
469            .analyze_dead_code_with_artifacts(false, true)
470            .expect("trace graph analysis succeeds");
471        drop(session);
472        temp_dir.close().expect("temporary project is removed");
473
474        let result = benchmark_trace_graph_family_compact_json(
475            artifacts.graph.as_ref().expect("trace graph is retained"),
476            &trace_root,
477            &artifacts.script_used_packages,
478        )
479        .expect("retained trace graph serializes without project IO");
480        assert_eq!(result.0, FIXTURE_SIZE);
481        assert_eq!(result.1, 1);
482        assert_eq!(result.2, FIXTURE_SIZE);
483        assert_eq!(result.3, FIXTURE_SIZE);
484        assert!(result.4 > 0);
485    }
486
487    #[test]
488    fn clone_benchmark_boundary_uses_only_the_retained_report() {
489        let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
490        let root = temp_dir.path().to_path_buf();
491        write_file(
492            &root,
493            "package.json",
494            r#"{"name":"trace-clone-test","type":"module"}"#,
495        );
496        for index in 0..FIXTURE_SIZE {
497            write_file(
498                &root,
499                &format!("src/clone{index}.ts"),
500                format!(
501                    "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"
502                ),
503            );
504        }
505
506        let session = AnalysisSession::load(&root, None).expect("clone session loads");
507        let trace_root = session.root().to_path_buf();
508        let mut config = session.config().duplicates.clone();
509        config.min_tokens = 35;
510        config.min_lines = 5;
511        config.min_occurrences = FIXTURE_SIZE;
512        let report = session.find_duplicates_with_defaults(&config, None).report;
513        let group = report
514            .clone_groups
515            .iter()
516            .max_by_key(|group| group.instances.len())
517            .expect("clone group exists");
518        let target = group.instances.last().expect("clone instance exists");
519        let target_file = target
520            .file
521            .strip_prefix(&trace_root)
522            .expect("clone path is project-relative")
523            .to_string_lossy()
524            .replace('\\', "/");
525        let target_line = target.start_line;
526        let expected_fingerprint =
527            CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
528        drop(session);
529        temp_dir.close().expect("temporary project is removed");
530
531        let result = benchmark_trace_clone_compact_json(
532            &report,
533            &trace_root,
534            &target_file,
535            target_line,
536            &expected_fingerprint,
537        )
538        .expect("retained clone report serializes without project IO");
539        assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
540        assert_eq!(result.location_line, target_line);
541        assert_eq!(result.location_fingerprint, expected_fingerprint);
542        assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
543        assert_eq!(result.location_group_count, 1);
544        assert_eq!(result.fingerprint_group_count, 1);
545        assert_eq!(result.location_instance_count, FIXTURE_SIZE);
546        assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
547        assert!(result.rendered_bytes > 0);
548    }
549}