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