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