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