Skip to main content

fallow_types/
results.rs

1//! Analysis result types for all issue categories.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::output_dead_code::{
9    BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
10    EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeakFinding,
11    TestOnlyDependencyFinding, TypeOnlyDependencyFinding, UnlistedDependencyFinding,
12    UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
13    UnusedClassMemberFinding, UnusedDependencyFinding, UnusedDependencyOverrideFinding,
14    UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
15    UnusedOptionalDependencyFinding, UnusedTypeFinding,
16};
17use crate::serde_path;
18use crate::suppress::IssueKind;
19
20/// Summary of detected entry points, grouped by discovery source.
21///
22/// Used to surface entry-point detection status in human and JSON output,
23/// so library authors can verify that fallow found the right entry points.
24#[derive(Debug, Clone, Default)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct EntryPointSummary {
27    /// Total number of entry points detected.
28    pub total: usize,
29    /// Breakdown by source category (e.g., "package.json" -> 3, "plugin" -> 12).
30    /// Sorted by key for deterministic output.
31    pub by_source: Vec<(String, usize)>,
32}
33
34/// Complete analysis results.
35///
36/// # Examples
37///
38/// ```
39/// use fallow_types::output_dead_code::UnusedFileFinding;
40/// use fallow_types::results::{AnalysisResults, UnusedFile};
41/// use std::path::PathBuf;
42///
43/// let mut results = AnalysisResults::default();
44/// assert_eq!(results.total_issues(), 0);
45/// assert!(!results.has_issues());
46///
47/// results
48///     .unused_files
49///     .push(UnusedFileFinding::with_actions(UnusedFile {
50///         path: PathBuf::from("src/dead.ts"),
51///     }));
52/// assert_eq!(results.total_issues(), 1);
53/// assert!(results.has_issues());
54/// ```
55#[derive(Debug, Default, Clone, Serialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57pub struct AnalysisResults {
58    /// Files not reachable from any entry point. Wrapped in
59    /// [`UnusedFileFinding`] so each entry carries a typed `actions` array
60    /// natively, replacing the pre-2.76 post-pass injection.
61    pub unused_files: Vec<UnusedFileFinding>,
62    /// Exports never imported by other modules. Wrapped in
63    /// [`UnusedExportFinding`] so each entry carries a typed `actions`
64    /// array natively.
65    pub unused_exports: Vec<UnusedExportFinding>,
66    /// Type exports never imported by other modules. Wrapped in
67    /// [`UnusedTypeFinding`]: the inner [`UnusedExport`] struct is shared
68    /// with `unused_exports` but the wrapper emits a type-targeted fix
69    /// description.
70    pub unused_types: Vec<UnusedTypeFinding>,
71    /// Exported symbols whose public signature references same-file private
72    /// types. Wrapped in [`PrivateTypeLeakFinding`] so each entry carries a
73    /// typed `actions` array natively.
74    pub private_type_leaks: Vec<PrivateTypeLeakFinding>,
75    /// Dependencies listed in package.json but never imported. Wrapped in
76    /// [`UnusedDependencyFinding`] so each entry carries a typed `actions`
77    /// array natively. The fix action swaps from `remove-dependency` to
78    /// `move-dependency` when `used_in_workspaces` is non-empty.
79    pub unused_dependencies: Vec<UnusedDependencyFinding>,
80    /// Dev dependencies listed in package.json but never imported. Wrapped
81    /// in [`UnusedDevDependencyFinding`]: same bare struct as
82    /// `unused_dependencies` with a `devDependencies`-targeted fix
83    /// description.
84    pub unused_dev_dependencies: Vec<UnusedDevDependencyFinding>,
85    /// Optional dependencies listed in package.json but never imported.
86    /// Wrapped in [`UnusedOptionalDependencyFinding`] with an
87    /// `optionalDependencies`-targeted fix description.
88    pub unused_optional_dependencies: Vec<UnusedOptionalDependencyFinding>,
89    /// Enum members never accessed. Wrapped in
90    /// [`UnusedEnumMemberFinding`] so each entry carries a typed `actions`
91    /// array natively.
92    pub unused_enum_members: Vec<UnusedEnumMemberFinding>,
93    /// Class members never accessed. Wrapped in
94    /// [`UnusedClassMemberFinding`]: same inner [`UnusedMember`] struct as
95    /// `unused_enum_members`, with a class-targeted fix description and the
96    /// `auto_fixable: false` default to reflect dependency-injection
97    /// patterns.
98    pub unused_class_members: Vec<UnusedClassMemberFinding>,
99    /// Import specifiers that could not be resolved. Wrapped in
100    /// [`UnresolvedImportFinding`] so each entry carries a typed `actions`
101    /// array natively.
102    pub unresolved_imports: Vec<UnresolvedImportFinding>,
103    /// Dependencies used in code but not listed in package.json. Wrapped in
104    /// [`UnlistedDependencyFinding`].
105    pub unlisted_dependencies: Vec<UnlistedDependencyFinding>,
106    /// Exports with the same name across multiple modules. Wrapped in
107    /// [`DuplicateExportFinding`] so each entry carries a typed `actions`
108    /// array natively, with the position-0 `add-to-config` `ignoreExports`
109    /// snippet wired in at wrapper construction.
110    pub duplicate_exports: Vec<DuplicateExportFinding>,
111    /// Production dependencies only used via type-only imports (could be
112    /// devDependencies). Only populated in production mode. Wrapped in
113    /// [`TypeOnlyDependencyFinding`].
114    pub type_only_dependencies: Vec<TypeOnlyDependencyFinding>,
115    /// Production dependencies only imported by test files (could be
116    /// devDependencies). Wrapped in [`TestOnlyDependencyFinding`].
117    #[serde(default)]
118    pub test_only_dependencies: Vec<TestOnlyDependencyFinding>,
119    /// Circular dependency chains detected in the module graph. Wrapped in
120    /// [`CircularDependencyFinding`] so each entry carries a typed `actions`
121    /// array natively.
122    pub circular_dependencies: Vec<CircularDependencyFinding>,
123    /// Imports that cross architecture boundary rules. Wrapped in
124    /// [`BoundaryViolationFinding`] so each entry carries a typed `actions`
125    /// array natively.
126    #[serde(default)]
127    pub boundary_violations: Vec<BoundaryViolationFinding>,
128    /// Suppression comments or JSDoc tags that no longer match any issue.
129    #[serde(default)]
130    pub stale_suppressions: Vec<StaleSuppression>,
131    /// Entries in pnpm-workspace.yaml's catalog: or catalogs: sections not
132    /// referenced by any workspace package via the catalog: protocol. Wrapped
133    /// in [`UnusedCatalogEntryFinding`] so each entry carries a typed
134    /// `actions` array natively, with per-instance `auto_fixable` derived
135    /// from `hardcoded_consumers`.
136    #[serde(default)]
137    pub unused_catalog_entries: Vec<UnusedCatalogEntryFinding>,
138    /// Named groups under pnpm-workspace.yaml's catalogs: section that declare
139    /// no package entries. The top-level catalog: map is not reported. Wrapped
140    /// in [`EmptyCatalogGroupFinding`].
141    #[serde(default)]
142    pub empty_catalog_groups: Vec<EmptyCatalogGroupFinding>,
143    /// Workspace package.json references to catalogs (`catalog:` or
144    /// `catalog:<name>`) that do not declare the consumed package. pnpm install
145    /// will error until the named catalog grows to include the package or the
146    /// reference is switched / removed. Wrapped in
147    /// [`UnresolvedCatalogReferenceFinding`] with the discriminated
148    /// `add-catalog-entry` / `update-catalog-reference` primary at position 0.
149    #[serde(default)]
150    pub unresolved_catalog_references: Vec<UnresolvedCatalogReferenceFinding>,
151    /// Entries in pnpm-workspace.yaml's overrides: section, or package.json's
152    /// pnpm.overrides block, whose target package is not declared by any
153    /// workspace package and is not present in pnpm-lock.yaml. Default severity
154    /// is warn because projects without a readable lockfile fall back to
155    /// manifest-only checks; the hint field flags those conservative cases.
156    /// Wrapped in [`UnusedDependencyOverrideFinding`].
157    #[serde(default)]
158    pub unused_dependency_overrides: Vec<UnusedDependencyOverrideFinding>,
159    /// pnpm.overrides entries whose key or value does not parse as a valid
160    /// override spec (empty key, empty value, malformed selector, unbalanced
161    /// parent matcher). pnpm install will reject these. Default severity is
162    /// error. Wrapped in [`MisconfiguredDependencyOverrideFinding`].
163    #[serde(default)]
164    pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverrideFinding>,
165    /// Number of suppression entries that matched an issue during analysis.
166    /// Human output uses this for the suppression footer; it is skipped in
167    /// machine output to avoid changing the public JSON issue contract.
168    #[serde(skip)]
169    pub suppression_count: usize,
170    /// Detected feature flag patterns. Advisory output, not included in issue counts.
171    /// Skipped during default serialization: injected separately in JSON output when enabled.
172    #[serde(skip)]
173    pub feature_flags: Vec<FeatureFlag>,
174    /// Usage counts for all exports across the project. Used by the LSP for Code Lens.
175    /// Not included in issue counts -- this is metadata, not an issue type.
176    /// Skipped during serialization: this is internal LSP data, not part of the JSON output schema.
177    #[serde(skip)]
178    pub export_usages: Vec<ExportUsage>,
179    /// Summary of detected entry points, grouped by discovery source.
180    /// Not included in issue counts -- this is informational metadata.
181    /// Skipped during serialization: rendered separately in JSON output.
182    #[serde(skip)]
183    pub entry_point_summary: Option<EntryPointSummary>,
184}
185
186impl AnalysisResults {
187    /// Total number of issues found.
188    ///
189    /// Sums across all issue categories (unused files, exports, types,
190    /// dependencies, members, unresolved imports, unlisted deps, duplicates,
191    /// type-only deps, circular deps, and boundary violations).
192    ///
193    /// # Examples
194    ///
195    /// ```
196    /// use fallow_types::output_dead_code::{UnresolvedImportFinding, UnusedFileFinding};
197    /// use fallow_types::results::{AnalysisResults, UnresolvedImport, UnusedFile};
198    /// use std::path::PathBuf;
199    ///
200    /// let mut results = AnalysisResults::default();
201    /// results
202    ///     .unused_files
203    ///     .push(UnusedFileFinding::with_actions(UnusedFile {
204    ///         path: PathBuf::from("a.ts"),
205    ///     }));
206    /// results
207    ///     .unresolved_imports
208    ///     .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
209    ///         path: PathBuf::from("b.ts"),
210    ///         specifier: "./missing".to_string(),
211    ///         line: 1,
212    ///         col: 0,
213    ///         specifier_col: 0,
214    ///     }));
215    /// assert_eq!(results.total_issues(), 2);
216    /// ```
217    #[must_use]
218    pub const fn total_issues(&self) -> usize {
219        self.unused_files.len()
220            + self.unused_exports.len()
221            + self.unused_types.len()
222            + self.private_type_leaks.len()
223            + self.unused_dependencies.len()
224            + self.unused_dev_dependencies.len()
225            + self.unused_optional_dependencies.len()
226            + self.unused_enum_members.len()
227            + self.unused_class_members.len()
228            + self.unresolved_imports.len()
229            + self.unlisted_dependencies.len()
230            + self.duplicate_exports.len()
231            + self.type_only_dependencies.len()
232            + self.test_only_dependencies.len()
233            + self.circular_dependencies.len()
234            + self.boundary_violations.len()
235            + self.stale_suppressions.len()
236            + self.unused_catalog_entries.len()
237            + self.empty_catalog_groups.len()
238            + self.unresolved_catalog_references.len()
239            + self.unused_dependency_overrides.len()
240            + self.misconfigured_dependency_overrides.len()
241    }
242
243    /// Whether any issues were found.
244    #[must_use]
245    pub const fn has_issues(&self) -> bool {
246        self.total_issues() > 0
247    }
248
249    /// Sort all result arrays for deterministic output ordering.
250    ///
251    /// Parallel collection (rayon, `FxHashMap` iteration) does not guarantee
252    /// insertion order, so the same project can produce different orderings
253    /// across runs. This method canonicalises every result list by sorting on
254    /// (path, line, col, name) so that JSON/SARIF/human output is stable.
255    #[expect(
256        clippy::too_many_lines,
257        reason = "one short sort_by per result array; splitting would add indirection without clarity"
258    )]
259    pub fn sort(&mut self) {
260        self.unused_files
261            .sort_by(|a, b| a.file.path.cmp(&b.file.path));
262
263        self.unused_exports.sort_by(|a, b| {
264            a.export
265                .path
266                .cmp(&b.export.path)
267                .then(a.export.line.cmp(&b.export.line))
268                .then(a.export.export_name.cmp(&b.export.export_name))
269        });
270
271        self.unused_types.sort_by(|a, b| {
272            a.export
273                .path
274                .cmp(&b.export.path)
275                .then(a.export.line.cmp(&b.export.line))
276                .then(a.export.export_name.cmp(&b.export.export_name))
277        });
278
279        self.private_type_leaks.sort_by(|a, b| {
280            a.leak
281                .path
282                .cmp(&b.leak.path)
283                .then(a.leak.line.cmp(&b.leak.line))
284                .then(a.leak.export_name.cmp(&b.leak.export_name))
285                .then(a.leak.type_name.cmp(&b.leak.type_name))
286        });
287
288        self.unused_dependencies.sort_by(|a, b| {
289            a.dep
290                .path
291                .cmp(&b.dep.path)
292                .then(a.dep.line.cmp(&b.dep.line))
293                .then(a.dep.package_name.cmp(&b.dep.package_name))
294        });
295
296        self.unused_dev_dependencies.sort_by(|a, b| {
297            a.dep
298                .path
299                .cmp(&b.dep.path)
300                .then(a.dep.line.cmp(&b.dep.line))
301                .then(a.dep.package_name.cmp(&b.dep.package_name))
302        });
303
304        self.unused_optional_dependencies.sort_by(|a, b| {
305            a.dep
306                .path
307                .cmp(&b.dep.path)
308                .then(a.dep.line.cmp(&b.dep.line))
309                .then(a.dep.package_name.cmp(&b.dep.package_name))
310        });
311
312        self.unused_enum_members.sort_by(|a, b| {
313            a.member
314                .path
315                .cmp(&b.member.path)
316                .then(a.member.line.cmp(&b.member.line))
317                .then(a.member.parent_name.cmp(&b.member.parent_name))
318                .then(a.member.member_name.cmp(&b.member.member_name))
319        });
320
321        self.unused_class_members.sort_by(|a, b| {
322            a.member
323                .path
324                .cmp(&b.member.path)
325                .then(a.member.line.cmp(&b.member.line))
326                .then(a.member.parent_name.cmp(&b.member.parent_name))
327                .then(a.member.member_name.cmp(&b.member.member_name))
328        });
329
330        self.unresolved_imports.sort_by(|a, b| {
331            a.import
332                .path
333                .cmp(&b.import.path)
334                .then(a.import.line.cmp(&b.import.line))
335                .then(a.import.col.cmp(&b.import.col))
336                .then(a.import.specifier.cmp(&b.import.specifier))
337        });
338
339        self.unlisted_dependencies
340            .sort_by(|a, b| a.dep.package_name.cmp(&b.dep.package_name));
341        for dep in &mut self.unlisted_dependencies {
342            dep.dep
343                .imported_from
344                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
345        }
346
347        self.duplicate_exports
348            .sort_by(|a, b| a.export.export_name.cmp(&b.export.export_name));
349        for dup in &mut self.duplicate_exports {
350            dup.export
351                .locations
352                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
353        }
354
355        self.type_only_dependencies.sort_by(|a, b| {
356            a.dep
357                .path
358                .cmp(&b.dep.path)
359                .then(a.dep.line.cmp(&b.dep.line))
360                .then(a.dep.package_name.cmp(&b.dep.package_name))
361        });
362
363        self.test_only_dependencies.sort_by(|a, b| {
364            a.dep
365                .path
366                .cmp(&b.dep.path)
367                .then(a.dep.line.cmp(&b.dep.line))
368                .then(a.dep.package_name.cmp(&b.dep.package_name))
369        });
370
371        self.circular_dependencies.sort_by(|a, b| {
372            a.cycle
373                .files
374                .cmp(&b.cycle.files)
375                .then(a.cycle.length.cmp(&b.cycle.length))
376        });
377
378        self.boundary_violations.sort_by(|a, b| {
379            a.violation
380                .from_path
381                .cmp(&b.violation.from_path)
382                .then(a.violation.line.cmp(&b.violation.line))
383                .then(a.violation.col.cmp(&b.violation.col))
384                .then(a.violation.to_path.cmp(&b.violation.to_path))
385        });
386
387        self.stale_suppressions.sort_by(|a, b| {
388            a.path
389                .cmp(&b.path)
390                .then(a.line.cmp(&b.line))
391                .then(a.col.cmp(&b.col))
392        });
393
394        self.unused_catalog_entries.sort_by(|a, b| {
395            a.entry
396                .path
397                .cmp(&b.entry.path)
398                .then_with(|| {
399                    catalog_sort_key(&a.entry.catalog_name)
400                        .cmp(&catalog_sort_key(&b.entry.catalog_name))
401                })
402                .then(a.entry.catalog_name.cmp(&b.entry.catalog_name))
403                .then(a.entry.entry_name.cmp(&b.entry.entry_name))
404        });
405        for finding in &mut self.unused_catalog_entries {
406            finding.entry.hardcoded_consumers.sort();
407            finding.entry.hardcoded_consumers.dedup();
408        }
409
410        self.empty_catalog_groups.sort_by(|a, b| {
411            a.group
412                .path
413                .cmp(&b.group.path)
414                .then_with(|| {
415                    catalog_sort_key(&a.group.catalog_name)
416                        .cmp(&catalog_sort_key(&b.group.catalog_name))
417                })
418                .then(a.group.catalog_name.cmp(&b.group.catalog_name))
419                .then(a.group.line.cmp(&b.group.line))
420        });
421
422        self.unresolved_catalog_references.sort_by(|a, b| {
423            a.reference
424                .path
425                .cmp(&b.reference.path)
426                .then(a.reference.line.cmp(&b.reference.line))
427                .then_with(|| {
428                    catalog_sort_key(&a.reference.catalog_name)
429                        .cmp(&catalog_sort_key(&b.reference.catalog_name))
430                })
431                .then(a.reference.catalog_name.cmp(&b.reference.catalog_name))
432                .then(a.reference.entry_name.cmp(&b.reference.entry_name))
433        });
434        for finding in &mut self.unresolved_catalog_references {
435            finding.reference.available_in_catalogs.sort();
436            finding.reference.available_in_catalogs.dedup();
437        }
438
439        self.unused_dependency_overrides.sort_by(|a, b| {
440            a.entry
441                .path
442                .cmp(&b.entry.path)
443                .then(a.entry.line.cmp(&b.entry.line))
444                .then(a.entry.raw_key.cmp(&b.entry.raw_key))
445        });
446
447        self.misconfigured_dependency_overrides.sort_by(|a, b| {
448            a.entry
449                .path
450                .cmp(&b.entry.path)
451                .then(a.entry.line.cmp(&b.entry.line))
452                .then(a.entry.raw_key.cmp(&b.entry.raw_key))
453        });
454
455        self.feature_flags.sort_by(|a, b| {
456            a.path
457                .cmp(&b.path)
458                .then(a.line.cmp(&b.line))
459                .then(a.flag_name.cmp(&b.flag_name))
460        });
461
462        for usage in &mut self.export_usages {
463            usage.reference_locations.sort_by(|a, b| {
464                a.path
465                    .cmp(&b.path)
466                    .then(a.line.cmp(&b.line))
467                    .then(a.col.cmp(&b.col))
468            });
469        }
470        self.export_usages.sort_by(|a, b| {
471            a.path
472                .cmp(&b.path)
473                .then(a.line.cmp(&b.line))
474                .then(a.export_name.cmp(&b.export_name))
475        });
476    }
477}
478
479/// Sort key for catalog names: the default catalog ("default") sorts before any named catalog.
480fn catalog_sort_key(name: &str) -> (u8, &str) {
481    if name == "default" {
482        (0, name)
483    } else {
484        (1, name)
485    }
486}
487
488/// A file that is not reachable from any entry point.
489#[derive(Debug, Clone, Serialize)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491pub struct UnusedFile {
492    /// Absolute path to the unused file.
493    #[serde(serialize_with = "serde_path::serialize")]
494    pub path: PathBuf,
495}
496
497/// An export that is never imported by other modules.
498#[derive(Debug, Clone, Serialize)]
499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
500pub struct UnusedExport {
501    /// File containing the unused export.
502    #[serde(serialize_with = "serde_path::serialize")]
503    pub path: PathBuf,
504    /// Name of the unused export.
505    pub export_name: String,
506    /// Whether this is a type-only export.
507    pub is_type_only: bool,
508    /// 1-based line number of the export.
509    pub line: u32,
510    /// 0-based byte column offset.
511    pub col: u32,
512    /// Byte offset into the source file (used by the fix command).
513    pub span_start: u32,
514    /// Whether this finding comes from a barrel/index re-export rather than the source definition.
515    pub is_re_export: bool,
516}
517
518/// A public export signature that references a same-file private type.
519#[derive(Debug, Clone, Serialize)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521pub struct PrivateTypeLeak {
522    /// File containing the exported symbol.
523    #[serde(serialize_with = "serde_path::serialize")]
524    pub path: PathBuf,
525    /// Export whose public signature leaks the private type.
526    pub export_name: String,
527    /// Private type referenced by the public signature.
528    pub type_name: String,
529    /// 1-based line number of the leaking type reference.
530    pub line: u32,
531    /// 0-based byte column offset.
532    pub col: u32,
533    /// Byte offset of the type reference.
534    pub span_start: u32,
535}
536
537/// A dependency that is listed in package.json but never imported.
538#[derive(Debug, Clone, Serialize)]
539#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
540pub struct UnusedDependency {
541    /// Package name, including internal workspace package names.
542    pub package_name: String,
543    /// Whether this is in `dependencies`, `devDependencies`, or `optionalDependencies`.
544    pub location: DependencyLocation,
545    /// Path to the package.json where this dependency is listed.
546    /// For root deps this is `<root>/package.json`, for workspace deps it is `<ws>/package.json`.
547    #[serde(serialize_with = "serde_path::serialize")]
548    pub path: PathBuf,
549    /// 1-based line number of the dependency entry in package.json.
550    pub line: u32,
551    /// Workspace roots that import this package even though the declaring workspace does not.
552    #[serde(
553        serialize_with = "serde_path::serialize_vec",
554        skip_serializing_if = "Vec::is_empty"
555    )]
556    #[cfg_attr(feature = "schema", schemars(default))]
557    pub used_in_workspaces: Vec<PathBuf>,
558}
559
560/// Where in package.json a dependency is listed.
561///
562/// # Examples
563///
564/// ```
565/// use fallow_types::results::DependencyLocation;
566///
567/// // All three variants are constructible
568/// let loc = DependencyLocation::Dependencies;
569/// let dev = DependencyLocation::DevDependencies;
570/// let opt = DependencyLocation::OptionalDependencies;
571/// // Debug output includes the variant name
572/// assert!(format!("{loc:?}").contains("Dependencies"));
573/// assert!(format!("{dev:?}").contains("DevDependencies"));
574/// assert!(format!("{opt:?}").contains("OptionalDependencies"));
575/// ```
576#[derive(Debug, Clone, Serialize)]
577#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
578#[serde(rename_all = "camelCase")]
579pub enum DependencyLocation {
580    /// Listed in `dependencies`.
581    Dependencies,
582    /// Listed in `devDependencies`.
583    DevDependencies,
584    /// Listed in `optionalDependencies`.
585    OptionalDependencies,
586}
587
588/// An unused enum or class member.
589#[derive(Debug, Clone, Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub struct UnusedMember {
592    /// File containing the unused member.
593    #[serde(serialize_with = "serde_path::serialize")]
594    pub path: PathBuf,
595    /// Name of the parent enum or class.
596    pub parent_name: String,
597    /// Name of the unused member.
598    pub member_name: String,
599    /// Whether this is an enum member, class method, or class property.
600    pub kind: MemberKind,
601    /// 1-based line number.
602    pub line: u32,
603    /// 0-based byte column offset.
604    pub col: u32,
605}
606
607/// An import that could not be resolved.
608#[derive(Debug, Clone, Serialize)]
609#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
610pub struct UnresolvedImport {
611    /// File containing the unresolved import.
612    #[serde(serialize_with = "serde_path::serialize")]
613    pub path: PathBuf,
614    /// The import specifier that could not be resolved.
615    pub specifier: String,
616    /// 1-based line number.
617    pub line: u32,
618    /// 0-based byte column offset of the import statement.
619    pub col: u32,
620    /// 0-based byte column offset of the source string literal (the specifier in quotes).
621    /// Used by the LSP to underline just the specifier, not the entire import line.
622    pub specifier_col: u32,
623}
624
625/// A dependency used in code but not listed in package.json.
626#[derive(Debug, Clone, Serialize)]
627#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
628pub struct UnlistedDependency {
629    /// Package name, including internal workspace package names, that is
630    /// imported but not listed in package.json.
631    pub package_name: String,
632    /// Import sites where this unlisted dependency is used (file path, line, column).
633    pub imported_from: Vec<ImportSite>,
634}
635
636/// A location where an import occurs.
637#[derive(Debug, Clone, Serialize)]
638#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
639pub struct ImportSite {
640    /// File containing the import.
641    #[serde(serialize_with = "serde_path::serialize")]
642    pub path: PathBuf,
643    /// 1-based line number.
644    pub line: u32,
645    /// 0-based byte column offset.
646    pub col: u32,
647}
648
649/// An export that appears multiple times across the project.
650#[derive(Debug, Clone, Serialize)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652pub struct DuplicateExport {
653    /// The duplicated export name.
654    pub export_name: String,
655    /// Locations where this export name appears.
656    pub locations: Vec<DuplicateLocation>,
657}
658
659/// A location where a duplicate export appears.
660#[derive(Debug, Clone, Serialize)]
661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
662pub struct DuplicateLocation {
663    /// File containing the duplicate export.
664    #[serde(serialize_with = "serde_path::serialize")]
665    pub path: PathBuf,
666    /// 1-based line number.
667    pub line: u32,
668    /// 0-based byte column offset.
669    pub col: u32,
670}
671
672/// A production dependency that is only used via type-only imports.
673/// In production builds, type imports are erased, so this dependency
674/// is not needed at runtime and could be moved to devDependencies.
675#[derive(Debug, Clone, Serialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677pub struct TypeOnlyDependency {
678    /// Production dependency that is only used via type-only imports.
679    pub package_name: String,
680    /// Path to the package.json where the dependency is listed.
681    #[serde(serialize_with = "serde_path::serialize")]
682    pub path: PathBuf,
683    /// 1-based line number of the dependency entry in package.json.
684    pub line: u32,
685}
686
687/// A pnpm catalog entry declared in pnpm-workspace.yaml that no workspace package
688/// references via the `catalog:` protocol.
689///
690/// The default catalog (top-level `catalog:` key) uses `catalog_name: "default"`.
691/// Named catalogs (under `catalogs.<name>:`) use their declared name.
692#[derive(Debug, Clone, Serialize)]
693#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
694pub struct UnusedCatalogEntry {
695    /// Package name declared in the catalog (e.g. `"react"`, `"@scope/lib"`).
696    pub entry_name: String,
697    /// Catalog group: `"default"` for the top-level `catalog:` map, or the
698    /// named catalog key for entries declared under `catalogs.<name>:`.
699    pub catalog_name: String,
700    /// Path to `pnpm-workspace.yaml`, relative to the analyzed root.
701    #[serde(serialize_with = "serde_path::serialize")]
702    pub path: PathBuf,
703    /// 1-based line number of the catalog entry within `pnpm-workspace.yaml`.
704    pub line: u32,
705    /// Workspace `package.json` files that declare the same package with a
706    /// hardcoded version range instead of `catalog:`. Empty when no consumer
707    /// uses a hardcoded version. Sorted lexicographically for deterministic
708    /// output.
709    #[serde(
710        default,
711        serialize_with = "serde_path::serialize_vec",
712        skip_serializing_if = "Vec::is_empty"
713    )]
714    pub hardcoded_consumers: Vec<PathBuf>,
715}
716
717/// A named `catalogs.<name>:` group in `pnpm-workspace.yaml` with no package entries.
718#[derive(Debug, Clone, Serialize)]
719#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
720pub struct EmptyCatalogGroup {
721    /// Catalog group name declared under the top-level `catalogs:` map.
722    pub catalog_name: String,
723    /// Path to `pnpm-workspace.yaml`, relative to the analyzed root.
724    #[serde(serialize_with = "serde_path::serialize")]
725    pub path: PathBuf,
726    /// 1-based line number of the empty group header within `pnpm-workspace.yaml`.
727    pub line: u32,
728}
729
730/// A workspace package.json reference (`catalog:` or `catalog:<name>`) that points
731/// at a catalog which does not declare the consumed package.
732///
733/// `pnpm install` errors at install time with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_CATALOG_PROTOCOL`
734/// when this happens. fallow surfaces it statically so the failure is caught at
735/// `fallow check` time, before any install.
736///
737/// The default catalog (bare `catalog:` references the top-level `catalog:` map)
738/// uses `catalog_name: "default"`. Named catalogs (`catalog:react17`) use the
739/// declared catalog name.
740#[derive(Debug, Clone, Serialize)]
741#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
742pub struct UnresolvedCatalogReference {
743    /// Package name being referenced via the catalog protocol (e.g. `"react"`).
744    pub entry_name: String,
745    /// Catalog group the reference points at: `"default"` for bare `catalog:` references,
746    /// or the named catalog key for `catalog:<name>` references.
747    pub catalog_name: String,
748    /// Absolute path to the consumer `package.json`. Matches the storage
749    /// convention used by every path-anchored finding type (`UnusedFile`,
750    /// `UnresolvedImport`, `UnusedExport`, etc.) so the shared filtering
751    /// pipelines (`filter_results_by_changed_files`, per-file overrides,
752    /// audit attribution) work without a separate root-join pass. JSON
753    /// output strips the project-root prefix via `serde_path::serialize`.
754    #[serde(serialize_with = "serde_path::serialize")]
755    pub path: PathBuf,
756    /// 1-based line number of the dependency entry in the consumer `package.json`.
757    pub line: u32,
758    /// Other catalogs (in the same `pnpm-workspace.yaml`) that DO declare this
759    /// package. Empty when no catalog has the package. Sorted lexicographically.
760    /// Lets agents and humans decide whether to switch the reference to a
761    /// different catalog or to add the entry to the named catalog.
762    #[serde(default, skip_serializing_if = "Vec::is_empty")]
763    pub available_in_catalogs: Vec<String>,
764}
765
766/// Where an override entry was declared. Serialized as the filename label
767/// (`"pnpm-workspace.yaml"` or `"package.json"`) so the value in JSON output
768/// matches the value users write in `ignoreDependencyOverrides[].source`.
769#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
770#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
771pub enum DependencyOverrideSource {
772    /// Top-level `overrides:` key in `pnpm-workspace.yaml`.
773    #[serde(rename = "pnpm-workspace.yaml")]
774    PnpmWorkspaceYaml,
775    /// `pnpm.overrides` in a root `package.json`.
776    #[serde(rename = "package.json")]
777    PnpmPackageJson,
778}
779
780impl DependencyOverrideSource {
781    /// Stable string label matching the serde rename. Used in baseline keys,
782    /// audit keys, jq comparisons, and `ignoreDependencyOverrides[].source`.
783    #[must_use]
784    pub const fn as_label(&self) -> &'static str {
785        match self {
786            Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
787            Self::PnpmPackageJson => "package.json",
788        }
789    }
790}
791
792impl std::fmt::Display for DependencyOverrideSource {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        f.write_str(self.as_label())
795    }
796}
797
798/// An entry in pnpm's `overrides:` map (or the legacy `pnpm.overrides` in
799/// `package.json`) whose target package is not declared in any workspace
800/// `package.json` and is not present in `pnpm-lock.yaml`. Projects without a
801/// readable lockfile fall back to package manifest checks; the `hint` field
802/// flags that conservative mode.
803#[derive(Debug, Clone, Serialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805pub struct UnusedDependencyOverride {
806    /// The full original override key as written in the source (e.g.
807    /// `"react>react-dom"`, `"@types/react@<18"`). Preserved for round-trip
808    /// reporting so agents see the unmodified spelling.
809    pub raw_key: String,
810    /// The target package the override rewrites (e.g. `"react-dom"` for
811    /// `"react>react-dom"`, `"@types/react"` for `"@types/react@<18"`).
812    pub target_package: String,
813    /// Optional parent package (left side of `>`). `None` for bare-target keys.
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub parent_package: Option<String>,
816    /// Optional version selector on the target (e.g. `Some("<18")` for
817    /// `"@types/react@<18"`).
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub version_constraint: Option<String>,
820    /// The right-hand side of the entry: the version pnpm should force.
821    pub version_range: String,
822    /// File the override was declared in. Matches the value users write in
823    /// `ignoreDependencyOverrides[].source`.
824    pub source: DependencyOverrideSource,
825    /// Path to the source file. `pnpm-workspace.yaml` or a `package.json`,
826    /// stored as an absolute filesystem path so `--changed-since` and
827    /// per-file `overrides.rules` can compare directly against the analyzer's
828    /// changed-set / per-path rule lookups. JSON serialization strips the
829    /// project root via `serde_path::serialize`, matching the
830    /// `UnresolvedCatalogReference` convention.
831    #[serde(serialize_with = "serde_path::serialize")]
832    pub path: PathBuf,
833    /// 1-based line number of the entry within the source file.
834    pub line: u32,
835    /// Soft hint reminding consumers to verify the override before removal.
836    /// Emitted on every unused-override finding (both bare-target and
837    /// parent-chain shapes) because projects without a readable lockfile still
838    /// use the conservative package-manifest fallback.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub hint: Option<String>,
841}
842
843/// Why a dependency-override entry is misconfigured. `pnpm install` would
844/// either fail at install time or silently no-op on these entries; surfacing
845/// them statically catches the issue before pnpm does.
846#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
847#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
848#[serde(rename_all = "kebab-case")]
849pub enum DependencyOverrideMisconfigReason {
850    /// The override key could not be parsed into a recognised pnpm shape
851    /// (e.g. dangling `>`, missing target, garbage characters).
852    UnparsableKey,
853    /// The override value is missing, empty, or contains line breaks.
854    EmptyValue,
855}
856
857impl DependencyOverrideMisconfigReason {
858    /// Human-readable summary of the reason.
859    #[must_use]
860    pub const fn describe(self) -> &'static str {
861        match self {
862            Self::UnparsableKey => "override key cannot be parsed",
863            Self::EmptyValue => "override value is missing or empty",
864        }
865    }
866}
867
868/// An override entry whose key or value is malformed. Default severity is
869/// `error` because pnpm refuses to install (or silently produces a no-op
870/// override) when it encounters these shapes.
871#[derive(Debug, Clone, Serialize)]
872#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
873pub struct MisconfiguredDependencyOverride {
874    /// The full original override key as written in the source.
875    pub raw_key: String,
876    /// Parsed target package name when the key was syntactically valid (the
877    /// `EmptyValue` reason path). `None` for `UnparsableKey` findings whose
878    /// key could not be parsed at all. Used by JSON `add-to-config` actions to
879    /// emit a paste-ready `ignoreDependencyOverrides` value that matches the
880    /// suppression matcher (which also keys on `target_package`); avoids the
881    /// pitfall where `raw_key` like `"react@<18"` would not match the rule
882    /// that targets package `"react"`.
883    #[serde(default, skip_serializing_if = "Option::is_none")]
884    pub target_package: Option<String>,
885    /// The right-hand side of the entry, exactly as written. Empty when the
886    /// value was missing.
887    pub raw_value: String,
888    /// Classifier for the misconfiguration. 'unparsable-key' = the key is not a
889    /// valid pnpm shape; 'empty-value' = the value is missing, empty, or
890    /// contains line breaks.
891    pub reason: DependencyOverrideMisconfigReason,
892    /// Where the override entry was declared.
893    pub source: DependencyOverrideSource,
894    /// Path to the source file. Stored as an absolute filesystem path so
895    /// `--changed-since` and per-file `overrides.rules` can compare directly.
896    /// JSON serialization strips the project root via `serde_path::serialize`.
897    #[serde(serialize_with = "serde_path::serialize")]
898    pub path: PathBuf,
899    /// 1-based line number of the entry within the source file.
900    pub line: u32,
901}
902
903/// A production dependency that is only imported by test files.
904/// Since it is never used in production code, it could be moved to devDependencies.
905#[derive(Debug, Clone, Serialize)]
906#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
907pub struct TestOnlyDependency {
908    /// Production dependency that is only imported by test files — consider
909    /// moving to devDependencies.
910    pub package_name: String,
911    /// Path to the package.json where the dependency is listed.
912    #[serde(serialize_with = "serde_path::serialize")]
913    pub path: PathBuf,
914    /// 1-based line number of the dependency entry in package.json.
915    pub line: u32,
916}
917
918/// A circular dependency chain detected in the module graph.
919///
920/// The `line` and `col` fields carry `#[serde(default)]` so callers reading
921/// historical baseline JSON without these fields can still deserialize the
922/// struct, but the JSON output layer always emits them (u32 always
923/// serializes, never via `skip_serializing_if`). The schemars derive sees
924/// the serde defaults and marks both fields optional in the generated
925/// schema; the explicit `extend("required" = ...)` override here keeps the
926/// schema's `required` array honest about what the JSON output actually
927/// contains.
928#[derive(Debug, Clone, Serialize, Deserialize)]
929#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
930#[cfg_attr(feature = "schema", schemars(extend("required" = ["files", "length", "line", "col"])))]
931pub struct CircularDependency {
932    /// Files forming the cycle, in import order.
933    #[serde(serialize_with = "serde_path::serialize_vec")]
934    pub files: Vec<PathBuf>,
935    /// Number of files in the cycle.
936    pub length: usize,
937    /// 1-based line number of the import that starts the cycle (in the first file).
938    #[serde(default)]
939    pub line: u32,
940    /// 0-based byte column offset of the import that starts the cycle.
941    #[serde(default)]
942    pub col: u32,
943    /// Whether this cycle crosses workspace package boundaries.
944    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
945    pub is_cross_package: bool,
946}
947
948/// An import that crosses an architecture boundary rule.
949#[derive(Debug, Clone, Serialize)]
950#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
951pub struct BoundaryViolation {
952    /// The file making the disallowed import.
953    #[serde(serialize_with = "serde_path::serialize")]
954    pub from_path: PathBuf,
955    /// The file being imported that violates the boundary.
956    #[serde(serialize_with = "serde_path::serialize")]
957    pub to_path: PathBuf,
958    /// The zone the importing file belongs to.
959    pub from_zone: String,
960    /// The zone the imported file belongs to.
961    pub to_zone: String,
962    /// The raw import specifier from the source file.
963    pub import_specifier: String,
964    /// 1-based line number of the import statement in the source file.
965    pub line: u32,
966    /// 0-based byte column offset of the import statement.
967    pub col: u32,
968}
969
970/// The origin of a stale suppression: inline comment or JSDoc tag.
971#[derive(Debug, Clone, Serialize)]
972#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
973#[serde(rename_all = "snake_case", tag = "type")]
974pub enum SuppressionOrigin {
975    /// A `// fallow-ignore-next-line` or `// fallow-ignore-file` comment.
976    Comment {
977        /// The issue kind token from the comment (e.g., "unused-exports"), or None for blanket.
978        #[serde(skip_serializing_if = "Option::is_none")]
979        issue_kind: Option<String>,
980        /// Whether this was a file-level suppression.
981        is_file_level: bool,
982    },
983    /// An `@expected-unused` JSDoc tag on an export.
984    JsdocTag {
985        /// The name of the export that was tagged.
986        export_name: String,
987    },
988}
989
990/// A suppression comment or JSDoc tag that no longer matches any issue.
991#[derive(Debug, Clone, Serialize)]
992#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
993pub struct StaleSuppression {
994    /// File containing the stale suppression.
995    #[serde(serialize_with = "serde_path::serialize")]
996    pub path: PathBuf,
997    /// 1-based line number of the suppression comment or tag.
998    pub line: u32,
999    /// 0-based byte column offset.
1000    pub col: u32,
1001    /// The origin and details of the stale suppression.
1002    pub origin: SuppressionOrigin,
1003}
1004
1005impl StaleSuppression {
1006    /// Produce a human-readable description of this stale suppression.
1007    #[must_use]
1008    pub fn description(&self) -> String {
1009        match &self.origin {
1010            SuppressionOrigin::Comment {
1011                issue_kind,
1012                is_file_level,
1013            } => {
1014                let directive = if *is_file_level {
1015                    "fallow-ignore-file"
1016                } else {
1017                    "fallow-ignore-next-line"
1018                };
1019                match issue_kind {
1020                    Some(kind) => format!("// {directive} {kind}"),
1021                    None => format!("// {directive}"),
1022                }
1023            }
1024            SuppressionOrigin::JsdocTag { export_name } => {
1025                format!("@expected-unused on {export_name}")
1026            }
1027        }
1028    }
1029
1030    /// Produce an explanation of why this suppression is stale.
1031    #[must_use]
1032    pub fn explanation(&self) -> String {
1033        match &self.origin {
1034            SuppressionOrigin::Comment {
1035                issue_kind,
1036                is_file_level,
1037            } => {
1038                let scope = if *is_file_level {
1039                    "in this file"
1040                } else {
1041                    "on the next line"
1042                };
1043                match issue_kind {
1044                    Some(kind) => format!("no {kind} issue found {scope}"),
1045                    None => format!("no issues found {scope}"),
1046                }
1047            }
1048            SuppressionOrigin::JsdocTag { export_name } => {
1049                format!("{export_name} is now used")
1050            }
1051        }
1052    }
1053
1054    /// The suppressed `IssueKind`, if this was a comment suppression with a specific kind.
1055    #[must_use]
1056    pub fn suppressed_kind(&self) -> Option<IssueKind> {
1057        match &self.origin {
1058            SuppressionOrigin::Comment { issue_kind, .. } => {
1059                issue_kind.as_deref().and_then(IssueKind::parse)
1060            }
1061            SuppressionOrigin::JsdocTag { .. } => None,
1062        }
1063    }
1064}
1065
1066/// The detection method used to identify a feature flag.
1067#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1068#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1069#[serde(rename_all = "snake_case")]
1070pub enum FlagKind {
1071    /// Environment variable check (e.g., `process.env.FEATURE_X`).
1072    EnvironmentVariable,
1073    /// Feature flag SDK call (e.g., `useFlag('name')`, `variation('name', false)`).
1074    SdkCall,
1075    /// Config object property access (e.g., `config.features.newCheckout`).
1076    ConfigObject,
1077}
1078
1079/// Detection confidence for a feature flag finding.
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1081#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1082#[serde(rename_all = "snake_case")]
1083pub enum FlagConfidence {
1084    /// Low confidence: heuristic match (config object patterns).
1085    Low,
1086    /// Medium confidence: pattern match with some ambiguity.
1087    Medium,
1088    /// High confidence: unambiguous pattern (env vars, direct SDK calls).
1089    High,
1090}
1091
1092/// A detected feature flag use site.
1093#[derive(Debug, Clone, Serialize)]
1094#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1095pub struct FeatureFlag {
1096    /// File containing the feature flag usage.
1097    #[serde(serialize_with = "serde_path::serialize")]
1098    pub path: PathBuf,
1099    /// Name or identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
1100    pub flag_name: String,
1101    /// How the flag was detected.
1102    pub kind: FlagKind,
1103    /// Detection confidence level.
1104    pub confidence: FlagConfidence,
1105    /// 1-based line number.
1106    pub line: u32,
1107    /// 0-based byte column offset.
1108    pub col: u32,
1109    /// Start byte offset of the guarded code block (if-branch span), if detected.
1110    #[serde(skip)]
1111    pub guard_span_start: Option<u32>,
1112    /// End byte offset of the guarded code block (if-branch span), if detected.
1113    #[serde(skip)]
1114    pub guard_span_end: Option<u32>,
1115    /// SDK or provider name (e.g., "LaunchDarkly", "Statsig"), if detected from SDK call.
1116    #[serde(skip_serializing_if = "Option::is_none")]
1117    pub sdk_name: Option<String>,
1118    /// Line range of the guarded code block (derived from guard_span + line_offsets).
1119    /// Used for cross-reference with dead code findings.
1120    #[serde(skip)]
1121    pub guard_line_start: Option<u32>,
1122    /// End line of the guarded code block.
1123    #[serde(skip)]
1124    pub guard_line_end: Option<u32>,
1125    /// Unused exports found within the guarded code block.
1126    /// Populated by cross-reference with dead code analysis.
1127    #[serde(skip_serializing_if = "Vec::is_empty")]
1128    pub guarded_dead_exports: Vec<String>,
1129}
1130
1131// Size assertion: FeatureFlag is stored in a Vec per analysis run.
1132const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
1133
1134/// Usage count for an export symbol. Used by the LSP Code Lens to show
1135/// reference counts above each export declaration.
1136#[derive(Debug, Clone, Serialize)]
1137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1138pub struct ExportUsage {
1139    /// File containing the export.
1140    #[serde(serialize_with = "serde_path::serialize")]
1141    pub path: PathBuf,
1142    /// Name of the exported symbol.
1143    pub export_name: String,
1144    /// 1-based line number.
1145    pub line: u32,
1146    /// 0-based byte column offset.
1147    pub col: u32,
1148    /// Number of files that reference this export.
1149    pub reference_count: usize,
1150    /// Locations where this export is referenced. Used by the LSP Code Lens
1151    /// to enable click-to-navigate via `editor.action.showReferences`.
1152    pub reference_locations: Vec<ReferenceLocation>,
1153}
1154
1155/// A location where an export is referenced (import site in another file).
1156#[derive(Debug, Clone, Serialize)]
1157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1158pub struct ReferenceLocation {
1159    /// File containing the import that references the export.
1160    #[serde(serialize_with = "serde_path::serialize")]
1161    pub path: PathBuf,
1162    /// 1-based line number.
1163    pub line: u32,
1164    /// 0-based byte column offset.
1165    pub col: u32,
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use super::*;
1171    use crate::output_dead_code::{
1172        BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
1173        UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
1174        UnusedTypeFinding,
1175    };
1176
1177    #[test]
1178    fn empty_results_no_issues() {
1179        let results = AnalysisResults::default();
1180        assert_eq!(results.total_issues(), 0);
1181        assert!(!results.has_issues());
1182    }
1183
1184    #[test]
1185    fn results_with_unused_file() {
1186        let mut results = AnalysisResults::default();
1187        results
1188            .unused_files
1189            .push(UnusedFileFinding::with_actions(UnusedFile {
1190                path: PathBuf::from("test.ts"),
1191            }));
1192        assert_eq!(results.total_issues(), 1);
1193        assert!(results.has_issues());
1194    }
1195
1196    #[test]
1197    fn results_with_unused_export() {
1198        let mut results = AnalysisResults::default();
1199        results
1200            .unused_exports
1201            .push(UnusedExportFinding::with_actions(UnusedExport {
1202                path: PathBuf::from("test.ts"),
1203                export_name: "foo".to_string(),
1204                is_type_only: false,
1205                line: 1,
1206                col: 0,
1207                span_start: 0,
1208                is_re_export: false,
1209            }));
1210        assert_eq!(results.total_issues(), 1);
1211        assert!(results.has_issues());
1212    }
1213
1214    fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
1215        UnusedExport {
1216            path: PathBuf::from(path),
1217            export_name: export_name.to_string(),
1218            is_type_only,
1219            line: 1,
1220            col: 0,
1221            span_start: 0,
1222            is_re_export: false,
1223        }
1224    }
1225
1226    fn test_unused_dependency(
1227        package_name: &str,
1228        location: DependencyLocation,
1229    ) -> UnusedDependency {
1230        UnusedDependency {
1231            package_name: package_name.to_string(),
1232            location,
1233            path: PathBuf::from("package.json"),
1234            line: 5,
1235            used_in_workspaces: Vec::new(),
1236        }
1237    }
1238
1239    fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
1240        UnusedMember {
1241            path: PathBuf::from("members.ts"),
1242            parent_name: "Parent".to_string(),
1243            member_name: member_name.to_string(),
1244            kind,
1245            line: 1,
1246            col: 0,
1247        }
1248    }
1249
1250    #[test]
1251    fn results_total_counts_all_types() {
1252        let results = AnalysisResults {
1253            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1254                path: PathBuf::from("a.ts"),
1255            })],
1256            unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
1257                "b.ts", "x", false,
1258            ))],
1259            unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
1260                "c.ts", "T", true,
1261            ))],
1262            unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1263                test_unused_dependency("dep", DependencyLocation::Dependencies),
1264            )],
1265            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1266                test_unused_dependency("dev", DependencyLocation::DevDependencies),
1267            )],
1268            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
1269                "A",
1270                MemberKind::EnumMember,
1271            ))],
1272            unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
1273                "m",
1274                MemberKind::ClassMethod,
1275            ))],
1276            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
1277                path: PathBuf::from("f.ts"),
1278                specifier: "./missing".to_string(),
1279                line: 1,
1280                col: 0,
1281                specifier_col: 0,
1282            })],
1283            unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1284                UnlistedDependency {
1285                    package_name: "unlisted".to_string(),
1286                    imported_from: vec![ImportSite {
1287                        path: PathBuf::from("g.ts"),
1288                        line: 1,
1289                        col: 0,
1290                    }],
1291                },
1292            )],
1293            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
1294                export_name: "dup".to_string(),
1295                locations: vec![
1296                    DuplicateLocation {
1297                        path: PathBuf::from("h.ts"),
1298                        line: 15,
1299                        col: 0,
1300                    },
1301                    DuplicateLocation {
1302                        path: PathBuf::from("i.ts"),
1303                        line: 30,
1304                        col: 0,
1305                    },
1306                ],
1307            })],
1308            unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1309                test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
1310            )],
1311            type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
1312                TypeOnlyDependency {
1313                    package_name: "type-only".to_string(),
1314                    path: PathBuf::from("package.json"),
1315                    line: 8,
1316                },
1317            )],
1318            test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1319                TestOnlyDependency {
1320                    package_name: "test-only".to_string(),
1321                    path: PathBuf::from("package.json"),
1322                    line: 9,
1323                },
1324            )],
1325            circular_dependencies: vec![CircularDependencyFinding::with_actions(
1326                CircularDependency {
1327                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1328                    length: 2,
1329                    line: 3,
1330                    col: 0,
1331                    is_cross_package: false,
1332                },
1333            )],
1334            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1335                from_path: PathBuf::from("src/ui/Button.tsx"),
1336                to_path: PathBuf::from("src/db/queries.ts"),
1337                from_zone: "ui".to_string(),
1338                to_zone: "database".to_string(),
1339                import_specifier: "../db/queries".to_string(),
1340                line: 3,
1341                col: 0,
1342            })],
1343            ..Default::default()
1344        };
1345
1346        // 15 categories, one of each
1347        assert_eq!(results.total_issues(), 15);
1348        assert!(results.has_issues());
1349    }
1350
1351    // ── total_issues / has_issues consistency ──────────────────
1352
1353    #[test]
1354    fn total_issues_and_has_issues_are_consistent() {
1355        let results = AnalysisResults::default();
1356        assert_eq!(results.total_issues(), 0);
1357        assert!(!results.has_issues());
1358        assert_eq!(results.total_issues() > 0, results.has_issues());
1359    }
1360
1361    // ── total_issues counts each category independently ─────────
1362
1363    #[test]
1364    fn total_issues_sums_all_categories_independently() {
1365        let mut results = AnalysisResults::default();
1366        results
1367            .unused_files
1368            .push(UnusedFileFinding::with_actions(UnusedFile {
1369                path: PathBuf::from("a.ts"),
1370            }));
1371        assert_eq!(results.total_issues(), 1);
1372
1373        results
1374            .unused_files
1375            .push(UnusedFileFinding::with_actions(UnusedFile {
1376                path: PathBuf::from("b.ts"),
1377            }));
1378        assert_eq!(results.total_issues(), 2);
1379
1380        results
1381            .unresolved_imports
1382            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1383                path: PathBuf::from("c.ts"),
1384                specifier: "./missing".to_string(),
1385                line: 1,
1386                col: 0,
1387                specifier_col: 0,
1388            }));
1389        assert_eq!(results.total_issues(), 3);
1390    }
1391
1392    // ── default is truly empty ──────────────────────────────────
1393
1394    #[test]
1395    fn default_results_all_fields_empty() {
1396        let r = AnalysisResults::default();
1397        assert!(r.unused_files.is_empty());
1398        assert!(r.unused_exports.is_empty());
1399        assert!(r.unused_types.is_empty());
1400        assert!(r.unused_dependencies.is_empty());
1401        assert!(r.unused_dev_dependencies.is_empty());
1402        assert!(r.unused_optional_dependencies.is_empty());
1403        assert!(r.unused_enum_members.is_empty());
1404        assert!(r.unused_class_members.is_empty());
1405        assert!(r.unresolved_imports.is_empty());
1406        assert!(r.unlisted_dependencies.is_empty());
1407        assert!(r.duplicate_exports.is_empty());
1408        assert!(r.type_only_dependencies.is_empty());
1409        assert!(r.test_only_dependencies.is_empty());
1410        assert!(r.circular_dependencies.is_empty());
1411        assert!(r.boundary_violations.is_empty());
1412        assert!(r.unused_catalog_entries.is_empty());
1413        assert!(r.unresolved_catalog_references.is_empty());
1414        assert!(r.export_usages.is_empty());
1415    }
1416
1417    // ── EntryPointSummary ────────────────────────────────────────
1418
1419    #[test]
1420    fn entry_point_summary_default() {
1421        let summary = EntryPointSummary::default();
1422        assert_eq!(summary.total, 0);
1423        assert!(summary.by_source.is_empty());
1424    }
1425
1426    #[test]
1427    fn entry_point_summary_not_in_default_results() {
1428        let r = AnalysisResults::default();
1429        assert!(r.entry_point_summary.is_none());
1430    }
1431
1432    #[test]
1433    fn entry_point_summary_some_preserves_data() {
1434        let r = AnalysisResults {
1435            entry_point_summary: Some(EntryPointSummary {
1436                total: 5,
1437                by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
1438            }),
1439            ..AnalysisResults::default()
1440        };
1441        let summary = r.entry_point_summary.as_ref().unwrap();
1442        assert_eq!(summary.total, 5);
1443        assert_eq!(summary.by_source.len(), 2);
1444        assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
1445    }
1446
1447    // ── sort: unused_files by path ──────────────────────────────
1448
1449    #[test]
1450    fn sort_unused_files_by_path() {
1451        let mut r = AnalysisResults::default();
1452        r.unused_files
1453            .push(UnusedFileFinding::with_actions(UnusedFile {
1454                path: PathBuf::from("z.ts"),
1455            }));
1456        r.unused_files
1457            .push(UnusedFileFinding::with_actions(UnusedFile {
1458                path: PathBuf::from("a.ts"),
1459            }));
1460        r.unused_files
1461            .push(UnusedFileFinding::with_actions(UnusedFile {
1462                path: PathBuf::from("m.ts"),
1463            }));
1464        r.sort();
1465        let paths: Vec<_> = r
1466            .unused_files
1467            .iter()
1468            .map(|f| f.file.path.to_string_lossy().to_string())
1469            .collect();
1470        assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
1471    }
1472
1473    // ── sort: unused_exports by path, line, name ────────────────
1474
1475    #[test]
1476    fn sort_unused_exports_by_path_line_name() {
1477        let mut r = AnalysisResults::default();
1478        let mk = |path: &str, line: u32, name: &str| {
1479            UnusedExportFinding::with_actions(UnusedExport {
1480                path: PathBuf::from(path),
1481                export_name: name.to_string(),
1482                is_type_only: false,
1483                line,
1484                col: 0,
1485                span_start: 0,
1486                is_re_export: false,
1487            })
1488        };
1489        r.unused_exports.push(mk("b.ts", 5, "beta"));
1490        r.unused_exports.push(mk("a.ts", 10, "zeta"));
1491        r.unused_exports.push(mk("a.ts", 10, "alpha"));
1492        r.unused_exports.push(mk("a.ts", 1, "gamma"));
1493        r.sort();
1494        let keys: Vec<_> = r
1495            .unused_exports
1496            .iter()
1497            .map(|e| {
1498                format!(
1499                    "{}:{}:{}",
1500                    e.export.path.to_string_lossy(),
1501                    e.export.line,
1502                    e.export.export_name
1503                )
1504            })
1505            .collect();
1506        assert_eq!(
1507            keys,
1508            vec![
1509                "a.ts:1:gamma",
1510                "a.ts:10:alpha",
1511                "a.ts:10:zeta",
1512                "b.ts:5:beta"
1513            ]
1514        );
1515    }
1516
1517    // ── sort: unused_types (same sort as unused_exports) ────────
1518
1519    #[test]
1520    fn sort_unused_types_by_path_line_name() {
1521        let mut r = AnalysisResults::default();
1522        let mk = |path: &str, line: u32, name: &str| {
1523            UnusedTypeFinding::with_actions(UnusedExport {
1524                path: PathBuf::from(path),
1525                export_name: name.to_string(),
1526                is_type_only: true,
1527                line,
1528                col: 0,
1529                span_start: 0,
1530                is_re_export: false,
1531            })
1532        };
1533        r.unused_types.push(mk("z.ts", 1, "Z"));
1534        r.unused_types.push(mk("a.ts", 1, "A"));
1535        r.sort();
1536        assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
1537        assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
1538    }
1539
1540    // ── sort: unused_dependencies by path, line, name ───────────
1541
1542    #[test]
1543    fn sort_unused_dependencies_by_path_line_name() {
1544        let mut r = AnalysisResults::default();
1545        let mk = |path: &str, line: u32, name: &str| {
1546            UnusedDependencyFinding::with_actions(UnusedDependency {
1547                package_name: name.to_string(),
1548                location: DependencyLocation::Dependencies,
1549                path: PathBuf::from(path),
1550                line,
1551                used_in_workspaces: Vec::new(),
1552            })
1553        };
1554        r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1555        r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1556        r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1557        r.sort();
1558        let names: Vec<_> = r
1559            .unused_dependencies
1560            .iter()
1561            .map(|d| d.dep.package_name.as_str())
1562            .collect();
1563        assert_eq!(names, vec!["axios", "react", "zlib"]);
1564    }
1565
1566    // ── sort: unused_dev_dependencies ───────────────────────────
1567
1568    #[test]
1569    fn sort_unused_dev_dependencies() {
1570        let mut r = AnalysisResults::default();
1571        r.unused_dev_dependencies
1572            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1573                package_name: "vitest".to_string(),
1574                location: DependencyLocation::DevDependencies,
1575                path: PathBuf::from("package.json"),
1576                line: 10,
1577                used_in_workspaces: Vec::new(),
1578            }));
1579        r.unused_dev_dependencies
1580            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1581                package_name: "jest".to_string(),
1582                location: DependencyLocation::DevDependencies,
1583                path: PathBuf::from("package.json"),
1584                line: 5,
1585                used_in_workspaces: Vec::new(),
1586            }));
1587        r.sort();
1588        assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
1589        assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
1590    }
1591
1592    // ── sort: unused_optional_dependencies ──────────────────────
1593
1594    #[test]
1595    fn sort_unused_optional_dependencies() {
1596        let mut r = AnalysisResults::default();
1597        r.unused_optional_dependencies
1598            .push(UnusedOptionalDependencyFinding::with_actions(
1599                UnusedDependency {
1600                    package_name: "zod".to_string(),
1601                    location: DependencyLocation::OptionalDependencies,
1602                    path: PathBuf::from("package.json"),
1603                    line: 3,
1604                    used_in_workspaces: Vec::new(),
1605                },
1606            ));
1607        r.unused_optional_dependencies
1608            .push(UnusedOptionalDependencyFinding::with_actions(
1609                UnusedDependency {
1610                    package_name: "ajv".to_string(),
1611                    location: DependencyLocation::OptionalDependencies,
1612                    path: PathBuf::from("package.json"),
1613                    line: 2,
1614                    used_in_workspaces: Vec::new(),
1615                },
1616            ));
1617        r.sort();
1618        assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
1619        assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
1620    }
1621
1622    // ── sort: unused_enum_members by path, line, parent, member ─
1623
1624    #[test]
1625    fn sort_unused_enum_members_by_path_line_parent_member() {
1626        let mut r = AnalysisResults::default();
1627        let mk = |path: &str, line: u32, parent: &str, member: &str| {
1628            UnusedEnumMemberFinding::with_actions(UnusedMember {
1629                path: PathBuf::from(path),
1630                parent_name: parent.to_string(),
1631                member_name: member.to_string(),
1632                kind: MemberKind::EnumMember,
1633                line,
1634                col: 0,
1635            })
1636        };
1637        r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1638        r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1639        r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1640        r.sort();
1641        let keys: Vec<_> = r
1642            .unused_enum_members
1643            .iter()
1644            .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
1645            .collect();
1646        assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1647    }
1648
1649    // ── sort: unused_class_members by path, line, parent, member
1650
1651    #[test]
1652    fn sort_unused_class_members() {
1653        let mut r = AnalysisResults::default();
1654        let mk = |path: &str, line: u32, parent: &str, member: &str| {
1655            UnusedClassMemberFinding::with_actions(UnusedMember {
1656                path: PathBuf::from(path),
1657                parent_name: parent.to_string(),
1658                member_name: member.to_string(),
1659                kind: MemberKind::ClassMethod,
1660                line,
1661                col: 0,
1662            })
1663        };
1664        r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1665        r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1666        r.sort();
1667        assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
1668        assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
1669    }
1670
1671    // ── sort: unresolved_imports by path, line, col, specifier ──
1672
1673    #[test]
1674    fn sort_unresolved_imports_by_path_line_col_specifier() {
1675        let mut r = AnalysisResults::default();
1676        let mk = |path: &str, line: u32, col: u32, spec: &str| {
1677            UnresolvedImportFinding::with_actions(UnresolvedImport {
1678                path: PathBuf::from(path),
1679                specifier: spec.to_string(),
1680                line,
1681                col,
1682                specifier_col: 0,
1683            })
1684        };
1685        r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1686        r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1687        r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1688        r.sort();
1689        let specs: Vec<_> = r
1690            .unresolved_imports
1691            .iter()
1692            .map(|i| i.import.specifier.as_str())
1693            .collect();
1694        assert_eq!(specs, vec!["./m", "./a", "./z"]);
1695    }
1696
1697    // ── sort: unlisted_dependencies + inner imported_from ───────
1698
1699    #[test]
1700    fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1701        let mut r = AnalysisResults::default();
1702        r.unlisted_dependencies
1703            .push(UnlistedDependencyFinding::with_actions(
1704                UnlistedDependency {
1705                    package_name: "zod".to_string(),
1706                    imported_from: vec![
1707                        ImportSite {
1708                            path: PathBuf::from("b.ts"),
1709                            line: 10,
1710                            col: 0,
1711                        },
1712                        ImportSite {
1713                            path: PathBuf::from("a.ts"),
1714                            line: 1,
1715                            col: 0,
1716                        },
1717                    ],
1718                },
1719            ));
1720        r.unlisted_dependencies
1721            .push(UnlistedDependencyFinding::with_actions(
1722                UnlistedDependency {
1723                    package_name: "axios".to_string(),
1724                    imported_from: vec![ImportSite {
1725                        path: PathBuf::from("c.ts"),
1726                        line: 1,
1727                        col: 0,
1728                    }],
1729                },
1730            ));
1731        r.sort();
1732
1733        // Outer sort: by package_name
1734        assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
1735        assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
1736
1737        // Inner sort: imported_from sorted by path, then line
1738        let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1739            .dep
1740            .imported_from
1741            .iter()
1742            .map(|s| s.path.to_string_lossy().to_string())
1743            .collect();
1744        assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1745    }
1746
1747    // ── sort: duplicate_exports + inner locations ───────────────
1748
1749    #[test]
1750    fn sort_duplicate_exports_by_name_and_inner_locations() {
1751        let mut r = AnalysisResults::default();
1752        r.duplicate_exports
1753            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1754                export_name: "z".to_string(),
1755                locations: vec![
1756                    DuplicateLocation {
1757                        path: PathBuf::from("c.ts"),
1758                        line: 1,
1759                        col: 0,
1760                    },
1761                    DuplicateLocation {
1762                        path: PathBuf::from("a.ts"),
1763                        line: 5,
1764                        col: 0,
1765                    },
1766                ],
1767            }));
1768        r.duplicate_exports
1769            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1770                export_name: "a".to_string(),
1771                locations: vec![DuplicateLocation {
1772                    path: PathBuf::from("b.ts"),
1773                    line: 1,
1774                    col: 0,
1775                }],
1776            }));
1777        r.sort();
1778
1779        // Outer sort: by export_name
1780        assert_eq!(r.duplicate_exports[0].export.export_name, "a");
1781        assert_eq!(r.duplicate_exports[1].export.export_name, "z");
1782
1783        // Inner sort: locations sorted by path, then line
1784        let z_locs: Vec<_> = r.duplicate_exports[1]
1785            .export
1786            .locations
1787            .iter()
1788            .map(|l| l.path.to_string_lossy().to_string())
1789            .collect();
1790        assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1791    }
1792
1793    // ── sort: type_only_dependencies ────────────────────────────
1794
1795    #[test]
1796    fn sort_type_only_dependencies() {
1797        let mut r = AnalysisResults::default();
1798        r.type_only_dependencies
1799            .push(TypeOnlyDependencyFinding::with_actions(
1800                TypeOnlyDependency {
1801                    package_name: "zod".to_string(),
1802                    path: PathBuf::from("package.json"),
1803                    line: 10,
1804                },
1805            ));
1806        r.type_only_dependencies
1807            .push(TypeOnlyDependencyFinding::with_actions(
1808                TypeOnlyDependency {
1809                    package_name: "ajv".to_string(),
1810                    path: PathBuf::from("package.json"),
1811                    line: 5,
1812                },
1813            ));
1814        r.sort();
1815        assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
1816        assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
1817    }
1818
1819    // ── sort: test_only_dependencies ────────────────────────────
1820
1821    #[test]
1822    fn sort_test_only_dependencies() {
1823        let mut r = AnalysisResults::default();
1824        r.test_only_dependencies
1825            .push(TestOnlyDependencyFinding::with_actions(
1826                TestOnlyDependency {
1827                    package_name: "vitest".to_string(),
1828                    path: PathBuf::from("package.json"),
1829                    line: 15,
1830                },
1831            ));
1832        r.test_only_dependencies
1833            .push(TestOnlyDependencyFinding::with_actions(
1834                TestOnlyDependency {
1835                    package_name: "jest".to_string(),
1836                    path: PathBuf::from("package.json"),
1837                    line: 10,
1838                },
1839            ));
1840        r.sort();
1841        assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
1842        assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
1843    }
1844
1845    // ── sort: circular_dependencies by files, then length ───────
1846
1847    #[test]
1848    fn sort_circular_dependencies_by_files_then_length() {
1849        let mut r = AnalysisResults::default();
1850        r.circular_dependencies
1851            .push(CircularDependencyFinding::with_actions(
1852                CircularDependency {
1853                    files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1854                    length: 2,
1855                    line: 1,
1856                    col: 0,
1857                    is_cross_package: false,
1858                },
1859            ));
1860        r.circular_dependencies
1861            .push(CircularDependencyFinding::with_actions(
1862                CircularDependency {
1863                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1864                    length: 2,
1865                    line: 1,
1866                    col: 0,
1867                    is_cross_package: true,
1868                },
1869            ));
1870        r.sort();
1871        assert_eq!(
1872            r.circular_dependencies[0].cycle.files[0],
1873            PathBuf::from("a.ts")
1874        );
1875        assert_eq!(
1876            r.circular_dependencies[1].cycle.files[0],
1877            PathBuf::from("b.ts")
1878        );
1879    }
1880
1881    // ── sort: boundary_violations by from_path, line, col, to_path
1882
1883    #[test]
1884    fn sort_boundary_violations() {
1885        let mut r = AnalysisResults::default();
1886        let mk = |from: &str, line: u32, col: u32, to: &str| {
1887            BoundaryViolationFinding::with_actions(BoundaryViolation {
1888                from_path: PathBuf::from(from),
1889                to_path: PathBuf::from(to),
1890                from_zone: "a".to_string(),
1891                to_zone: "b".to_string(),
1892                import_specifier: to.to_string(),
1893                line,
1894                col,
1895            })
1896        };
1897        r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1898        r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1899        r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1900        r.sort();
1901        let from_paths: Vec<_> = r
1902            .boundary_violations
1903            .iter()
1904            .map(|v| {
1905                format!(
1906                    "{}:{}",
1907                    v.violation.from_path.to_string_lossy(),
1908                    v.violation.line
1909                )
1910            })
1911            .collect();
1912        assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1913    }
1914
1915    // ── sort: export_usages + inner reference_locations ─────────
1916
1917    #[test]
1918    fn sort_export_usages_and_inner_reference_locations() {
1919        let mut r = AnalysisResults::default();
1920        r.export_usages.push(ExportUsage {
1921            path: PathBuf::from("z.ts"),
1922            export_name: "foo".to_string(),
1923            line: 1,
1924            col: 0,
1925            reference_count: 2,
1926            reference_locations: vec![
1927                ReferenceLocation {
1928                    path: PathBuf::from("c.ts"),
1929                    line: 10,
1930                    col: 0,
1931                },
1932                ReferenceLocation {
1933                    path: PathBuf::from("a.ts"),
1934                    line: 5,
1935                    col: 0,
1936                },
1937            ],
1938        });
1939        r.export_usages.push(ExportUsage {
1940            path: PathBuf::from("a.ts"),
1941            export_name: "bar".to_string(),
1942            line: 1,
1943            col: 0,
1944            reference_count: 1,
1945            reference_locations: vec![ReferenceLocation {
1946                path: PathBuf::from("b.ts"),
1947                line: 1,
1948                col: 0,
1949            }],
1950        });
1951        r.sort();
1952
1953        // Outer sort: by path, then line, then export_name
1954        assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1955        assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1956
1957        // Inner sort: reference_locations sorted by path, line, col
1958        let refs: Vec<_> = r.export_usages[1]
1959            .reference_locations
1960            .iter()
1961            .map(|l| l.path.to_string_lossy().to_string())
1962            .collect();
1963        assert_eq!(refs, vec!["a.ts", "c.ts"]);
1964    }
1965
1966    // ── sort: empty results does not panic ──────────────────────
1967
1968    #[test]
1969    fn sort_empty_results_is_noop() {
1970        let mut r = AnalysisResults::default();
1971        r.sort(); // should not panic
1972        assert_eq!(r.total_issues(), 0);
1973    }
1974
1975    // ── sort: single-element lists remain stable ────────────────
1976
1977    #[test]
1978    fn sort_single_element_lists_stable() {
1979        let mut r = AnalysisResults::default();
1980        r.unused_files
1981            .push(UnusedFileFinding::with_actions(UnusedFile {
1982                path: PathBuf::from("only.ts"),
1983            }));
1984        r.sort();
1985        assert_eq!(r.unused_files[0].file.path, PathBuf::from("only.ts"));
1986    }
1987
1988    // ── serialization ──────────────────────────────────────────
1989
1990    #[test]
1991    fn serialize_empty_results() {
1992        let r = AnalysisResults::default();
1993        let json = serde_json::to_value(&r).unwrap();
1994
1995        // All arrays should be present and empty
1996        assert!(json["unused_files"].as_array().unwrap().is_empty());
1997        assert!(json["unused_exports"].as_array().unwrap().is_empty());
1998        assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1999
2000        // Skipped fields should be absent
2001        assert!(json.get("export_usages").is_none());
2002        assert!(json.get("entry_point_summary").is_none());
2003    }
2004
2005    #[test]
2006    fn serialize_unused_file_path() {
2007        let r = UnusedFile {
2008            path: PathBuf::from("src/utils/index.ts"),
2009        };
2010        let json = serde_json::to_value(&r).unwrap();
2011        assert_eq!(json["path"], "src/utils/index.ts");
2012    }
2013
2014    #[test]
2015    fn serialize_dependency_location_camel_case() {
2016        let dep = UnusedDependency {
2017            package_name: "react".to_string(),
2018            location: DependencyLocation::DevDependencies,
2019            path: PathBuf::from("package.json"),
2020            line: 5,
2021            used_in_workspaces: Vec::new(),
2022        };
2023        let json = serde_json::to_value(&dep).unwrap();
2024        assert_eq!(json["location"], "devDependencies");
2025
2026        let dep2 = UnusedDependency {
2027            package_name: "react".to_string(),
2028            location: DependencyLocation::Dependencies,
2029            path: PathBuf::from("package.json"),
2030            line: 3,
2031            used_in_workspaces: Vec::new(),
2032        };
2033        let json2 = serde_json::to_value(&dep2).unwrap();
2034        assert_eq!(json2["location"], "dependencies");
2035
2036        let dep3 = UnusedDependency {
2037            package_name: "fsevents".to_string(),
2038            location: DependencyLocation::OptionalDependencies,
2039            path: PathBuf::from("package.json"),
2040            line: 7,
2041            used_in_workspaces: Vec::new(),
2042        };
2043        let json3 = serde_json::to_value(&dep3).unwrap();
2044        assert_eq!(json3["location"], "optionalDependencies");
2045    }
2046
2047    #[test]
2048    fn serialize_circular_dependency_skips_false_cross_package() {
2049        let cd = CircularDependency {
2050            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2051            length: 2,
2052            line: 1,
2053            col: 0,
2054            is_cross_package: false,
2055        };
2056        let json = serde_json::to_value(&cd).unwrap();
2057        // skip_serializing_if = "std::ops::Not::not" means false is skipped
2058        assert!(json.get("is_cross_package").is_none());
2059    }
2060
2061    #[test]
2062    fn serialize_circular_dependency_includes_true_cross_package() {
2063        let cd = CircularDependency {
2064            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2065            length: 2,
2066            line: 1,
2067            col: 0,
2068            is_cross_package: true,
2069        };
2070        let json = serde_json::to_value(&cd).unwrap();
2071        assert_eq!(json["is_cross_package"], true);
2072    }
2073
2074    #[test]
2075    fn serialize_unused_export_fields() {
2076        let e = UnusedExport {
2077            path: PathBuf::from("src/mod.ts"),
2078            export_name: "helper".to_string(),
2079            is_type_only: true,
2080            line: 42,
2081            col: 7,
2082            span_start: 100,
2083            is_re_export: true,
2084        };
2085        let json = serde_json::to_value(&e).unwrap();
2086        assert_eq!(json["path"], "src/mod.ts");
2087        assert_eq!(json["export_name"], "helper");
2088        assert_eq!(json["is_type_only"], true);
2089        assert_eq!(json["line"], 42);
2090        assert_eq!(json["col"], 7);
2091        assert_eq!(json["span_start"], 100);
2092        assert_eq!(json["is_re_export"], true);
2093    }
2094
2095    #[test]
2096    fn serialize_boundary_violation_fields() {
2097        let v = BoundaryViolation {
2098            from_path: PathBuf::from("src/ui/button.tsx"),
2099            to_path: PathBuf::from("src/db/queries.ts"),
2100            from_zone: "ui".to_string(),
2101            to_zone: "db".to_string(),
2102            import_specifier: "../db/queries".to_string(),
2103            line: 3,
2104            col: 0,
2105        };
2106        let json = serde_json::to_value(&v).unwrap();
2107        assert_eq!(json["from_path"], "src/ui/button.tsx");
2108        assert_eq!(json["to_path"], "src/db/queries.ts");
2109        assert_eq!(json["from_zone"], "ui");
2110        assert_eq!(json["to_zone"], "db");
2111        assert_eq!(json["import_specifier"], "../db/queries");
2112    }
2113
2114    #[test]
2115    fn serialize_unlisted_dependency_with_import_sites() {
2116        let d = UnlistedDependency {
2117            package_name: "chalk".to_string(),
2118            imported_from: vec![
2119                ImportSite {
2120                    path: PathBuf::from("a.ts"),
2121                    line: 1,
2122                    col: 0,
2123                },
2124                ImportSite {
2125                    path: PathBuf::from("b.ts"),
2126                    line: 5,
2127                    col: 3,
2128                },
2129            ],
2130        };
2131        let json = serde_json::to_value(&d).unwrap();
2132        assert_eq!(json["package_name"], "chalk");
2133        let sites = json["imported_from"].as_array().unwrap();
2134        assert_eq!(sites.len(), 2);
2135        assert_eq!(sites[0]["path"], "a.ts");
2136        assert_eq!(sites[1]["line"], 5);
2137    }
2138
2139    #[test]
2140    fn serialize_duplicate_export_with_locations() {
2141        let d = DuplicateExport {
2142            export_name: "Button".to_string(),
2143            locations: vec![
2144                DuplicateLocation {
2145                    path: PathBuf::from("src/a.ts"),
2146                    line: 10,
2147                    col: 0,
2148                },
2149                DuplicateLocation {
2150                    path: PathBuf::from("src/b.ts"),
2151                    line: 20,
2152                    col: 5,
2153                },
2154            ],
2155        };
2156        let json = serde_json::to_value(&d).unwrap();
2157        assert_eq!(json["export_name"], "Button");
2158        let locs = json["locations"].as_array().unwrap();
2159        assert_eq!(locs.len(), 2);
2160        assert_eq!(locs[0]["line"], 10);
2161        assert_eq!(locs[1]["col"], 5);
2162    }
2163
2164    #[test]
2165    fn serialize_type_only_dependency() {
2166        let d = TypeOnlyDependency {
2167            package_name: "@types/react".to_string(),
2168            path: PathBuf::from("package.json"),
2169            line: 12,
2170        };
2171        let json = serde_json::to_value(&d).unwrap();
2172        assert_eq!(json["package_name"], "@types/react");
2173        assert_eq!(json["line"], 12);
2174    }
2175
2176    #[test]
2177    fn serialize_test_only_dependency() {
2178        let d = TestOnlyDependency {
2179            package_name: "vitest".to_string(),
2180            path: PathBuf::from("package.json"),
2181            line: 8,
2182        };
2183        let json = serde_json::to_value(&d).unwrap();
2184        assert_eq!(json["package_name"], "vitest");
2185        assert_eq!(json["line"], 8);
2186    }
2187
2188    #[test]
2189    fn serialize_unused_member() {
2190        let m = UnusedMember {
2191            path: PathBuf::from("enums.ts"),
2192            parent_name: "Status".to_string(),
2193            member_name: "Pending".to_string(),
2194            kind: MemberKind::EnumMember,
2195            line: 3,
2196            col: 4,
2197        };
2198        let json = serde_json::to_value(&m).unwrap();
2199        assert_eq!(json["parent_name"], "Status");
2200        assert_eq!(json["member_name"], "Pending");
2201        assert_eq!(json["line"], 3);
2202    }
2203
2204    #[test]
2205    fn serialize_unresolved_import() {
2206        let i = UnresolvedImport {
2207            path: PathBuf::from("app.ts"),
2208            specifier: "./missing-module".to_string(),
2209            line: 7,
2210            col: 0,
2211            specifier_col: 21,
2212        };
2213        let json = serde_json::to_value(&i).unwrap();
2214        assert_eq!(json["specifier"], "./missing-module");
2215        assert_eq!(json["specifier_col"], 21);
2216    }
2217
2218    // ── deserialize: CircularDependency serde(default) fields ──
2219
2220    #[test]
2221    fn deserialize_circular_dependency_with_defaults() {
2222        // CircularDependency derives Deserialize; line/col/is_cross_package have #[serde(default)]
2223        let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
2224        let cd: CircularDependency = serde_json::from_str(json).unwrap();
2225        assert_eq!(cd.files.len(), 2);
2226        assert_eq!(cd.length, 2);
2227        assert_eq!(cd.line, 0);
2228        assert_eq!(cd.col, 0);
2229        assert!(!cd.is_cross_package);
2230    }
2231
2232    #[test]
2233    fn deserialize_circular_dependency_with_all_fields() {
2234        let json =
2235            r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
2236        let cd: CircularDependency = serde_json::from_str(json).unwrap();
2237        assert_eq!(cd.line, 5);
2238        assert_eq!(cd.col, 10);
2239        assert!(cd.is_cross_package);
2240    }
2241
2242    // ── clone produces independent copies ───────────────────────
2243
2244    #[test]
2245    fn clone_results_are_independent() {
2246        let mut r = AnalysisResults::default();
2247        r.unused_files
2248            .push(UnusedFileFinding::with_actions(UnusedFile {
2249                path: PathBuf::from("a.ts"),
2250            }));
2251        let mut cloned = r.clone();
2252        cloned
2253            .unused_files
2254            .push(UnusedFileFinding::with_actions(UnusedFile {
2255                path: PathBuf::from("b.ts"),
2256            }));
2257        assert_eq!(r.total_issues(), 1);
2258        assert_eq!(cloned.total_issues(), 2);
2259    }
2260
2261    // ── export_usages not counted in total_issues ───────────────
2262
2263    #[test]
2264    fn export_usages_not_counted_in_total_issues() {
2265        let mut r = AnalysisResults::default();
2266        r.export_usages.push(ExportUsage {
2267            path: PathBuf::from("mod.ts"),
2268            export_name: "foo".to_string(),
2269            line: 1,
2270            col: 0,
2271            reference_count: 3,
2272            reference_locations: vec![],
2273        });
2274        // export_usages is metadata, not an issue type
2275        assert_eq!(r.total_issues(), 0);
2276        assert!(!r.has_issues());
2277    }
2278
2279    // ── entry_point_summary not counted in total_issues ─────────
2280
2281    #[test]
2282    fn entry_point_summary_not_counted_in_total_issues() {
2283        let r = AnalysisResults {
2284            entry_point_summary: Some(EntryPointSummary {
2285                total: 10,
2286                by_source: vec![("config".to_string(), 10)],
2287            }),
2288            ..AnalysisResults::default()
2289        };
2290        assert_eq!(r.total_issues(), 0);
2291        assert!(!r.has_issues());
2292    }
2293}