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