Skip to main content

skif_js/
explain.rs

1//! Why a file, or one export of it, counts as used: the entry rule, the importers and the chain from an entry.
2//!
3//! [`JsAnalysis::explain_usage`] builds a [`UsageReport`]; the verdict of an export and the evidence listed with it are
4//! computed together, and [`UsageReport::render`] is the only place that turns the report into text.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::fmt::Write as _;
8use std::path::Path;
9
10use serde::Serialize;
11use skif_js_syntax::{ExportKind, Import, ImportKind, ImportedKind, ImportedName};
12use skif_workspace::PackageId;
13
14use crate::analysis::JsAnalysis;
15use crate::discover::relative;
16use crate::globs::Globs;
17use crate::graph::{Module, ModuleId, Target};
18use crate::input::IncompleteEntries;
19use crate::manifest_paths::{crossing_stars, manifest_targets, source_candidates, source_patterns};
20use crate::resolve::join_normalized;
21use crate::setup::PackageSetup;
22
23/// Re-export hops followed when explaining an export.
24const MAX_DEPTH: usize = 8;
25/// Importers listed by [`UsageReport::render`] before the rest is summarised.
26const MAX_LISTED: usize = 12;
27
28/// Which rule makes a module an entry file.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31#[non_exhaustive]
32pub enum EntryReason {
33    /// An entry glob of its package: defaults, configuration, test conventions or a plugin.
34    EntryGlob,
35    /// A configuration file of a tool.
36    ConfigFile,
37    /// Run by a script of `package.json`.
38    Script,
39    /// Published by `package.json` (`main`, `module`, `types`, `bin`, `exports`, n8n `nodes` and `credentials`).
40    Published,
41    /// Matched by an entry glob of another package that points outside that package.
42    ExternalEntryGlob,
43    /// In the narrowed scope of a package whose entries are incomplete: a config the analysis cannot fully read may
44    /// make it an entry, so it is treated as one (ADR 0065).
45    IncompleteScope,
46    /// The module is an entry, but none of the known rules accounts for it.
47    Unknown,
48}
49
50impl EntryReason {
51    const fn describe(self) -> &'static str {
52        match self {
53            Self::EntryGlob => {
54                "matches an entry glob of its package (defaults, configuration, test conventions or a plugin)"
55            }
56            Self::ConfigFile => "a configuration file of a tool",
57            Self::Script => "run by a script of package.json",
58            Self::Published => {
59                "published by package.json (main, module, types, bin, exports, n8n nodes and credentials)"
60            }
61            Self::ExternalEntryGlob => "matches an entry glob of another package that points outside it",
62            Self::IncompleteScope => {
63                "may be an entry of a configuration that is not fully known, so it is treated as one (narrowed scope)"
64            }
65            Self::Unknown => "unknown: no entry rule of the analysis accounts for it",
66        }
67    }
68}
69
70/// Why a file is not in the module graph.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73#[non_exhaustive]
74pub enum Absence {
75    /// No entry file reaches it; `unused-files` reports it.
76    UnusedFile,
77    /// Its package is excluded from the run (`ignore-packages`, or `[js]` turned off for it).
78    PackageExcluded,
79    /// A declaration file (`.d.ts`), which is never reported as unused.
80    DeclarationFile,
81    /// The entry files of its package are not fully known and may be anywhere in it, so none of its files is judged
82    /// (package-wide); [`UsageReport::incomplete`] says why.
83    EntriesIncomplete,
84    /// Outside the project globs of its package.
85    OutsideProject,
86    /// Matched by an `ignore` glob.
87    Ignored,
88    /// Not a source file the analysis looks at: missing, ignored by Git, of a type that is not analysed, or owned by
89    /// no package.
90    NotDiscovered,
91}
92
93/// One import of a module.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "kebab-case")]
96#[non_exhaustive]
97pub struct Importer {
98    /// The importing file, relative to the workspace root.
99    pub file: String,
100    /// 1-based line of the import.
101    pub line: u32,
102    /// How the import is written.
103    pub kind: ImportKind,
104    /// What the import takes; empty for the whole module.
105    pub names: Vec<String>,
106    /// Whether an entry file reaches the importing file.
107    pub reachable: bool,
108}
109
110/// A module that imports an export.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "kebab-case")]
113#[non_exhaustive]
114pub struct ExportUser {
115    /// The importing file, relative to the workspace root.
116    pub file: String,
117    /// 1-based line of the import.
118    pub line: u32,
119    /// What the import takes.
120    pub how: String,
121    /// Whether an entry file reaches the importing file.
122    pub reachable: bool,
123}
124
125/// Whether an export counts as used.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127#[serde(rename_all = "kebab-case")]
128#[non_exhaustive]
129pub enum Verdict {
130    /// The module has no such export.
131    NotAnExport,
132    /// Public by definition: the file is an entry, or the export is tagged.
133    Public,
134    /// Another module uses it, or a used export of the same file mentions it in its signature.
135    Used,
136    /// Nothing uses it.
137    Unused,
138    /// Nothing in the analysed graph uses it, but the entry files of its package are not fully known, so a file no
139    /// known entry reaches may use it; it is not reported.
140    NotJudged,
141}
142
143/// A re-export that forwards an export, with the explanation of the forwarded export.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "kebab-case")]
146#[non_exhaustive]
147pub struct ReExport {
148    /// The re-exporting file.
149    pub file: String,
150    /// The name it exposes the export under.
151    pub name: String,
152    /// The explanation of that export in the re-exporting file.
153    pub export: ExportReport,
154}
155
156/// Why one export of a module counts as used or not.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[allow(
159    clippy::struct_excessive_bools,
160    reason = "independent facts of one serialized report"
161)]
162#[serde(rename_all = "kebab-case")]
163#[non_exhaustive]
164pub struct ExportReport {
165    /// The export name.
166    pub name: String,
167    /// The file that declares or forwards it.
168    pub file: String,
169    /// The verdict, derived from the facts below.
170    pub verdict: Verdict,
171    /// The file is an entry whose exports are public.
172    pub entry_public: bool,
173    /// The export is tagged `@public` (or another ignored tag).
174    pub tagged_public: bool,
175    /// Imports that take this export or the module as a whole.
176    pub users: Vec<ExportUser>,
177    /// Re-exports that forward it.
178    pub re_exports: Vec<ReExport>,
179    /// A used export of the same file mentions it in its signature.
180    pub in_used_signature: bool,
181    /// The export is itself a re-export.
182    pub is_re_export: bool,
183    /// The class behind the export is registered as a custom element (ADR 0062), so the browser uses it.
184    #[serde(skip_serializing_if = "is_false")]
185    pub registered: bool,
186    /// The entry files of the package are not fully known, so its exports are not judged.
187    #[serde(skip_serializing_if = "is_false")]
188    pub entries_incomplete: bool,
189    /// The same export was already explained higher up (a circular `export *`); not repeated.
190    #[serde(skip_serializing_if = "is_false")]
191    pub already_explained: bool,
192}
193
194/// The explanation [`JsAnalysis::explain_usage`] returns.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
196#[serde(rename_all = "kebab-case")]
197#[non_exhaustive]
198pub struct UsageReport {
199    /// The file, relative to the workspace root.
200    pub file: String,
201    /// Whether the file is in the module graph.
202    pub in_graph: bool,
203    /// Why it is not, when it is not.
204    pub absence: Option<Absence>,
205    /// Why the entry files of its package are not fully known, when the absence is
206    /// [`EntriesIncomplete`](Absence::EntriesIncomplete): every unreadable config, with files relative to the workspace
207    /// root.
208    #[serde(skip_serializing_if = "Vec::is_empty")]
209    pub incomplete: Vec<IncompleteEntries>,
210    /// When the entry files of its package are not fully known but can only be files matching these globs (relative
211    /// to the package directory): the narrowed scope of ADR 0065. Files in it are treated as entries and every other
212    /// file is judged; [`UsageReport::incomplete`] then lists the reasons too. Empty when the package's entries are
213    /// known, or unknown package-wide.
214    #[serde(skip_serializing_if = "Vec::is_empty")]
215    pub incomplete_scope: Vec<String>,
216    /// When the package's configs gave a narrowed scope but a module of the package imports a computed path, which
217    /// may load any of its files: that module and the 1-based line, as `file:line` relative to the workspace root.
218    /// The package is then unjudged package-wide.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub widened_by: Option<String>,
221    /// Which rule makes it an entry file, when it is one.
222    pub entry: Option<EntryReason>,
223    /// Every import of the module.
224    pub imported_by: Vec<Importer>,
225    /// The shortest chain of imports from an entry file to the module, entry first.
226    pub chain_from_entry: Option<Vec<String>>,
227    /// The explanation of the requested export.
228    pub export: Option<ExportReport>,
229}
230
231impl UsageReport {
232    /// Why the file is not in the module graph, as text.
233    fn render_absence(&self, absence: Absence, out: &mut String) {
234        match absence {
235            Absence::UnusedFile => {
236                let _ = writeln!(out, "  no entry file reaches it: reported by `unused-files`");
237                if !self.incomplete_scope.is_empty() {
238                    let _ = writeln!(
239                        out,
240                        "  the entry files of its package are not fully known, but the unknown ones can only match {} \
241                         (narrowed scope); this file does not, so it is judged",
242                        self.incomplete_scope.join(", ")
243                    );
244                    self.render_incomplete(out);
245                }
246            }
247            Absence::PackageExcluded => {
248                let _ = writeln!(out, "  not analysed: its package is excluded from the run");
249            }
250            Absence::DeclarationFile => {
251                let _ = writeln!(
252                    out,
253                    "  not in the module graph: a declaration file is never reported as unused"
254                );
255            }
256            Absence::EntriesIncomplete => {
257                let _ = writeln!(
258                    out,
259                    "  not in the module graph, and not reported as unused: the entry files of its package are \
260                     not fully known and may be anywhere in it (package-wide)"
261                );
262                if let Some(at) = &self.widened_by {
263                    let _ = writeln!(
264                        out,
265                        "  {at} imports a computed path, which may load any file of the package, so the narrowed \
266                         scope of its configs is not used"
267                    );
268                }
269                self.render_incomplete(out);
270            }
271            Absence::OutsideProject => {
272                let _ = writeln!(out, "  not analysed: outside the project globs of its package");
273            }
274            Absence::Ignored => {
275                let _ = writeln!(out, "  not analysed: matched by an `ignore` glob");
276            }
277            Absence::NotDiscovered => {
278                let _ = writeln!(
279                    out,
280                    "  not analysed: missing, ignored by Git, or not a source file the analysis looks at"
281                );
282            }
283        }
284    }
285
286    /// One line per reason the entry files of the package are incomplete.
287    fn render_incomplete(&self, out: &mut String) {
288        for incomplete in &self.incomplete {
289            let source = match (&incomplete.plugin, &incomplete.file) {
290                (Some(plugin), Some(file)) => format!("{plugin} reads {}: ", slashed(file)),
291                (None, Some(file)) => format!("{}: ", slashed(file)),
292                (Some(plugin), None) => format!("{plugin}: "),
293                (None, None) => String::new(),
294            };
295            let _ = writeln!(out, "  {source}{}", incomplete.cause.describe());
296        }
297    }
298
299    /// The report as text for a terminal.
300    #[must_use]
301    pub fn render(&self) -> String {
302        let mut out = String::new();
303        let _ = writeln!(out, "{}", self.file);
304        if let Some(absence) = self.absence {
305            self.render_absence(absence, &mut out);
306            return out;
307        }
308        if let Some(entry) = self.entry {
309            let _ = writeln!(out, "  entry file: {}", entry.describe());
310            if entry == EntryReason::IncompleteScope {
311                let _ = writeln!(
312                    out,
313                    "  the narrowed scope of its package: {}",
314                    self.incomplete_scope.join(", ")
315                );
316                self.render_incomplete(&mut out);
317            }
318        }
319        if self.imported_by.is_empty() {
320            let _ = writeln!(out, "  imported by no module");
321        } else {
322            let _ = writeln!(out, "  imported by {} place(s):", self.imported_by.len());
323            for importer in self.imported_by.iter().take(MAX_LISTED) {
324                let _ = writeln!(
325                    out,
326                    "    {}:{} ({}: {})",
327                    importer.file,
328                    importer.line,
329                    kind_label(importer.kind),
330                    names_label(&importer.names)
331                );
332            }
333            if self.imported_by.len() > MAX_LISTED {
334                let _ = writeln!(out, "    ... and {} more", self.imported_by.len() - MAX_LISTED);
335            }
336        }
337        match &self.chain_from_entry {
338            Some(chain) => {
339                let _ = writeln!(out, "  reached from an entry through: {}", chain.join(" -> "));
340            }
341            None => {
342                let _ = writeln!(out, "  no chain of imports from an entry file reaches it");
343            }
344        }
345        if let Some(export) = &self.export {
346            render_export(export, 0, &mut out);
347        }
348        out
349    }
350}
351
352/// The stable name of an import kind, as in the JSON output.
353const fn kind_label(kind: ImportKind) -> &'static str {
354    match kind {
355        ImportKind::Static => "static",
356        ImportKind::TypeOnly => "type-only",
357        ImportKind::SideEffect => "side-effect",
358        ImportKind::Dynamic => "dynamic",
359        ImportKind::Require => "require",
360        ImportKind::ImportMetaGlob => "import-meta-glob",
361        ImportKind::SourceGlob => "source-glob",
362        ImportKind::ExportFrom => "export-from",
363        ImportKind::Mock => "mock",
364    }
365}
366
367fn names_label(names: &[String]) -> String {
368    if names.is_empty() {
369        "the whole module".to_owned()
370    } else {
371        names.join(", ")
372    }
373}
374
375fn render_export(export: &ExportReport, depth: usize, out: &mut String) {
376    let pad = "  ".repeat(depth + 1);
377    if export.verdict == Verdict::NotAnExport {
378        let _ = writeln!(out, "{pad}`{}` is not an export of {}", export.name, export.file);
379        return;
380    }
381    if export.already_explained {
382        let _ = writeln!(
383            out,
384            "{pad}`{}` of {} is already explained above",
385            export.name, export.file
386        );
387        return;
388    }
389    let _ = writeln!(out, "{pad}export `{}` of {}:", export.name, export.file);
390    if export.entry_public {
391        let _ = writeln!(out, "{pad}  the file is an entry, so its exports are public");
392    }
393    if export.tagged_public {
394        let _ = writeln!(out, "{pad}  tagged `@public` (or another ignored tag)");
395    }
396    for user in &export.users {
397        let unreached = if user.reachable {
398            ""
399        } else {
400            "; no entry file reaches the importer"
401        };
402        let _ = writeln!(
403            out,
404            "{pad}  imported by {}:{} ({}{unreached})",
405            user.file, user.line, user.how
406        );
407    }
408    for re_export in &export.re_exports {
409        let _ = writeln!(out, "{pad}  re-exported by {} as `{}`:", re_export.file, re_export.name);
410        render_export(&re_export.export, depth + 1, out);
411    }
412    if export.users.is_empty() && export.re_exports.is_empty() {
413        let _ = writeln!(out, "{pad}  no module imports it by name or as a whole");
414    }
415    if export.in_used_signature {
416        let _ = writeln!(
417            out,
418            "{pad}  mentioned in the signature of a used export of the same file"
419        );
420    }
421    if export.is_re_export {
422        let _ = writeln!(out, "{pad}  itself a re-export of another module");
423    }
424    if export.registered {
425        let _ = writeln!(out, "{pad}  registered as a custom element");
426    }
427    if export.entries_incomplete {
428        let _ = writeln!(
429            out,
430            "{pad}  the entry files of its package are not fully known, so its exports are not judged"
431        );
432    }
433    let verdict = match export.verdict {
434        Verdict::Public => "public",
435        Verdict::Used => "used by another module",
436        Verdict::NotJudged => "not judged: no known module uses it, but its package's entries are incomplete",
437        _ => "not used by another module",
438    };
439    let _ = writeln!(out, "{pad}=> {verdict}");
440}
441
442fn describe_name(name: &ImportedName) -> String {
443    match &name.kind {
444        ImportedKind::Default => "default".to_owned(),
445        ImportedKind::Named(export) => export.clone(),
446        ImportedKind::Namespace if name.escapes => "the namespace, used as a whole".to_owned(),
447        ImportedKind::Namespace => format!("the namespace, members {}", name.accessed_members.join(", ")),
448    }
449}
450
451/// The modules an import reaches.
452fn reached(target: &Target) -> &[ModuleId] {
453    match target {
454        Target::Module(id) => std::slice::from_ref(id),
455        Target::Modules(ids) => ids.as_slice(),
456        _ => &[],
457    }
458}
459
460/// Whether `import` uses export `export` of the module it points at, or the module as a whole.
461fn uses_export(import: &Import, export: &str) -> Option<String> {
462    if matches!(
463        import.kind,
464        ImportKind::ExportFrom | ImportKind::SideEffect | ImportKind::Mock
465    ) {
466        return None;
467    }
468    if import.names.is_empty() {
469        return Some("the whole module".to_owned());
470    }
471    import.names.iter().find_map(|name| {
472        let hit = match &name.kind {
473            ImportedKind::Default => export == "default",
474            ImportedKind::Named(imported) => imported == export,
475            ImportedKind::Namespace => name.escapes || name.accessed_members.iter().any(|member| member == export),
476        };
477        hit.then(|| describe_name(name))
478    })
479}
480
481/// What one explanation shares: the modules an entry reaches and the exports already explained.
482struct Walk {
483    reachable: HashSet<ModuleId>,
484    seen: HashSet<(ModuleId, String)>,
485}
486
487#[allow(clippy::trivially_copy_pass_by_ref)]
488fn is_false(value: &bool) -> bool {
489    !*value
490}
491
492fn slashed(path: &Path) -> String {
493    path.to_string_lossy().replace('\\', "/")
494}
495
496impl JsAnalysis<'_> {
497    /// A report that says why `file` (relative to the workspace root) is reachable and, with `export`, why that
498    /// export of it counts as used or what would have to use it.
499    #[must_use]
500    pub fn explain_usage(&self, file: &Path, export: Option<&str>) -> UsageReport {
501        let Some(module) = self
502            .graph
503            .modules()
504            .iter()
505            .find(|module| module.path == file || module.abs == file)
506        else {
507            let unused = self.unused_files.iter().find(|unused| unused.path == file);
508            let (absence, incomplete, incomplete_scope) = if let Some(unused) = unused {
509                let (incomplete, scope) = self.narrowed_scope(unused.owner);
510                (Absence::UnusedFile, incomplete, scope)
511            } else {
512                let (absence, incomplete) = self.absence_of(file);
513                (absence, incomplete, Vec::new())
514            };
515            let widened_by = (absence == Absence::EntriesIncomplete)
516                .then(|| self.widened_by(file))
517                .flatten();
518            return UsageReport {
519                file: slashed(file),
520                in_graph: false,
521                absence: Some(absence),
522                incomplete,
523                incomplete_scope,
524                widened_by,
525                entry: None,
526                imported_by: Vec::new(),
527                chain_from_entry: None,
528                export: None,
529            };
530        };
531        let mut walk = Walk {
532            reachable: self.reachable_from_entries(),
533            seen: HashSet::new(),
534        };
535        let chain = self
536            .chain_to_entry(module.id)
537            .map(|chain| chain.iter().map(|id| slashed(&self.graph.module(*id).path)).collect());
538        let entry = module.is_entry.then(|| self.entry_reason(module));
539        let (incomplete, incomplete_scope) = if entry == Some(EntryReason::IncompleteScope) {
540            self.narrowed_scope(module.owner)
541        } else {
542            (Vec::new(), Vec::new())
543        };
544        UsageReport {
545            file: slashed(&module.path),
546            in_graph: true,
547            absence: None,
548            incomplete,
549            incomplete_scope,
550            widened_by: None,
551            entry,
552            imported_by: self.importers(module, &walk),
553            chain_from_entry: chain,
554            export: export.map(|name| self.explain_export(module, name, 0, &mut walk)),
555        }
556    }
557
558    pub(crate) fn reachable_from_entries(&self) -> HashSet<ModuleId> {
559        let modules = self.graph.modules();
560        let mut queue: VecDeque<ModuleId> = modules
561            .iter()
562            .filter(|module| module.is_entry)
563            .map(|module| module.id)
564            .collect();
565        let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
566        while let Some(current) = queue.pop_front() {
567            for resolved in &modules[current.index()].imports {
568                for next in reached(&resolved.target) {
569                    if seen.insert(*next) {
570                        queue.push_back(*next);
571                    }
572                }
573            }
574        }
575        seen
576    }
577
578    /// Which entry rule makes `module` an entry file.
579    fn entry_reason(&self, module: &Module) -> EntryReason {
580        if let Some(package) = self.setup.package(module.owner)
581            && let Some(rel) = relative(&module.abs, &package.dir)
582        {
583            if package.entry.is_match(&rel) {
584                return EntryReason::EntryGlob;
585            }
586            if package.config_files.contains(&module.abs) {
587                return EntryReason::ConfigFile;
588            }
589            if self.is_script_file(&module.abs) {
590                return EntryReason::Script;
591            }
592            if self.is_published(module, &package.dir, &rel) {
593                return EntryReason::Published;
594            }
595            if package.in_incomplete_scope(&rel) {
596                return EntryReason::IncompleteScope;
597            }
598        }
599        let path = slashed(&module.path);
600        let external = self.workspace.iter().any(|(id, _)| {
601            id != module.owner
602                && self
603                    .setup
604                    .package(id)
605                    .is_some_and(|other| other.external_entry.is_match(&path))
606        });
607        if external {
608            EntryReason::ExternalEntryGlob
609        } else {
610            EntryReason::Unknown
611        }
612    }
613
614    /// Whether a script of any package runs `abs`. The paths scripts name are collapsed as the entry files are, so
615    /// `./scripts/a.ts` and `../../scripts/a.ts` from another package name the file.
616    fn is_script_file(&self, abs: &Path) -> bool {
617        self.workspace.iter().any(|(id, _)| {
618            self.setup.package(id).is_some_and(|package| {
619                package
620                    .scripts
621                    .files
622                    .iter()
623                    .any(|file| join_normalized(&package.dir, file) == abs)
624            })
625        })
626    }
627
628    /// Why `file` (relative to the workspace root, or absolute), which is neither in the graph nor reported unused,
629    /// is not analysed. The checks follow the `unused-files` filter, with what discovery, the project globs and the
630    /// ignores leave out ahead of incomplete entries: those only explain a source file the analysis would judge.
631    fn absence_of(&self, file: &Path) -> (Absence, Vec<IncompleteEntries>) {
632        let root = &self.workspace.root;
633        let abs = if file.is_absolute() {
634            file.to_path_buf()
635        } else {
636            join_normalized(root, &slashed(file))
637        };
638        let owner = self
639            .workspace
640            .iter()
641            .map(|(id, package)| (id, root.join(&package.dir)))
642            .filter(|(_, dir)| abs.starts_with(dir))
643            .max_by_key(|(_, dir)| dir.components().count())
644            .map(|(id, _)| id);
645        let Some(owner) = owner else {
646            return (Absence::NotDiscovered, Vec::new());
647        };
648        let Some(package) = self.setup.package(owner) else {
649            return (Absence::PackageExcluded, Vec::new());
650        };
651        let Some(rel) = relative(&abs, root) else {
652            return (Absence::NotDiscovered, Vec::new());
653        };
654        if !self.outside_graph.contains(&rel) {
655            return (Absence::NotDiscovered, Vec::new());
656        }
657        if rel.ends_with(".d.ts") {
658            return (Absence::DeclarationFile, Vec::new());
659        }
660        let Some(in_package) = relative(&abs, &package.dir) else {
661            return (Absence::OutsideProject, Vec::new());
662        };
663        if !package.project.is_match(&in_package) {
664            return (Absence::OutsideProject, Vec::new());
665        }
666        if package.ignore.matches_any(&in_package) || self.setup.global_ignore.matches_any(&rel) {
667            return (Absence::Ignored, Vec::new());
668        }
669        if package.unjudged_package_wide() {
670            return (Absence::EntriesIncomplete, self.incomplete_reasons(package));
671        }
672        (Absence::NotDiscovered, Vec::new())
673    }
674
675    /// Where the package owning `file` imports a computed path that made its narrowed scope unusable, as `file:line`.
676    fn widened_by(&self, file: &Path) -> Option<String> {
677        let root = &self.workspace.root;
678        let abs = if file.is_absolute() {
679            file.to_path_buf()
680        } else {
681            join_normalized(root, &slashed(file))
682        };
683        self.workspace
684            .iter()
685            .filter(|(_, package)| abs.starts_with(root.join(&package.dir)))
686            .max_by_key(|(_, package)| root.join(&package.dir).components().count())
687            .and_then(|(id, _)| self.setup.package(id))
688            .and_then(|package| package.widened_by.as_ref())
689            .map(|(path, line)| format!("{}:{line}", slashed(path)))
690    }
691
692    /// Why the entries of `package` are incomplete, with files relative to the workspace root.
693    fn incomplete_reasons(&self, package: &PackageSetup) -> Vec<IncompleteEntries> {
694        let root = &self.workspace.root;
695        package
696            .incomplete_reasons
697            .iter()
698            .cloned()
699            .map(|mut reason| {
700                reason.file = reason
701                    .file
702                    .map(|path| relative(&path, root).map_or(path, std::path::PathBuf::from));
703                reason
704            })
705            .collect()
706    }
707
708    /// The reasons and the narrowed scope of package `owner`, when its entries are incomplete within a scope; empty
709    /// otherwise.
710    fn narrowed_scope(&self, owner: PackageId) -> (Vec<IncompleteEntries>, Vec<String>) {
711        match self.setup.package(owner) {
712            Some(package) if package.entries_incomplete => package.incomplete_scope.as_ref().map_or_else(
713                || (Vec::new(), Vec::new()),
714                |scope| (self.incomplete_reasons(package), scope.patterns.clone()),
715            ),
716            _ => (Vec::new(), Vec::new()),
717        }
718    }
719
720    /// Whether the manifest of the owning package publishes `module` (`rel` is relative to the package directory).
721    fn is_published(&self, module: &Module, package_dir: &Path, rel: &str) -> bool {
722        let Some(package) = self.workspace.package(module.owner) else {
723            return false;
724        };
725        manifest_targets(package.manifest.value()).into_iter().any(|target| {
726            if target.path.contains('*') {
727                let globs = Globs::new(
728                    source_patterns(&target.path)
729                        .into_iter()
730                        .chain(std::iter::once(target.path.trim_start_matches("./").to_owned()))
731                        .map(|pattern| {
732                            if target.from_exports {
733                                crossing_stars(&pattern)
734                            } else {
735                                pattern
736                            }
737                        }),
738                );
739                globs.is_match(rel)
740            } else {
741                source_candidates(&target.path)
742                    .into_iter()
743                    .any(|candidate| package_dir.join(candidate) == module.abs)
744            }
745        })
746    }
747
748    fn importers(&self, module: &Module, walk: &Walk) -> Vec<Importer> {
749        let mut found = Vec::new();
750        for importer_id in self.graph.imported_by(module.id) {
751            let importer = self.graph.module(*importer_id);
752            for resolved in &importer.imports {
753                if !reached(&resolved.target).contains(&module.id) {
754                    continue;
755                }
756                if let Some(import) = importer.info.imports.get(resolved.import) {
757                    found.push(Importer {
758                        file: slashed(&importer.path),
759                        line: import.span.start_line,
760                        kind: import.kind,
761                        names: import.names.iter().map(describe_name).collect(),
762                        reachable: walk.reachable.contains(importer_id),
763                    });
764                }
765            }
766        }
767        found
768    }
769
770    /// The shortest chain of imports from an entry file to `id`, entry first.
771    fn chain_to_entry(&self, id: ModuleId) -> Option<Vec<ModuleId>> {
772        let modules = self.graph.modules();
773        let mut previous: HashMap<ModuleId, ModuleId> = HashMap::new();
774        let mut queue: VecDeque<ModuleId> = modules
775            .iter()
776            .filter(|module| module.is_entry)
777            .map(|module| module.id)
778            .collect();
779        let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
780        while let Some(current) = queue.pop_front() {
781            if current == id {
782                let mut chain = vec![id];
783                let mut at = id;
784                while let Some(before) = previous.get(&at) {
785                    chain.push(*before);
786                    at = *before;
787                }
788                chain.reverse();
789                return Some(chain);
790            }
791            for resolved in &modules[current.index()].imports {
792                for next in reached(&resolved.target) {
793                    if seen.insert(*next) {
794                        previous.insert(*next, current);
795                        queue.push_back(*next);
796                    }
797                }
798            }
799        }
800        None
801    }
802
803    fn explain_export(&self, module: &Module, name: &str, depth: usize, walk: &mut Walk) -> ExportReport {
804        let mut report = ExportReport {
805            name: name.to_owned(),
806            file: slashed(&module.path),
807            verdict: Verdict::NotAnExport,
808            entry_public: false,
809            tagged_public: false,
810            users: Vec::new(),
811            re_exports: Vec::new(),
812            in_used_signature: false,
813            is_re_export: false,
814            registered: false,
815            entries_incomplete: false,
816            already_explained: false,
817        };
818        let Some(export) = module.info.exports.iter().find(|export| export.name == name) else {
819            return report;
820        };
821        if !walk.seen.insert((module.id, name.to_owned())) {
822            report.already_explained = true;
823            report.verdict = Verdict::Unused;
824            return report;
825        }
826        report.entry_public = module.is_entry && !self.setup.includes_entry_exports(module.owner);
827        report.tagged_public = self.is_tagged_public(export);
828        report.is_re_export = matches!(export.kind, ExportKind::ReExport | ExportKind::StarReExport);
829        report.entries_incomplete = self.entries_incomplete(module.owner);
830        for importer_id in self.graph.imported_by(module.id) {
831            let importer = self.graph.module(*importer_id);
832            for resolved in &importer.imports {
833                if !reached(&resolved.target).contains(&module.id) {
834                    continue;
835                }
836                let Some(import) = importer.info.imports.get(resolved.import) else {
837                    continue;
838                };
839                if let Some(how) = uses_export(import, name) {
840                    report.users.push(ExportUser {
841                        file: slashed(&importer.path),
842                        line: import.span.start_line,
843                        how,
844                        reachable: walk.reachable.contains(importer_id),
845                    });
846                }
847            }
848            if depth < MAX_DEPTH {
849                self.explain_reexports(importer, module, name, depth, walk, &mut report.re_exports);
850            }
851        }
852        report.registered = self.setup.is_registered(module.owner, export);
853        let used = report.registered || self.exports.is_used(module.id, name);
854        report.in_used_signature = !used && self.in_used_signature(module, export, &mut HashSet::new());
855        report.verdict = if report.entry_public || report.tagged_public {
856            Verdict::Public
857        } else if used || report.in_used_signature {
858            Verdict::Used
859        } else if report.entries_incomplete {
860            Verdict::NotJudged
861        } else {
862            Verdict::Unused
863        };
864        report
865    }
866
867    /// Follows the re-exports of `importer` that forward export `name` of `module`.
868    fn explain_reexports(
869        &self,
870        importer: &Module,
871        module: &Module,
872        name: &str,
873        depth: usize,
874        walk: &mut Walk,
875        out: &mut Vec<ReExport>,
876    ) {
877        for export in &importer.info.exports {
878            let Some(from) = export.from.as_deref() else {
879                continue;
880            };
881            if importer.reexport_source(from) != Some(module.id) {
882                continue;
883            }
884            let forwarded = match export.kind {
885                ExportKind::ReExport => export.imported_name.as_deref().unwrap_or(&export.name) == name,
886                ExportKind::StarReExport if export.name == "*" => {
887                    name != "default"
888                        && !importer
889                            .info
890                            .exports
891                            .iter()
892                            .any(|own| own.name == name && own.from.is_none())
893                }
894                ExportKind::StarReExport => true,
895                _ => false,
896            };
897            if !forwarded {
898                continue;
899            }
900            let exposed = if export.kind == ExportKind::StarReExport && export.name == "*" {
901                name
902            } else {
903                export.name.as_str()
904            };
905            out.push(ReExport {
906                file: slashed(&importer.path),
907                name: exposed.to_owned(),
908                export: self.explain_export(importer, exposed, depth + 1, walk),
909            });
910        }
911    }
912}