Skip to main content

fallow_types/
trace.rs

1//! Shared trace output contracts for analysis and integration surfaces.
2
3use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::cache_rejection::CacheRejection;
8use crate::duplicates::{CloneInstance, RefactoringSuggestion};
9use crate::semantic::SemanticNamespace;
10use crate::serde_path;
11use crate::trace_chain::StarExportAmbiguity;
12
13/// Result of tracing an export: why it is considered used or unused.
14#[derive(Debug, Serialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub struct ExportTrace {
17    /// The file containing the export.
18    #[serde(serialize_with = "serde_path::serialize")]
19    pub file: PathBuf,
20    /// The export name being traced.
21    pub export_name: String,
22    /// Namespace whose references are listed for the traced export. The
23    /// preferred lane wins whenever it carries a reference: `value` for a
24    /// value export, `type` for a type-only one. When the preferred lane
25    /// carries none and the other lane resolves to the same declaration, the
26    /// other lane's references are listed and this field names it, so a value
27    /// export whose only credit is a bound `import type` reports `type` with
28    /// `is_used: true`. `is_used` and `direct_references` follow the listed
29    /// lane only, and only reachable reference sources can credit it. Legal
30    /// declaration merges share one declaration group across lanes, including
31    /// an `interface` next to a same-name `class` and a `class` next to a
32    /// same-name `namespace`, so references to either lane credit the merged
33    /// declaration. Distinct same-name declarations outside a merge remain
34    /// separate and keep the preferred lane. `semantic.target.namespace`
35    /// names the lane the declaration itself occupies and can therefore differ
36    /// from this field. Producers always emit the field; the schema permits
37    /// omission by payloads created before namespaces were exposed.
38    #[cfg_attr(feature = "schema", schemars(default))]
39    pub namespace: crate::semantic::SemanticNamespace,
40    /// Whether the file is reachable from an entry point.
41    pub file_reachable: bool,
42    /// Whether the file is an entry point.
43    pub is_entry_point: bool,
44    /// Whether the export is considered used.
45    pub is_used: bool,
46    /// Files that reference this export directly.
47    pub direct_references: Vec<ExportReference>,
48    /// Reachable direct references grouped by namespace. This is additive to
49    /// `namespace` and `direct_references`, whose winning-lane meaning remains
50    /// unchanged for backwards compatibility.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub direct_references_by_namespace: Vec<NamespacedExportReferences>,
53    /// A star-export collision that makes the traced name ambiguous. When
54    /// present, `is_used: false` is an abstention rather than an unused-code
55    /// verdict.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub star_export_ambiguity: Option<StarExportAmbiguity>,
58    /// Re-export chains that pass through this export.
59    pub re_export_chains: Vec<ReExportChain>,
60    /// Human-readable reason summary.
61    pub reason: String,
62    /// Exact checker-backed references when type-aware tracing is enabled.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub semantic: Option<crate::semantic::SemanticSymbolTrace>,
65}
66
67/// Result of tracing a class / enum / store MEMBER: the `--trace FILE:NAME`
68/// fallback when `NAME` is not a top-level export but a member declared on one
69/// (issue #1744). The trace runs on the module graph only, so it reports the
70/// OWNING export's reachability and usage (the gating precondition for
71/// member-level crediting) plus a pointer to the right `--unused-*-members`
72/// command, rather than per-member crediting provenance.
73#[derive(Debug, Serialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75pub struct ClassMemberTrace {
76    /// The file containing the member.
77    #[serde(serialize_with = "serde_path::serialize")]
78    pub file: PathBuf,
79    /// The member name being traced.
80    pub member_name: String,
81    /// The member kind: `class-method`, `class-property`, `enum-member`,
82    /// `store-member`, or `namespace-member`.
83    pub member_kind: String,
84    /// The export that declares this member (the class / enum / store name).
85    pub owner_export: String,
86    /// Namespace whose references credit the owning export, mirroring
87    /// [`ExportTrace::namespace`] for the export this member is declared on.
88    /// `owner_is_used` and `owner_direct_references` describe that lane, so a
89    /// member of a value export credited only by a bound `import type` reports
90    /// `type` here. `semantic.target.namespace` names the lane the checker
91    /// proof covers and can therefore differ. Producers always emit the field;
92    /// the schema permits omission by payloads created before the owner
93    /// namespace was exposed.
94    #[cfg_attr(feature = "schema", schemars(default))]
95    pub owner_namespace: crate::semantic::SemanticNamespace,
96    /// Whether the owning export is considered used.
97    pub owner_is_used: bool,
98    /// Whether the file is reachable from an entry point.
99    pub owner_file_reachable: bool,
100    /// Whether the file is an entry point.
101    pub owner_is_entry_point: bool,
102    /// Files that reference the owning export directly.
103    pub owner_direct_references: Vec<ExportReference>,
104    /// Re-export chains through which the owning export is reachable. Populated
105    /// so a machine consumer can tell "used via a barrel" (empty direct refs but
106    /// non-empty chains) from "genuinely unreferenced".
107    pub owner_re_export_chains: Vec<ReExportChain>,
108    /// Human-readable reason summary plus the follow-up command to inspect the
109    /// member finding.
110    pub reason: String,
111    /// Exact checker-backed member references when type-aware tracing is enabled.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub semantic: Option<crate::semantic::SemanticSymbolTrace>,
114}
115
116/// A direct reference to an export.
117#[derive(Debug, Clone, Serialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct ExportReference {
120    /// File that contains the reference.
121    #[serde(serialize_with = "serde_path::serialize")]
122    pub from_file: PathBuf,
123    /// Reference kind, such as named import, default import, or re-export.
124    pub kind: String,
125}
126
127/// Direct references that credit one namespace of an export binding.
128#[derive(Debug, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130pub struct NamespacedExportReferences {
131    /// Credited namespace.
132    pub namespace: SemanticNamespace,
133    /// Number of reachable references in this namespace.
134    pub reference_count: usize,
135    /// Reachable references in deterministic graph order.
136    pub references: Vec<ExportReference>,
137}
138
139/// A re-export chain showing how an export is propagated.
140#[derive(Debug, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct ReExportChain {
143    /// The barrel file that re-exports this symbol.
144    #[serde(serialize_with = "serde_path::serialize")]
145    pub barrel_file: PathBuf,
146    /// The name it is re-exported as.
147    pub exported_as: String,
148    /// Number of references on the barrel's re-exported symbol.
149    pub reference_count: usize,
150}
151
152/// Result of tracing all edges for a file.
153#[derive(Debug, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct FileTrace {
156    /// The traced file.
157    #[serde(serialize_with = "serde_path::serialize")]
158    pub file: PathBuf,
159    /// Whether this file is reachable from entry points.
160    pub is_reachable: bool,
161    /// Whether this file is an entry point.
162    pub is_entry_point: bool,
163    /// Exports declared by this file.
164    pub exports: Vec<TracedExport>,
165    /// Files that this file imports from.
166    #[serde(serialize_with = "serde_path::serialize_vec")]
167    pub imports_from: Vec<PathBuf>,
168    /// Files that import from this file.
169    #[serde(serialize_with = "serde_path::serialize_vec")]
170    pub imported_by: Vec<PathBuf>,
171    /// Re-exports declared by this file.
172    pub re_exports: Vec<TracedReExport>,
173    /// The configs that make this file an entry point through Module
174    /// Federation `exposes`, one per config. Absent when no Federation config
175    /// exposes the file (issue #2796).
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub sources: Vec<TraceSource>,
178}
179
180/// Which configs name which files and which dependency names, collected by one
181/// analysis for the trace output.
182///
183/// It holds plain data, so a trace looks a file up without the rules that
184/// produced it.
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
186pub struct TraceProvenance {
187    /// Root-relative file path and the config that names it.
188    files: Vec<(PathBuf, TraceSource)>,
189    /// Dependency name and the config that names it.
190    dependencies: Vec<(String, TraceSource)>,
191    /// Dependency name and why the unused devDependency check credits it as
192    /// tooling.
193    tooling_credits: Vec<(String, ToolingCredit)>,
194}
195
196impl TraceProvenance {
197    /// Record that `source` names the root-relative `file`.
198    pub fn push_file(&mut self, file: PathBuf, source: TraceSource) {
199        if !self
200            .files
201            .iter()
202            .any(|(known, known_source)| *known == file && *known_source == source)
203        {
204            self.files.push((file, source));
205        }
206    }
207
208    /// Record that `source` names the dependency `name`.
209    pub fn push_dependency(&mut self, name: String, source: TraceSource) {
210        if !self
211            .dependencies
212            .iter()
213            .any(|(known, known_source)| *known == name && *known_source == source)
214        {
215            self.dependencies.push((name, source));
216        }
217    }
218
219    /// The configs that name `file`, a root-relative path.
220    #[must_use]
221    pub fn file_sources(&self, file: &std::path::Path) -> Vec<TraceSource> {
222        self.files
223            .iter()
224            .filter(|(known, _)| known == file)
225            .map(|(_, source)| source.clone())
226            .collect()
227    }
228
229    /// The configs that name the dependency `name`.
230    #[must_use]
231    pub fn dependency_sources(&self, name: &str) -> Vec<TraceSource> {
232        self.dependencies
233            .iter()
234            .filter(|(known, _)| known == name)
235            .map(|(_, source)| source.clone())
236            .collect()
237    }
238
239    /// Record why the dependency `name` is credited as tooling. The first
240    /// credit recorded for a name wins.
241    pub fn push_tooling_credit(&mut self, name: String, credit: ToolingCredit) {
242        if !self.tooling_credits.iter().any(|(known, _)| *known == name) {
243            self.tooling_credits.push((name, credit));
244        }
245    }
246
247    /// Why the dependency `name` is credited as tooling, if it is.
248    #[must_use]
249    pub fn tooling_credit(&self, name: &str) -> Option<ToolingCredit> {
250        self.tooling_credits
251            .iter()
252            .find(|(known, _)| known == name)
253            .map(|(_, credit)| credit.clone())
254    }
255}
256
257/// Why the unused devDependency check counts a dependency as used tooling
258/// although no source file imports it.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
261pub struct ToolingCredit {
262    /// The evidence: `plugin-config` when the plugin that declares the
263    /// dependency found its own config file, `plugin-reference` when a
264    /// package.json script, a CI workflow or a git hook runs one of that
265    /// plugin's packages, `ambient-types` for a type package that declares
266    /// globals, `types-target` when the project declares or imports the
267    /// package that a `@types/` package types, `types-config` when a config
268    /// file, such as a tsconfig `types` entry, names the type package,
269    /// `known-tooling` for a library from the tooling catalogue,
270    /// `known-tooling-config` when a command-line tool from the catalogue has
271    /// its own config file, and `own-peer` when the same manifest lists the
272    /// devDependency in `peerDependencies`. The set is open.
273    pub reason: String,
274    /// The plugin that declares the dependency as tooling.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub plugin: Option<String>,
277    /// The config file found, relative to the project root, for
278    /// `plugin-config` and `known-tooling-config`. A `package.json` path when
279    /// the config is a package.json key. For `own-peer`, the manifest that
280    /// lists the dependency.
281    #[serde(
282        serialize_with = "serde_path::serialize_option",
283        default,
284        skip_serializing_if = "Option::is_none"
285    )]
286    pub config: Option<PathBuf>,
287    /// The package that a script, CI workflow or git hook runs, for
288    /// `plugin-reference`, or the package that a `@types/` package types,
289    /// for `types-target`.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub reference: Option<String>,
292}
293
294/// A config that names a traced file or a traced dependency, and the key that
295/// names it.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298pub struct TraceSource {
299    /// The mechanism that names the file or the dependency:
300    /// `module-federation`. The set is open.
301    pub kind: String,
302    /// The plugin that read the config, as it labels itself:
303    /// `module-federation` for a standalone `module-federation.config.*`,
304    /// or the bundler plugin (`webpack`, `rspack`, `rsbuild`, `vite`,
305    /// `nextjs`) that read the same options inline from its own config.
306    pub plugin: String,
307    /// The file that names the file or the dependency, relative to the
308    /// project root: the config file, or the source file of a Module
309    /// Federation runtime call.
310    #[serde(serialize_with = "serde_path::serialize")]
311    pub config: PathBuf,
312    /// The config key or the runtime function that names the file or the
313    /// dependency: `exposes` for an exposed file, `remotes` for a remote
314    /// alias, and `registerRemotes`, `loadRemote`, `init` or `createInstance`
315    /// for a remote that a runtime call names. The set is open.
316    pub key: String,
317}
318
319/// An export with usage information.
320#[derive(Debug, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct TracedExport {
323    /// Export name.
324    pub name: String,
325    /// Whether the export is type-only.
326    pub is_type_only: bool,
327    /// Number of references to this export.
328    pub reference_count: usize,
329    /// Files that reference this export.
330    pub referenced_by: Vec<ExportReference>,
331}
332
333/// A re-export with source information.
334#[derive(Debug, Serialize)]
335#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
336pub struct TracedReExport {
337    /// Source file being re-exported from.
338    #[serde(serialize_with = "serde_path::serialize")]
339    pub source_file: PathBuf,
340    /// Imported symbol name.
341    pub imported_name: String,
342    /// Exported symbol name.
343    pub exported_name: String,
344}
345
346/// Result of tracing a dependency: where it is used.
347#[derive(Debug, Serialize)]
348#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
349pub struct DependencyTrace {
350    /// The dependency name being traced.
351    pub package_name: String,
352    /// Files that import this dependency, each listed once.
353    #[serde(serialize_with = "serde_path::serialize_vec")]
354    pub imported_by: Vec<PathBuf>,
355    /// Files whose every import of this dependency is type-only, each listed
356    /// once.
357    #[serde(serialize_with = "serde_path::serialize_vec")]
358    pub type_only_imported_by: Vec<PathBuf>,
359    /// Whether the dependency is invoked from package.json scripts, CI configs
360    /// or git hooks.
361    pub used_in_scripts: bool,
362    /// Whether the dependency is used at all: imported, invoked from scripts,
363    /// or listed as a peer by a used package (`peer_of`).
364    pub is_used: bool,
365    /// Number of files that import this dependency.
366    pub import_count: usize,
367    /// Used packages that list this dependency in their installed
368    /// `peerDependencies`, required or optional, sorted by name. The
369    /// unused-dependency check credits such a peer, because the package that
370    /// lists it loads it at runtime. Absent when no used package lists it.
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub peer_of: Vec<String>,
373    /// The configs that declare this name as a Module Federation remote alias
374    /// under `remotes`, one per config. A remote alias is provided by a
375    /// remote container at runtime, not by an npm package. Absent when no
376    /// Federation config declares the name (issue #2796).
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub sources: Vec<TraceSource>,
379    /// Why the unused devDependency check credits the dependency as tooling
380    /// when no file imports it and no script, CI workflow or git hook runs it.
381    /// When present, `is_used` is `true`. Absent otherwise.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub tooling_credit: Option<ToolingCredit>,
384    /// The manifests that the unused-dependency check flags for this name,
385    /// relative to the project root and sorted. The check reads each
386    /// declaring manifest on its own. An import credits the nearest manifest
387    /// that installs the package, so a name that one workspace uses can still
388    /// be unused in the root manifest or in another workspace. Absent when no
389    /// manifest is flagged.
390    #[serde(
391        default,
392        serialize_with = "serde_path::serialize_vec",
393        skip_serializing_if = "Vec::is_empty"
394    )]
395    pub unused_in: Vec<PathBuf>,
396    /// How the code uses each imported name of the dependency. Present on
397    /// `fallow trace --dependency`, and on the MCP `trace_dependency` tool
398    /// when a usage parameter is set. Absent on
399    /// `fallow dead-code --trace-dependency`. The object has its own
400    /// `schema_version`.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub usage: Option<crate::trace_usage::DependencyUsage>,
403}
404
405impl DependencyTrace {
406    /// Record the manifests that the unused-dependency report flags for this
407    /// name, so the trace names each declaration that the report lists.
408    pub fn apply_unused_declarations(
409        &mut self,
410        results: &crate::results::AnalysisResults,
411        root: &Path,
412    ) {
413        let flagged = results
414            .unused_dependencies
415            .iter()
416            .map(|finding| &finding.dep)
417            .chain(
418                results
419                    .unused_dev_dependencies
420                    .iter()
421                    .map(|finding| &finding.dep),
422            )
423            .chain(
424                results
425                    .unused_optional_dependencies
426                    .iter()
427                    .map(|finding| &finding.dep),
428            )
429            .filter(|dep| dep.package_name == self.package_name)
430            .map(|dep| {
431                dep.path
432                    .strip_prefix(root)
433                    .unwrap_or(&dep.path)
434                    .to_path_buf()
435            });
436        self.unused_in.extend(flagged);
437        self.unused_in.sort();
438        self.unused_in.dedup();
439    }
440
441    /// Attach the tooling credit of an otherwise unused dependency and count
442    /// the dependency as used, so the trace agrees with the unused-dependency
443    /// report. A dependency that an import or a script already uses keeps no
444    /// credit, because the credit does not decide its status.
445    pub fn apply_tooling_credit(&mut self, credit: Option<ToolingCredit>) {
446        if self.is_used {
447            return;
448        }
449        if let Some(credit) = credit {
450            self.is_used = true;
451            self.tooling_credit = Some(credit);
452        }
453    }
454}
455
456/// Sub-phase attribution inside the entry-point discovery stage.
457///
458/// `PipelineTimings::entry_points_ms` is a single opaque number; these are the
459/// consecutive wall-clock spans that make it up, so a slow discovery stage can
460/// be attributed instead of guessed at. The spans cover the discovery sections
461/// only, so they sum to slightly less than `entry_points_ms`: the summary and
462/// count that follow discovery are not attributed to any span.
463#[derive(Debug, Clone, Copy, Default, Serialize)]
464#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
465pub struct EntryPointSpans {
466    /// Root-package discovery: manual entry globs, root `package.json` fields,
467    /// and the nested `package.json` scan under the conventional monorepo
468    /// directories.
469    pub root_ms: f64,
470    /// Runtime script seed collection plus per-workspace discovery.
471    pub workspaces_ms: f64,
472    /// Plugin entry-point glob compilation and matching.
473    pub plugins_ms: f64,
474    /// The part of `plugins_ms` spent compiling plugin patterns into a glob
475    /// set. Scales with active pattern count, not with project size.
476    pub plugin_glob_build_ms: f64,
477    /// The part of `plugins_ms` spent matching the compiled set against every
478    /// discovered file. Scales with file count times pattern count.
479    pub plugin_glob_match_ms: f64,
480    /// Infrastructure config-file probing at the project root.
481    pub infrastructure_ms: f64,
482    /// Configured `dynamicallyLoaded` glob expansion. Zero when unconfigured.
483    pub dynamic_ms: f64,
484    /// Sorting and deduplicating the merged entry set.
485    pub dedup_ms: f64,
486}
487
488/// Deterministic work counts for one dead-code pipeline run.
489///
490/// Every count is exact for a given project, commit and cache state. It does
491/// not depend on the thread count, the machine or the load, so a regression
492/// test can compare it with exact equality where a millisecond value is too
493/// noisy. A count is zero when its stage did no work, for example the resolver
494/// counts on a run that reused the persisted module graph.
495#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
496#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
497pub struct PipelineCounters {
498    /// Source files whose bytes the parse stage read from disk. A warm cache
499    /// hit on file metadata reads no bytes. `cache_misses` counts the files
500    /// that were parsed.
501    pub files_read: u64,
502    /// Bytes of source read from disk by the parse stage.
503    pub source_bytes_read: u64,
504    /// Bytes of the persisted parse cache read from disk. Zero when the run
505    /// had no parse cache or used `--no-cache`.
506    pub parse_cache_bytes_read: u64,
507    /// Bytes of stylesheet source that the parse stage passed through the CSS
508    /// comment mask. The parse masks each stylesheet once, so this value is
509    /// the size of the parsed stylesheets. A higher value shows a repeated
510    /// mask. Zero when no stylesheet was parsed.
511    pub css_masked_bytes: u64,
512    /// Specifier resolutions that the import sites asked for: one for each
513    /// static import binding, re-export, `require()`, `import()` and module
514    /// mock. Internal retries inside the resolver are not counted here.
515    pub resolve_specifier_calls: u64,
516    /// Distinct `(specifier, from_style)` pairs for each importing file,
517    /// summed over all files. A ratio of `resolve_specifier_calls` to this
518    /// value above 1.0 shows bindings that share a specifier. The resolver
519    /// runs at most once for each of these pairs. A pair that returns before
520    /// the resolver, such as an external URL, makes no resolver call.
521    pub unique_specifiers: u64,
522    /// Calls into the module resolver, including fallback retries.
523    pub oxc_resolve_calls: u64,
524    /// Path canonicalize calls that import resolution needs: each direct call,
525    /// plus one for each distinct path that goes through the canonicalize
526    /// cache. Resolver setup is not counted.
527    pub canonicalize_calls: u64,
528}
529
530/// Pipeline performance timings.
531#[derive(Debug, Clone, Serialize)]
532#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
533pub struct PipelineTimings {
534    /// Time spent discovering files.
535    pub discover_files_ms: f64,
536    /// Number of discovered files.
537    pub file_count: usize,
538    /// Time spent discovering workspaces.
539    pub workspaces_ms: f64,
540    /// Number of discovered workspaces.
541    pub workspace_count: usize,
542    /// Time spent running plugin discovery.
543    pub plugins_ms: f64,
544    /// Time spent analyzing package scripts and CI configuration.
545    pub script_analysis_ms: f64,
546    /// Wall-clock time spent parsing and extracting modules.
547    pub parse_extract_ms: f64,
548    /// Summed parser CPU time across workers.
549    pub parse_cpu_ms: f64,
550    /// The part of `parse_extract_ms` that reads and decodes the persisted
551    /// parse cache. Zero with `--no-cache`.
552    pub parse_cache_load_ms: f64,
553    /// Number of extracted modules.
554    pub module_count: usize,
555    /// Number of files loaded from the parse cache.
556    pub cache_hits: usize,
557    /// Number of files parsed without a cache hit.
558    pub cache_misses: usize,
559    /// Why the persisted parse cache was not reused, when it was not. `None`
560    /// means the cache was loaded; the hit and miss counts then describe how
561    /// much of it applied.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub cache_rejection: Option<CacheRejection>,
564    /// Why the persisted module-graph cache was not reused, when it was not.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub graph_cache_rejection: Option<CacheRejection>,
567    /// Time spent updating the parse cache.
568    pub cache_update_ms: f64,
569    /// Time spent categorizing entry points.
570    pub entry_points_ms: f64,
571    /// Sub-phase attribution for `entry_points_ms`.
572    pub entry_point_spans: EntryPointSpans,
573    /// Number of entry points considered.
574    pub entry_point_count: usize,
575    /// Time spent resolving imports.
576    pub resolve_imports_ms: f64,
577    /// Time spent building the module graph.
578    pub build_graph_ms: f64,
579    /// Time spent running analysis.
580    pub analyze_ms: f64,
581    /// Time spent running duplicate-code analysis, when included.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub duplication_ms: Option<f64>,
584    /// Total pipeline time.
585    pub total_ms: f64,
586    /// Deterministic work counts for this run.
587    pub counters: PipelineCounters,
588}
589
590/// Result of computing the impact closure for a single file as the seed.
591#[derive(Debug, Serialize)]
592#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
593pub struct ImpactClosureTrace {
594    /// The seed file, root-relative.
595    pub seed: String,
596    /// Root-relative paths transitively affected by the seed.
597    pub affected_not_shown: Vec<String>,
598    /// Coordination gaps between the seed and consumers.
599    pub coordination_gap: Vec<ImpactClosureGap>,
600}
601
602/// Wire-version discriminator for [`ImportPathTrace`]. Independent from the
603/// global `SchemaVersion`: the import-path payload versions on its own cadence,
604/// like the other independently-versioned envelopes. Serializes as a string
605/// `const` so JSON consumers can switch on it.
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
607#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
608pub enum ImportPathTraceSchemaVersion {
609    /// First release of the `fallow trace --path` shape.
610    #[serde(rename = "1")]
611    V1,
612}
613
614/// Result of asking how one module reaches another: the shortest import path.
615///
616/// `reachable` is the only field that separates "no route exists" from "the
617/// route is empty because both ends are the same module". Both report
618/// `hops: 0`, so a consumer must read `reachable`, never the hop count.
619#[derive(Debug, Serialize)]
620#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
621#[cfg_attr(feature = "schema", schemars(title = "fallow trace --path"))]
622pub struct ImportPathTrace {
623    /// Wire-shape version of this payload.
624    pub schema_version: ImportPathTraceSchemaVersion,
625    /// The module the walk started from, root-relative.
626    pub from: String,
627    /// The module the walk was looking for, root-relative.
628    pub to: String,
629    /// Whether `to` is reachable from `from` by following import edges.
630    pub reachable: bool,
631    /// Number of import edges on the reported route. `0` both when the two ends
632    /// are the same module and when there is no route at all.
633    pub hops: usize,
634    /// The route, in import order. Empty whenever `hops` is `0`.
635    pub path: Vec<ImportPathHop>,
636    /// Human-readable summary of the outcome.
637    pub reason: String,
638}
639
640/// One import edge on an [`ImportPathTrace`].
641#[derive(Debug, Serialize, PartialEq, Eq)]
642#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
643pub struct ImportPathHop {
644    /// The importing module, root-relative.
645    pub from: String,
646    /// The imported module, root-relative.
647    pub to: String,
648    /// Whether every symbol on this edge is type-only, so the hop is erased at
649    /// build time. Type-only hops are reported, never skipped: an `import type`
650    /// chain is a real compile-time coupling.
651    pub type_only: bool,
652    /// Whether the edge carries a runtime value but no static one: the target
653    /// loads only on demand (`import()`, a lazy glob or template pattern) or
654    /// on another thread (a worker URL, a worker loader request,
655    /// `child_process.fork`). False for a
656    /// static hop and for a type-only hop.
657    pub dynamic: bool,
658    /// 1-based line in `from` of the imported binding that creates this edge:
659    /// the first value-carrying symbol on the import, or the first symbol when
660    /// every symbol is type-only. On a multi-line import that is the binding's
661    /// own line, not the `import` keyword's. Absent when the edge carries no
662    /// span or the source could not be read.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub import_line: Option<u32>,
665}
666
667/// One coordination-gap entry in an [`ImpactClosureTrace`].
668#[derive(Debug, Serialize)]
669#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
670pub struct ImpactClosureGap {
671    /// Root-relative path of the consumer module.
672    pub consumer_file: String,
673    /// Exported symbol names the consumer references.
674    pub consumed_symbols: Vec<String>,
675    /// Scope note for the syntactic trace.
676    pub note: String,
677}
678
679/// Result of tracing a clone: all groups containing the code at a source
680/// location or addressed by a stable clone fingerprint.
681#[derive(Debug, Serialize)]
682#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
683pub struct CloneTrace {
684    /// File passed to the trace request, root-relative when a group matches.
685    #[serde(serialize_with = "serde_path::serialize")]
686    pub file: PathBuf,
687    /// 1-based line passed to the trace request or representative group line.
688    pub line: usize,
689    /// The matched clone instance, if one exists.
690    pub matched_instance: Option<CloneInstance>,
691    /// Clone groups matched by the trace request.
692    pub clone_groups: Vec<TracedCloneGroup>,
693}
694
695/// One clone group returned from a clone trace request.
696#[derive(Debug, Serialize)]
697#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
698pub struct TracedCloneGroup {
699    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
700    /// report collisions.
701    pub fingerprint: String,
702    /// Number of tokens in the duplicated block.
703    pub token_count: usize,
704    /// Number of lines in the duplicated block.
705    pub line_count: usize,
706    /// Maximum directory-tree or same-file line distance between instances.
707    pub spread: usize,
708    /// Lowest all-pairs similarity for a near-miss clone group.
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    #[cfg_attr(feature = "schema", schemars(with = "f64"))]
711    pub similarity: Option<f64>,
712    /// Root-relative clone instances in this group.
713    pub instances: Vec<CloneInstance>,
714    /// Group-level refactoring suggestion.
715    pub suggestion: RefactoringSuggestion,
716    /// Best-effort name for the extracted function. Advisory only.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub suggested_name: Option<String>,
719}