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