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