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::serde_path;
9use crate::suppress::IssueKind;
10
11/// Summary of detected entry points, grouped by discovery source.
12///
13/// Used to surface entry-point detection status in human and JSON output,
14/// so library authors can verify that fallow found the right entry points.
15#[derive(Debug, Clone, Default)]
16pub struct EntryPointSummary {
17    /// Total number of entry points detected.
18    pub total: usize,
19    /// Breakdown by source category (e.g., "package.json" -> 3, "plugin" -> 12).
20    /// Sorted by key for deterministic output.
21    pub by_source: Vec<(String, usize)>,
22}
23
24/// Complete analysis results.
25///
26/// # Examples
27///
28/// ```
29/// use fallow_types::results::{AnalysisResults, UnusedFile};
30/// use std::path::PathBuf;
31///
32/// let mut results = AnalysisResults::default();
33/// assert_eq!(results.total_issues(), 0);
34/// assert!(!results.has_issues());
35///
36/// results.unused_files.push(UnusedFile {
37///     path: PathBuf::from("src/dead.ts"),
38/// });
39/// assert_eq!(results.total_issues(), 1);
40/// assert!(results.has_issues());
41/// ```
42#[derive(Debug, Default, Clone, Serialize)]
43pub struct AnalysisResults {
44    /// Files not reachable from any entry point.
45    pub unused_files: Vec<UnusedFile>,
46    /// Exports never imported by other modules.
47    pub unused_exports: Vec<UnusedExport>,
48    /// Type exports never imported by other modules.
49    pub unused_types: Vec<UnusedExport>,
50    /// Dependencies listed in package.json but never imported.
51    pub unused_dependencies: Vec<UnusedDependency>,
52    /// Dev dependencies listed in package.json but never imported.
53    pub unused_dev_dependencies: Vec<UnusedDependency>,
54    /// Optional dependencies listed in package.json but never imported.
55    pub unused_optional_dependencies: Vec<UnusedDependency>,
56    /// Enum members never accessed.
57    pub unused_enum_members: Vec<UnusedMember>,
58    /// Class members never accessed.
59    pub unused_class_members: Vec<UnusedMember>,
60    /// Import specifiers that could not be resolved.
61    pub unresolved_imports: Vec<UnresolvedImport>,
62    /// Dependencies used in code but not listed in package.json.
63    pub unlisted_dependencies: Vec<UnlistedDependency>,
64    /// Exports with the same name across multiple modules.
65    pub duplicate_exports: Vec<DuplicateExport>,
66    /// Production dependencies only used via type-only imports (could be devDependencies).
67    /// Only populated in production mode.
68    pub type_only_dependencies: Vec<TypeOnlyDependency>,
69    /// Production dependencies only imported by test files (could be devDependencies).
70    #[serde(default)]
71    pub test_only_dependencies: Vec<TestOnlyDependency>,
72    /// Circular dependency chains detected in the module graph.
73    pub circular_dependencies: Vec<CircularDependency>,
74    /// Imports that cross architecture boundary rules.
75    #[serde(default)]
76    pub boundary_violations: Vec<BoundaryViolation>,
77    /// Suppression comments or JSDoc tags that no longer match any issue.
78    #[serde(default)]
79    pub stale_suppressions: Vec<StaleSuppression>,
80    /// Detected feature flag patterns. Advisory output, not included in issue counts.
81    /// Skipped during default serialization: injected separately in JSON output when enabled.
82    #[serde(skip)]
83    pub feature_flags: Vec<FeatureFlag>,
84    /// Usage counts for all exports across the project. Used by the LSP for Code Lens.
85    /// Not included in issue counts -- this is metadata, not an issue type.
86    /// Skipped during serialization: this is internal LSP data, not part of the JSON output schema.
87    #[serde(skip)]
88    pub export_usages: Vec<ExportUsage>,
89    /// Summary of detected entry points, grouped by discovery source.
90    /// Not included in issue counts -- this is informational metadata.
91    /// Skipped during serialization: rendered separately in JSON output.
92    #[serde(skip)]
93    pub entry_point_summary: Option<EntryPointSummary>,
94}
95
96impl AnalysisResults {
97    /// Total number of issues found.
98    ///
99    /// Sums across all issue categories (unused files, exports, types,
100    /// dependencies, members, unresolved imports, unlisted deps, duplicates,
101    /// type-only deps, circular deps, and boundary violations).
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use fallow_types::results::{AnalysisResults, UnusedFile, UnresolvedImport};
107    /// use std::path::PathBuf;
108    ///
109    /// let mut results = AnalysisResults::default();
110    /// results.unused_files.push(UnusedFile { path: PathBuf::from("a.ts") });
111    /// results.unresolved_imports.push(UnresolvedImport {
112    ///     path: PathBuf::from("b.ts"),
113    ///     specifier: "./missing".to_string(),
114    ///     line: 1,
115    ///     col: 0,
116    ///     specifier_col: 0,
117    /// });
118    /// assert_eq!(results.total_issues(), 2);
119    /// ```
120    #[must_use]
121    pub const fn total_issues(&self) -> usize {
122        self.unused_files.len()
123            + self.unused_exports.len()
124            + self.unused_types.len()
125            + self.unused_dependencies.len()
126            + self.unused_dev_dependencies.len()
127            + self.unused_optional_dependencies.len()
128            + self.unused_enum_members.len()
129            + self.unused_class_members.len()
130            + self.unresolved_imports.len()
131            + self.unlisted_dependencies.len()
132            + self.duplicate_exports.len()
133            + self.type_only_dependencies.len()
134            + self.test_only_dependencies.len()
135            + self.circular_dependencies.len()
136            + self.boundary_violations.len()
137            + self.stale_suppressions.len()
138    }
139
140    /// Whether any issues were found.
141    #[must_use]
142    pub const fn has_issues(&self) -> bool {
143        self.total_issues() > 0
144    }
145
146    /// Sort all result arrays for deterministic output ordering.
147    ///
148    /// Parallel collection (rayon, `FxHashMap` iteration) does not guarantee
149    /// insertion order, so the same project can produce different orderings
150    /// across runs. This method canonicalises every result list by sorting on
151    /// (path, line, col, name) so that JSON/SARIF/human output is stable.
152    pub fn sort(&mut self) {
153        self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
154
155        self.unused_exports.sort_by(|a, b| {
156            a.path
157                .cmp(&b.path)
158                .then(a.line.cmp(&b.line))
159                .then(a.export_name.cmp(&b.export_name))
160        });
161
162        self.unused_types.sort_by(|a, b| {
163            a.path
164                .cmp(&b.path)
165                .then(a.line.cmp(&b.line))
166                .then(a.export_name.cmp(&b.export_name))
167        });
168
169        self.unused_dependencies.sort_by(|a, b| {
170            a.path
171                .cmp(&b.path)
172                .then(a.line.cmp(&b.line))
173                .then(a.package_name.cmp(&b.package_name))
174        });
175
176        self.unused_dev_dependencies.sort_by(|a, b| {
177            a.path
178                .cmp(&b.path)
179                .then(a.line.cmp(&b.line))
180                .then(a.package_name.cmp(&b.package_name))
181        });
182
183        self.unused_optional_dependencies.sort_by(|a, b| {
184            a.path
185                .cmp(&b.path)
186                .then(a.line.cmp(&b.line))
187                .then(a.package_name.cmp(&b.package_name))
188        });
189
190        self.unused_enum_members.sort_by(|a, b| {
191            a.path
192                .cmp(&b.path)
193                .then(a.line.cmp(&b.line))
194                .then(a.parent_name.cmp(&b.parent_name))
195                .then(a.member_name.cmp(&b.member_name))
196        });
197
198        self.unused_class_members.sort_by(|a, b| {
199            a.path
200                .cmp(&b.path)
201                .then(a.line.cmp(&b.line))
202                .then(a.parent_name.cmp(&b.parent_name))
203                .then(a.member_name.cmp(&b.member_name))
204        });
205
206        self.unresolved_imports.sort_by(|a, b| {
207            a.path
208                .cmp(&b.path)
209                .then(a.line.cmp(&b.line))
210                .then(a.col.cmp(&b.col))
211                .then(a.specifier.cmp(&b.specifier))
212        });
213
214        self.unlisted_dependencies
215            .sort_by(|a, b| a.package_name.cmp(&b.package_name));
216        for dep in &mut self.unlisted_dependencies {
217            dep.imported_from
218                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
219        }
220
221        self.duplicate_exports
222            .sort_by(|a, b| a.export_name.cmp(&b.export_name));
223        for dup in &mut self.duplicate_exports {
224            dup.locations
225                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
226        }
227
228        self.type_only_dependencies.sort_by(|a, b| {
229            a.path
230                .cmp(&b.path)
231                .then(a.line.cmp(&b.line))
232                .then(a.package_name.cmp(&b.package_name))
233        });
234
235        self.test_only_dependencies.sort_by(|a, b| {
236            a.path
237                .cmp(&b.path)
238                .then(a.line.cmp(&b.line))
239                .then(a.package_name.cmp(&b.package_name))
240        });
241
242        self.circular_dependencies
243            .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
244
245        self.boundary_violations.sort_by(|a, b| {
246            a.from_path
247                .cmp(&b.from_path)
248                .then(a.line.cmp(&b.line))
249                .then(a.col.cmp(&b.col))
250                .then(a.to_path.cmp(&b.to_path))
251        });
252
253        self.stale_suppressions.sort_by(|a, b| {
254            a.path
255                .cmp(&b.path)
256                .then(a.line.cmp(&b.line))
257                .then(a.col.cmp(&b.col))
258        });
259
260        self.feature_flags.sort_by(|a, b| {
261            a.path
262                .cmp(&b.path)
263                .then(a.line.cmp(&b.line))
264                .then(a.flag_name.cmp(&b.flag_name))
265        });
266
267        for usage in &mut self.export_usages {
268            usage.reference_locations.sort_by(|a, b| {
269                a.path
270                    .cmp(&b.path)
271                    .then(a.line.cmp(&b.line))
272                    .then(a.col.cmp(&b.col))
273            });
274        }
275        self.export_usages.sort_by(|a, b| {
276            a.path
277                .cmp(&b.path)
278                .then(a.line.cmp(&b.line))
279                .then(a.export_name.cmp(&b.export_name))
280        });
281    }
282}
283
284/// A file that is not reachable from any entry point.
285#[derive(Debug, Clone, Serialize)]
286pub struct UnusedFile {
287    /// Absolute path to the unused file.
288    #[serde(serialize_with = "serde_path::serialize")]
289    pub path: PathBuf,
290}
291
292/// An export that is never imported by other modules.
293#[derive(Debug, Clone, Serialize)]
294pub struct UnusedExport {
295    /// File containing the unused export.
296    #[serde(serialize_with = "serde_path::serialize")]
297    pub path: PathBuf,
298    /// Name of the unused export.
299    pub export_name: String,
300    /// Whether this is a type-only export.
301    pub is_type_only: bool,
302    /// 1-based line number of the export.
303    pub line: u32,
304    /// 0-based byte column offset.
305    pub col: u32,
306    /// Byte offset into the source file (used by the fix command).
307    pub span_start: u32,
308    /// Whether this finding comes from a barrel/index re-export rather than the source definition.
309    pub is_re_export: bool,
310}
311
312/// A dependency that is listed in package.json but never imported.
313#[derive(Debug, Clone, Serialize)]
314pub struct UnusedDependency {
315    /// npm package name.
316    pub package_name: String,
317    /// Whether this is in `dependencies`, `devDependencies`, or `optionalDependencies`.
318    pub location: DependencyLocation,
319    /// Path to the package.json where this dependency is listed.
320    /// For root deps this is `<root>/package.json`, for workspace deps it is `<ws>/package.json`.
321    #[serde(serialize_with = "serde_path::serialize")]
322    pub path: PathBuf,
323    /// 1-based line number of the dependency entry in package.json.
324    pub line: u32,
325}
326
327/// Where in package.json a dependency is listed.
328///
329/// # Examples
330///
331/// ```
332/// use fallow_types::results::DependencyLocation;
333///
334/// // All three variants are constructible
335/// let loc = DependencyLocation::Dependencies;
336/// let dev = DependencyLocation::DevDependencies;
337/// let opt = DependencyLocation::OptionalDependencies;
338/// // Debug output includes the variant name
339/// assert!(format!("{loc:?}").contains("Dependencies"));
340/// assert!(format!("{dev:?}").contains("DevDependencies"));
341/// assert!(format!("{opt:?}").contains("OptionalDependencies"));
342/// ```
343#[derive(Debug, Clone, Serialize)]
344#[serde(rename_all = "camelCase")]
345pub enum DependencyLocation {
346    /// Listed in `dependencies`.
347    Dependencies,
348    /// Listed in `devDependencies`.
349    DevDependencies,
350    /// Listed in `optionalDependencies`.
351    OptionalDependencies,
352}
353
354/// An unused enum or class member.
355#[derive(Debug, Clone, Serialize)]
356pub struct UnusedMember {
357    /// File containing the unused member.
358    #[serde(serialize_with = "serde_path::serialize")]
359    pub path: PathBuf,
360    /// Name of the parent enum or class.
361    pub parent_name: String,
362    /// Name of the unused member.
363    pub member_name: String,
364    /// Whether this is an enum member, class method, or class property.
365    pub kind: MemberKind,
366    /// 1-based line number.
367    pub line: u32,
368    /// 0-based byte column offset.
369    pub col: u32,
370}
371
372/// An import that could not be resolved.
373#[derive(Debug, Clone, Serialize)]
374pub struct UnresolvedImport {
375    /// File containing the unresolved import.
376    #[serde(serialize_with = "serde_path::serialize")]
377    pub path: PathBuf,
378    /// The import specifier that could not be resolved.
379    pub specifier: String,
380    /// 1-based line number.
381    pub line: u32,
382    /// 0-based byte column offset of the import statement.
383    pub col: u32,
384    /// 0-based byte column offset of the source string literal (the specifier in quotes).
385    /// Used by the LSP to underline just the specifier, not the entire import line.
386    pub specifier_col: u32,
387}
388
389/// A dependency used in code but not listed in package.json.
390#[derive(Debug, Clone, Serialize)]
391pub struct UnlistedDependency {
392    /// npm package name.
393    pub package_name: String,
394    /// Import sites where this unlisted dependency is used (file path, line, column).
395    pub imported_from: Vec<ImportSite>,
396}
397
398/// A location where an import occurs.
399#[derive(Debug, Clone, Serialize)]
400pub struct ImportSite {
401    /// File containing the import.
402    #[serde(serialize_with = "serde_path::serialize")]
403    pub path: PathBuf,
404    /// 1-based line number.
405    pub line: u32,
406    /// 0-based byte column offset.
407    pub col: u32,
408}
409
410/// An export that appears multiple times across the project.
411#[derive(Debug, Clone, Serialize)]
412pub struct DuplicateExport {
413    /// The duplicated export name.
414    pub export_name: String,
415    /// Locations where this export name appears.
416    pub locations: Vec<DuplicateLocation>,
417}
418
419/// A location where a duplicate export appears.
420#[derive(Debug, Clone, Serialize)]
421pub struct DuplicateLocation {
422    /// File containing the duplicate export.
423    #[serde(serialize_with = "serde_path::serialize")]
424    pub path: PathBuf,
425    /// 1-based line number.
426    pub line: u32,
427    /// 0-based byte column offset.
428    pub col: u32,
429}
430
431/// A production dependency that is only used via type-only imports.
432/// In production builds, type imports are erased, so this dependency
433/// is not needed at runtime and could be moved to devDependencies.
434#[derive(Debug, Clone, Serialize)]
435pub struct TypeOnlyDependency {
436    /// npm package name.
437    pub package_name: String,
438    /// Path to the package.json where the dependency is listed.
439    #[serde(serialize_with = "serde_path::serialize")]
440    pub path: PathBuf,
441    /// 1-based line number of the dependency entry in package.json.
442    pub line: u32,
443}
444
445/// A production dependency that is only imported by test files.
446/// Since it is never used in production code, it could be moved to devDependencies.
447#[derive(Debug, Clone, Serialize)]
448pub struct TestOnlyDependency {
449    /// npm package name.
450    pub package_name: String,
451    /// Path to the package.json where the dependency is listed.
452    #[serde(serialize_with = "serde_path::serialize")]
453    pub path: PathBuf,
454    /// 1-based line number of the dependency entry in package.json.
455    pub line: u32,
456}
457
458/// A circular dependency chain detected in the module graph.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct CircularDependency {
461    /// Files forming the cycle, in import order.
462    #[serde(serialize_with = "serde_path::serialize_vec")]
463    pub files: Vec<PathBuf>,
464    /// Number of files in the cycle.
465    pub length: usize,
466    /// 1-based line number of the import that starts the cycle (in the first file).
467    #[serde(default)]
468    pub line: u32,
469    /// 0-based byte column offset of the import that starts the cycle.
470    #[serde(default)]
471    pub col: u32,
472    /// Whether this cycle crosses workspace package boundaries.
473    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474    pub is_cross_package: bool,
475}
476
477/// An import that crosses an architecture boundary rule.
478#[derive(Debug, Clone, Serialize)]
479pub struct BoundaryViolation {
480    /// The file making the disallowed import.
481    #[serde(serialize_with = "serde_path::serialize")]
482    pub from_path: PathBuf,
483    /// The file being imported that violates the boundary.
484    #[serde(serialize_with = "serde_path::serialize")]
485    pub to_path: PathBuf,
486    /// The zone the importing file belongs to.
487    pub from_zone: String,
488    /// The zone the imported file belongs to.
489    pub to_zone: String,
490    /// The raw import specifier from the source file.
491    pub import_specifier: String,
492    /// 1-based line number of the import statement in the source file.
493    pub line: u32,
494    /// 0-based byte column offset of the import statement.
495    pub col: u32,
496}
497
498/// The origin of a stale suppression: inline comment or JSDoc tag.
499#[derive(Debug, Clone, Serialize)]
500#[serde(rename_all = "snake_case", tag = "type")]
501pub enum SuppressionOrigin {
502    /// A `// fallow-ignore-next-line` or `// fallow-ignore-file` comment.
503    Comment {
504        /// The issue kind token from the comment (e.g., "unused-exports"), or None for blanket.
505        #[serde(skip_serializing_if = "Option::is_none")]
506        issue_kind: Option<String>,
507        /// Whether this was a file-level suppression.
508        is_file_level: bool,
509    },
510    /// An `@expected-unused` JSDoc tag on an export.
511    JsdocTag {
512        /// The name of the export that was tagged.
513        export_name: String,
514    },
515}
516
517/// A suppression comment or JSDoc tag that no longer matches any issue.
518#[derive(Debug, Clone, Serialize)]
519pub struct StaleSuppression {
520    /// File containing the stale suppression.
521    #[serde(serialize_with = "serde_path::serialize")]
522    pub path: PathBuf,
523    /// 1-based line number of the suppression comment or tag.
524    pub line: u32,
525    /// 0-based byte column offset.
526    pub col: u32,
527    /// The origin and details of the stale suppression.
528    pub origin: SuppressionOrigin,
529}
530
531impl StaleSuppression {
532    /// Produce a human-readable description of this stale suppression.
533    #[must_use]
534    pub fn description(&self) -> String {
535        match &self.origin {
536            SuppressionOrigin::Comment {
537                issue_kind,
538                is_file_level,
539            } => {
540                let directive = if *is_file_level {
541                    "fallow-ignore-file"
542                } else {
543                    "fallow-ignore-next-line"
544                };
545                match issue_kind {
546                    Some(kind) => format!("// {directive} {kind}"),
547                    None => format!("// {directive}"),
548                }
549            }
550            SuppressionOrigin::JsdocTag { export_name } => {
551                format!("@expected-unused on {export_name}")
552            }
553        }
554    }
555
556    /// Produce an explanation of why this suppression is stale.
557    #[must_use]
558    pub fn explanation(&self) -> String {
559        match &self.origin {
560            SuppressionOrigin::Comment {
561                issue_kind,
562                is_file_level,
563            } => {
564                let scope = if *is_file_level {
565                    "in this file"
566                } else {
567                    "on the next line"
568                };
569                match issue_kind {
570                    Some(kind) => format!("no {kind} issue found {scope}"),
571                    None => format!("no issues found {scope}"),
572                }
573            }
574            SuppressionOrigin::JsdocTag { export_name } => {
575                format!("{export_name} is now used")
576            }
577        }
578    }
579
580    /// The suppressed `IssueKind`, if this was a comment suppression with a specific kind.
581    #[must_use]
582    pub fn suppressed_kind(&self) -> Option<IssueKind> {
583        match &self.origin {
584            SuppressionOrigin::Comment { issue_kind, .. } => {
585                issue_kind.as_deref().and_then(IssueKind::parse)
586            }
587            SuppressionOrigin::JsdocTag { .. } => None,
588        }
589    }
590}
591
592/// The detection method used to identify a feature flag.
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
594#[serde(rename_all = "snake_case")]
595pub enum FlagKind {
596    /// Environment variable check (e.g., `process.env.FEATURE_X`).
597    EnvironmentVariable,
598    /// Feature flag SDK call (e.g., `useFlag('name')`, `variation('name', false)`).
599    SdkCall,
600    /// Config object property access (e.g., `config.features.newCheckout`).
601    ConfigObject,
602}
603
604/// Detection confidence for a feature flag finding.
605#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
606#[serde(rename_all = "snake_case")]
607pub enum FlagConfidence {
608    /// Low confidence: heuristic match (config object patterns).
609    Low,
610    /// Medium confidence: pattern match with some ambiguity.
611    Medium,
612    /// High confidence: unambiguous pattern (env vars, direct SDK calls).
613    High,
614}
615
616/// A detected feature flag use site.
617#[derive(Debug, Clone, Serialize)]
618pub struct FeatureFlag {
619    /// File containing the feature flag usage.
620    #[serde(serialize_with = "serde_path::serialize")]
621    pub path: PathBuf,
622    /// Name or identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
623    pub flag_name: String,
624    /// How the flag was detected.
625    pub kind: FlagKind,
626    /// Detection confidence level.
627    pub confidence: FlagConfidence,
628    /// 1-based line number.
629    pub line: u32,
630    /// 0-based byte column offset.
631    pub col: u32,
632    /// Start byte offset of the guarded code block (if-branch span), if detected.
633    #[serde(skip)]
634    pub guard_span_start: Option<u32>,
635    /// End byte offset of the guarded code block (if-branch span), if detected.
636    #[serde(skip)]
637    pub guard_span_end: Option<u32>,
638    /// SDK or provider name (e.g., "LaunchDarkly", "Statsig"), if detected from SDK call.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub sdk_name: Option<String>,
641    /// Line range of the guarded code block (derived from guard_span + line_offsets).
642    /// Used for cross-reference with dead code findings.
643    #[serde(skip)]
644    pub guard_line_start: Option<u32>,
645    /// End line of the guarded code block.
646    #[serde(skip)]
647    pub guard_line_end: Option<u32>,
648    /// Unused exports found within the guarded code block.
649    /// Populated by cross-reference with dead code analysis.
650    #[serde(skip_serializing_if = "Vec::is_empty")]
651    pub guarded_dead_exports: Vec<String>,
652}
653
654// Size assertion: FeatureFlag is stored in a Vec per analysis run.
655const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
656
657/// Usage count for an export symbol. Used by the LSP Code Lens to show
658/// reference counts above each export declaration.
659#[derive(Debug, Clone, Serialize)]
660pub struct ExportUsage {
661    /// File containing the export.
662    #[serde(serialize_with = "serde_path::serialize")]
663    pub path: PathBuf,
664    /// Name of the exported symbol.
665    pub export_name: String,
666    /// 1-based line number.
667    pub line: u32,
668    /// 0-based byte column offset.
669    pub col: u32,
670    /// Number of files that reference this export.
671    pub reference_count: usize,
672    /// Locations where this export is referenced. Used by the LSP Code Lens
673    /// to enable click-to-navigate via `editor.action.showReferences`.
674    pub reference_locations: Vec<ReferenceLocation>,
675}
676
677/// A location where an export is referenced (import site in another file).
678#[derive(Debug, Clone, Serialize)]
679pub struct ReferenceLocation {
680    /// File containing the import that references the export.
681    #[serde(serialize_with = "serde_path::serialize")]
682    pub path: PathBuf,
683    /// 1-based line number.
684    pub line: u32,
685    /// 0-based byte column offset.
686    pub col: u32,
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn empty_results_no_issues() {
695        let results = AnalysisResults::default();
696        assert_eq!(results.total_issues(), 0);
697        assert!(!results.has_issues());
698    }
699
700    #[test]
701    fn results_with_unused_file() {
702        let mut results = AnalysisResults::default();
703        results.unused_files.push(UnusedFile {
704            path: PathBuf::from("test.ts"),
705        });
706        assert_eq!(results.total_issues(), 1);
707        assert!(results.has_issues());
708    }
709
710    #[test]
711    fn results_with_unused_export() {
712        let mut results = AnalysisResults::default();
713        results.unused_exports.push(UnusedExport {
714            path: PathBuf::from("test.ts"),
715            export_name: "foo".to_string(),
716            is_type_only: false,
717            line: 1,
718            col: 0,
719            span_start: 0,
720            is_re_export: false,
721        });
722        assert_eq!(results.total_issues(), 1);
723        assert!(results.has_issues());
724    }
725
726    #[test]
727    fn results_total_counts_all_types() {
728        let mut results = AnalysisResults::default();
729        results.unused_files.push(UnusedFile {
730            path: PathBuf::from("a.ts"),
731        });
732        results.unused_exports.push(UnusedExport {
733            path: PathBuf::from("b.ts"),
734            export_name: "x".to_string(),
735            is_type_only: false,
736            line: 1,
737            col: 0,
738            span_start: 0,
739            is_re_export: false,
740        });
741        results.unused_types.push(UnusedExport {
742            path: PathBuf::from("c.ts"),
743            export_name: "T".to_string(),
744            is_type_only: true,
745            line: 1,
746            col: 0,
747            span_start: 0,
748            is_re_export: false,
749        });
750        results.unused_dependencies.push(UnusedDependency {
751            package_name: "dep".to_string(),
752            location: DependencyLocation::Dependencies,
753            path: PathBuf::from("package.json"),
754            line: 5,
755        });
756        results.unused_dev_dependencies.push(UnusedDependency {
757            package_name: "dev".to_string(),
758            location: DependencyLocation::DevDependencies,
759            path: PathBuf::from("package.json"),
760            line: 5,
761        });
762        results.unused_enum_members.push(UnusedMember {
763            path: PathBuf::from("d.ts"),
764            parent_name: "E".to_string(),
765            member_name: "A".to_string(),
766            kind: MemberKind::EnumMember,
767            line: 1,
768            col: 0,
769        });
770        results.unused_class_members.push(UnusedMember {
771            path: PathBuf::from("e.ts"),
772            parent_name: "C".to_string(),
773            member_name: "m".to_string(),
774            kind: MemberKind::ClassMethod,
775            line: 1,
776            col: 0,
777        });
778        results.unresolved_imports.push(UnresolvedImport {
779            path: PathBuf::from("f.ts"),
780            specifier: "./missing".to_string(),
781            line: 1,
782            col: 0,
783            specifier_col: 0,
784        });
785        results.unlisted_dependencies.push(UnlistedDependency {
786            package_name: "unlisted".to_string(),
787            imported_from: vec![ImportSite {
788                path: PathBuf::from("g.ts"),
789                line: 1,
790                col: 0,
791            }],
792        });
793        results.duplicate_exports.push(DuplicateExport {
794            export_name: "dup".to_string(),
795            locations: vec![
796                DuplicateLocation {
797                    path: PathBuf::from("h.ts"),
798                    line: 15,
799                    col: 0,
800                },
801                DuplicateLocation {
802                    path: PathBuf::from("i.ts"),
803                    line: 30,
804                    col: 0,
805                },
806            ],
807        });
808        results.unused_optional_dependencies.push(UnusedDependency {
809            package_name: "optional".to_string(),
810            location: DependencyLocation::OptionalDependencies,
811            path: PathBuf::from("package.json"),
812            line: 5,
813        });
814        results.type_only_dependencies.push(TypeOnlyDependency {
815            package_name: "type-only".to_string(),
816            path: PathBuf::from("package.json"),
817            line: 8,
818        });
819        results.test_only_dependencies.push(TestOnlyDependency {
820            package_name: "test-only".to_string(),
821            path: PathBuf::from("package.json"),
822            line: 9,
823        });
824        results.circular_dependencies.push(CircularDependency {
825            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
826            length: 2,
827            line: 3,
828            col: 0,
829            is_cross_package: false,
830        });
831        results.boundary_violations.push(BoundaryViolation {
832            from_path: PathBuf::from("src/ui/Button.tsx"),
833            to_path: PathBuf::from("src/db/queries.ts"),
834            from_zone: "ui".to_string(),
835            to_zone: "database".to_string(),
836            import_specifier: "../db/queries".to_string(),
837            line: 3,
838            col: 0,
839        });
840
841        // 15 categories, one of each
842        assert_eq!(results.total_issues(), 15);
843        assert!(results.has_issues());
844    }
845
846    // ── total_issues / has_issues consistency ──────────────────
847
848    #[test]
849    fn total_issues_and_has_issues_are_consistent() {
850        let results = AnalysisResults::default();
851        assert_eq!(results.total_issues(), 0);
852        assert!(!results.has_issues());
853        assert_eq!(results.total_issues() > 0, results.has_issues());
854    }
855
856    // ── total_issues counts each category independently ─────────
857
858    #[test]
859    fn total_issues_sums_all_categories_independently() {
860        let mut results = AnalysisResults::default();
861        results.unused_files.push(UnusedFile {
862            path: PathBuf::from("a.ts"),
863        });
864        assert_eq!(results.total_issues(), 1);
865
866        results.unused_files.push(UnusedFile {
867            path: PathBuf::from("b.ts"),
868        });
869        assert_eq!(results.total_issues(), 2);
870
871        results.unresolved_imports.push(UnresolvedImport {
872            path: PathBuf::from("c.ts"),
873            specifier: "./missing".to_string(),
874            line: 1,
875            col: 0,
876            specifier_col: 0,
877        });
878        assert_eq!(results.total_issues(), 3);
879    }
880
881    // ── default is truly empty ──────────────────────────────────
882
883    #[test]
884    fn default_results_all_fields_empty() {
885        let r = AnalysisResults::default();
886        assert!(r.unused_files.is_empty());
887        assert!(r.unused_exports.is_empty());
888        assert!(r.unused_types.is_empty());
889        assert!(r.unused_dependencies.is_empty());
890        assert!(r.unused_dev_dependencies.is_empty());
891        assert!(r.unused_optional_dependencies.is_empty());
892        assert!(r.unused_enum_members.is_empty());
893        assert!(r.unused_class_members.is_empty());
894        assert!(r.unresolved_imports.is_empty());
895        assert!(r.unlisted_dependencies.is_empty());
896        assert!(r.duplicate_exports.is_empty());
897        assert!(r.type_only_dependencies.is_empty());
898        assert!(r.test_only_dependencies.is_empty());
899        assert!(r.circular_dependencies.is_empty());
900        assert!(r.boundary_violations.is_empty());
901        assert!(r.export_usages.is_empty());
902    }
903
904    // ── EntryPointSummary ────────────────────────────────────────
905
906    #[test]
907    fn entry_point_summary_default() {
908        let summary = EntryPointSummary::default();
909        assert_eq!(summary.total, 0);
910        assert!(summary.by_source.is_empty());
911    }
912
913    #[test]
914    fn entry_point_summary_not_in_default_results() {
915        let r = AnalysisResults::default();
916        assert!(r.entry_point_summary.is_none());
917    }
918
919    #[test]
920    fn entry_point_summary_some_preserves_data() {
921        let r = AnalysisResults {
922            entry_point_summary: Some(EntryPointSummary {
923                total: 5,
924                by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
925            }),
926            ..AnalysisResults::default()
927        };
928        let summary = r.entry_point_summary.as_ref().unwrap();
929        assert_eq!(summary.total, 5);
930        assert_eq!(summary.by_source.len(), 2);
931        assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
932    }
933
934    // ── sort: unused_files by path ──────────────────────────────
935
936    #[test]
937    fn sort_unused_files_by_path() {
938        let mut r = AnalysisResults::default();
939        r.unused_files.push(UnusedFile {
940            path: PathBuf::from("z.ts"),
941        });
942        r.unused_files.push(UnusedFile {
943            path: PathBuf::from("a.ts"),
944        });
945        r.unused_files.push(UnusedFile {
946            path: PathBuf::from("m.ts"),
947        });
948        r.sort();
949        let paths: Vec<_> = r
950            .unused_files
951            .iter()
952            .map(|f| f.path.to_string_lossy().to_string())
953            .collect();
954        assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
955    }
956
957    // ── sort: unused_exports by path, line, name ────────────────
958
959    #[test]
960    fn sort_unused_exports_by_path_line_name() {
961        let mut r = AnalysisResults::default();
962        let mk = |path: &str, line: u32, name: &str| UnusedExport {
963            path: PathBuf::from(path),
964            export_name: name.to_string(),
965            is_type_only: false,
966            line,
967            col: 0,
968            span_start: 0,
969            is_re_export: false,
970        };
971        r.unused_exports.push(mk("b.ts", 5, "beta"));
972        r.unused_exports.push(mk("a.ts", 10, "zeta"));
973        r.unused_exports.push(mk("a.ts", 10, "alpha"));
974        r.unused_exports.push(mk("a.ts", 1, "gamma"));
975        r.sort();
976        let keys: Vec<_> = r
977            .unused_exports
978            .iter()
979            .map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
980            .collect();
981        assert_eq!(
982            keys,
983            vec![
984                "a.ts:1:gamma",
985                "a.ts:10:alpha",
986                "a.ts:10:zeta",
987                "b.ts:5:beta"
988            ]
989        );
990    }
991
992    // ── sort: unused_types (same sort as unused_exports) ────────
993
994    #[test]
995    fn sort_unused_types_by_path_line_name() {
996        let mut r = AnalysisResults::default();
997        let mk = |path: &str, line: u32, name: &str| UnusedExport {
998            path: PathBuf::from(path),
999            export_name: name.to_string(),
1000            is_type_only: true,
1001            line,
1002            col: 0,
1003            span_start: 0,
1004            is_re_export: false,
1005        };
1006        r.unused_types.push(mk("z.ts", 1, "Z"));
1007        r.unused_types.push(mk("a.ts", 1, "A"));
1008        r.sort();
1009        assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
1010        assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
1011    }
1012
1013    // ── sort: unused_dependencies by path, line, name ───────────
1014
1015    #[test]
1016    fn sort_unused_dependencies_by_path_line_name() {
1017        let mut r = AnalysisResults::default();
1018        let mk = |path: &str, line: u32, name: &str| UnusedDependency {
1019            package_name: name.to_string(),
1020            location: DependencyLocation::Dependencies,
1021            path: PathBuf::from(path),
1022            line,
1023        };
1024        r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1025        r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1026        r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1027        r.sort();
1028        let names: Vec<_> = r
1029            .unused_dependencies
1030            .iter()
1031            .map(|d| d.package_name.as_str())
1032            .collect();
1033        assert_eq!(names, vec!["axios", "react", "zlib"]);
1034    }
1035
1036    // ── sort: unused_dev_dependencies ───────────────────────────
1037
1038    #[test]
1039    fn sort_unused_dev_dependencies() {
1040        let mut r = AnalysisResults::default();
1041        r.unused_dev_dependencies.push(UnusedDependency {
1042            package_name: "vitest".to_string(),
1043            location: DependencyLocation::DevDependencies,
1044            path: PathBuf::from("package.json"),
1045            line: 10,
1046        });
1047        r.unused_dev_dependencies.push(UnusedDependency {
1048            package_name: "jest".to_string(),
1049            location: DependencyLocation::DevDependencies,
1050            path: PathBuf::from("package.json"),
1051            line: 5,
1052        });
1053        r.sort();
1054        assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
1055        assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
1056    }
1057
1058    // ── sort: unused_optional_dependencies ──────────────────────
1059
1060    #[test]
1061    fn sort_unused_optional_dependencies() {
1062        let mut r = AnalysisResults::default();
1063        r.unused_optional_dependencies.push(UnusedDependency {
1064            package_name: "zod".to_string(),
1065            location: DependencyLocation::OptionalDependencies,
1066            path: PathBuf::from("package.json"),
1067            line: 3,
1068        });
1069        r.unused_optional_dependencies.push(UnusedDependency {
1070            package_name: "ajv".to_string(),
1071            location: DependencyLocation::OptionalDependencies,
1072            path: PathBuf::from("package.json"),
1073            line: 2,
1074        });
1075        r.sort();
1076        assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
1077        assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
1078    }
1079
1080    // ── sort: unused_enum_members by path, line, parent, member ─
1081
1082    #[test]
1083    fn sort_unused_enum_members_by_path_line_parent_member() {
1084        let mut r = AnalysisResults::default();
1085        let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1086            path: PathBuf::from(path),
1087            parent_name: parent.to_string(),
1088            member_name: member.to_string(),
1089            kind: MemberKind::EnumMember,
1090            line,
1091            col: 0,
1092        };
1093        r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1094        r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1095        r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1096        r.sort();
1097        let keys: Vec<_> = r
1098            .unused_enum_members
1099            .iter()
1100            .map(|m| format!("{}:{}", m.parent_name, m.member_name))
1101            .collect();
1102        assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1103    }
1104
1105    // ── sort: unused_class_members by path, line, parent, member
1106
1107    #[test]
1108    fn sort_unused_class_members() {
1109        let mut r = AnalysisResults::default();
1110        let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1111            path: PathBuf::from(path),
1112            parent_name: parent.to_string(),
1113            member_name: member.to_string(),
1114            kind: MemberKind::ClassMethod,
1115            line,
1116            col: 0,
1117        };
1118        r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1119        r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1120        r.sort();
1121        assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
1122        assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
1123    }
1124
1125    // ── sort: unresolved_imports by path, line, col, specifier ──
1126
1127    #[test]
1128    fn sort_unresolved_imports_by_path_line_col_specifier() {
1129        let mut r = AnalysisResults::default();
1130        let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
1131            path: PathBuf::from(path),
1132            specifier: spec.to_string(),
1133            line,
1134            col,
1135            specifier_col: 0,
1136        };
1137        r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1138        r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1139        r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1140        r.sort();
1141        let specs: Vec<_> = r
1142            .unresolved_imports
1143            .iter()
1144            .map(|i| i.specifier.as_str())
1145            .collect();
1146        assert_eq!(specs, vec!["./m", "./a", "./z"]);
1147    }
1148
1149    // ── sort: unlisted_dependencies + inner imported_from ───────
1150
1151    #[test]
1152    fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1153        let mut r = AnalysisResults::default();
1154        r.unlisted_dependencies.push(UnlistedDependency {
1155            package_name: "zod".to_string(),
1156            imported_from: vec![
1157                ImportSite {
1158                    path: PathBuf::from("b.ts"),
1159                    line: 10,
1160                    col: 0,
1161                },
1162                ImportSite {
1163                    path: PathBuf::from("a.ts"),
1164                    line: 1,
1165                    col: 0,
1166                },
1167            ],
1168        });
1169        r.unlisted_dependencies.push(UnlistedDependency {
1170            package_name: "axios".to_string(),
1171            imported_from: vec![ImportSite {
1172                path: PathBuf::from("c.ts"),
1173                line: 1,
1174                col: 0,
1175            }],
1176        });
1177        r.sort();
1178
1179        // Outer sort: by package_name
1180        assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
1181        assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
1182
1183        // Inner sort: imported_from sorted by path, then line
1184        let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1185            .imported_from
1186            .iter()
1187            .map(|s| s.path.to_string_lossy().to_string())
1188            .collect();
1189        assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1190    }
1191
1192    // ── sort: duplicate_exports + inner locations ───────────────
1193
1194    #[test]
1195    fn sort_duplicate_exports_by_name_and_inner_locations() {
1196        let mut r = AnalysisResults::default();
1197        r.duplicate_exports.push(DuplicateExport {
1198            export_name: "z".to_string(),
1199            locations: vec![
1200                DuplicateLocation {
1201                    path: PathBuf::from("c.ts"),
1202                    line: 1,
1203                    col: 0,
1204                },
1205                DuplicateLocation {
1206                    path: PathBuf::from("a.ts"),
1207                    line: 5,
1208                    col: 0,
1209                },
1210            ],
1211        });
1212        r.duplicate_exports.push(DuplicateExport {
1213            export_name: "a".to_string(),
1214            locations: vec![DuplicateLocation {
1215                path: PathBuf::from("b.ts"),
1216                line: 1,
1217                col: 0,
1218            }],
1219        });
1220        r.sort();
1221
1222        // Outer sort: by export_name
1223        assert_eq!(r.duplicate_exports[0].export_name, "a");
1224        assert_eq!(r.duplicate_exports[1].export_name, "z");
1225
1226        // Inner sort: locations sorted by path, then line
1227        let z_locs: Vec<_> = r.duplicate_exports[1]
1228            .locations
1229            .iter()
1230            .map(|l| l.path.to_string_lossy().to_string())
1231            .collect();
1232        assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1233    }
1234
1235    // ── sort: type_only_dependencies ────────────────────────────
1236
1237    #[test]
1238    fn sort_type_only_dependencies() {
1239        let mut r = AnalysisResults::default();
1240        r.type_only_dependencies.push(TypeOnlyDependency {
1241            package_name: "zod".to_string(),
1242            path: PathBuf::from("package.json"),
1243            line: 10,
1244        });
1245        r.type_only_dependencies.push(TypeOnlyDependency {
1246            package_name: "ajv".to_string(),
1247            path: PathBuf::from("package.json"),
1248            line: 5,
1249        });
1250        r.sort();
1251        assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
1252        assert_eq!(r.type_only_dependencies[1].package_name, "zod");
1253    }
1254
1255    // ── sort: test_only_dependencies ────────────────────────────
1256
1257    #[test]
1258    fn sort_test_only_dependencies() {
1259        let mut r = AnalysisResults::default();
1260        r.test_only_dependencies.push(TestOnlyDependency {
1261            package_name: "vitest".to_string(),
1262            path: PathBuf::from("package.json"),
1263            line: 15,
1264        });
1265        r.test_only_dependencies.push(TestOnlyDependency {
1266            package_name: "jest".to_string(),
1267            path: PathBuf::from("package.json"),
1268            line: 10,
1269        });
1270        r.sort();
1271        assert_eq!(r.test_only_dependencies[0].package_name, "jest");
1272        assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
1273    }
1274
1275    // ── sort: circular_dependencies by files, then length ───────
1276
1277    #[test]
1278    fn sort_circular_dependencies_by_files_then_length() {
1279        let mut r = AnalysisResults::default();
1280        r.circular_dependencies.push(CircularDependency {
1281            files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1282            length: 2,
1283            line: 1,
1284            col: 0,
1285            is_cross_package: false,
1286        });
1287        r.circular_dependencies.push(CircularDependency {
1288            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1289            length: 2,
1290            line: 1,
1291            col: 0,
1292            is_cross_package: true,
1293        });
1294        r.sort();
1295        assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
1296        assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
1297    }
1298
1299    // ── sort: boundary_violations by from_path, line, col, to_path
1300
1301    #[test]
1302    fn sort_boundary_violations() {
1303        let mut r = AnalysisResults::default();
1304        let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
1305            from_path: PathBuf::from(from),
1306            to_path: PathBuf::from(to),
1307            from_zone: "a".to_string(),
1308            to_zone: "b".to_string(),
1309            import_specifier: to.to_string(),
1310            line,
1311            col,
1312        };
1313        r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1314        r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1315        r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1316        r.sort();
1317        let from_paths: Vec<_> = r
1318            .boundary_violations
1319            .iter()
1320            .map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
1321            .collect();
1322        assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1323    }
1324
1325    // ── sort: export_usages + inner reference_locations ─────────
1326
1327    #[test]
1328    fn sort_export_usages_and_inner_reference_locations() {
1329        let mut r = AnalysisResults::default();
1330        r.export_usages.push(ExportUsage {
1331            path: PathBuf::from("z.ts"),
1332            export_name: "foo".to_string(),
1333            line: 1,
1334            col: 0,
1335            reference_count: 2,
1336            reference_locations: vec![
1337                ReferenceLocation {
1338                    path: PathBuf::from("c.ts"),
1339                    line: 10,
1340                    col: 0,
1341                },
1342                ReferenceLocation {
1343                    path: PathBuf::from("a.ts"),
1344                    line: 5,
1345                    col: 0,
1346                },
1347            ],
1348        });
1349        r.export_usages.push(ExportUsage {
1350            path: PathBuf::from("a.ts"),
1351            export_name: "bar".to_string(),
1352            line: 1,
1353            col: 0,
1354            reference_count: 1,
1355            reference_locations: vec![ReferenceLocation {
1356                path: PathBuf::from("b.ts"),
1357                line: 1,
1358                col: 0,
1359            }],
1360        });
1361        r.sort();
1362
1363        // Outer sort: by path, then line, then export_name
1364        assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1365        assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1366
1367        // Inner sort: reference_locations sorted by path, line, col
1368        let refs: Vec<_> = r.export_usages[1]
1369            .reference_locations
1370            .iter()
1371            .map(|l| l.path.to_string_lossy().to_string())
1372            .collect();
1373        assert_eq!(refs, vec!["a.ts", "c.ts"]);
1374    }
1375
1376    // ── sort: empty results does not panic ──────────────────────
1377
1378    #[test]
1379    fn sort_empty_results_is_noop() {
1380        let mut r = AnalysisResults::default();
1381        r.sort(); // should not panic
1382        assert_eq!(r.total_issues(), 0);
1383    }
1384
1385    // ── sort: single-element lists remain stable ────────────────
1386
1387    #[test]
1388    fn sort_single_element_lists_stable() {
1389        let mut r = AnalysisResults::default();
1390        r.unused_files.push(UnusedFile {
1391            path: PathBuf::from("only.ts"),
1392        });
1393        r.sort();
1394        assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
1395    }
1396
1397    // ── serialization ──────────────────────────────────────────
1398
1399    #[test]
1400    fn serialize_empty_results() {
1401        let r = AnalysisResults::default();
1402        let json = serde_json::to_value(&r).unwrap();
1403
1404        // All arrays should be present and empty
1405        assert!(json["unused_files"].as_array().unwrap().is_empty());
1406        assert!(json["unused_exports"].as_array().unwrap().is_empty());
1407        assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1408
1409        // Skipped fields should be absent
1410        assert!(json.get("export_usages").is_none());
1411        assert!(json.get("entry_point_summary").is_none());
1412    }
1413
1414    #[test]
1415    fn serialize_unused_file_path() {
1416        let r = UnusedFile {
1417            path: PathBuf::from("src/utils/index.ts"),
1418        };
1419        let json = serde_json::to_value(&r).unwrap();
1420        assert_eq!(json["path"], "src/utils/index.ts");
1421    }
1422
1423    #[test]
1424    fn serialize_dependency_location_camel_case() {
1425        let dep = UnusedDependency {
1426            package_name: "react".to_string(),
1427            location: DependencyLocation::DevDependencies,
1428            path: PathBuf::from("package.json"),
1429            line: 5,
1430        };
1431        let json = serde_json::to_value(&dep).unwrap();
1432        assert_eq!(json["location"], "devDependencies");
1433
1434        let dep2 = UnusedDependency {
1435            package_name: "react".to_string(),
1436            location: DependencyLocation::Dependencies,
1437            path: PathBuf::from("package.json"),
1438            line: 3,
1439        };
1440        let json2 = serde_json::to_value(&dep2).unwrap();
1441        assert_eq!(json2["location"], "dependencies");
1442
1443        let dep3 = UnusedDependency {
1444            package_name: "fsevents".to_string(),
1445            location: DependencyLocation::OptionalDependencies,
1446            path: PathBuf::from("package.json"),
1447            line: 7,
1448        };
1449        let json3 = serde_json::to_value(&dep3).unwrap();
1450        assert_eq!(json3["location"], "optionalDependencies");
1451    }
1452
1453    #[test]
1454    fn serialize_circular_dependency_skips_false_cross_package() {
1455        let cd = CircularDependency {
1456            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1457            length: 2,
1458            line: 1,
1459            col: 0,
1460            is_cross_package: false,
1461        };
1462        let json = serde_json::to_value(&cd).unwrap();
1463        // skip_serializing_if = "std::ops::Not::not" means false is skipped
1464        assert!(json.get("is_cross_package").is_none());
1465    }
1466
1467    #[test]
1468    fn serialize_circular_dependency_includes_true_cross_package() {
1469        let cd = CircularDependency {
1470            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1471            length: 2,
1472            line: 1,
1473            col: 0,
1474            is_cross_package: true,
1475        };
1476        let json = serde_json::to_value(&cd).unwrap();
1477        assert_eq!(json["is_cross_package"], true);
1478    }
1479
1480    #[test]
1481    fn serialize_unused_export_fields() {
1482        let e = UnusedExport {
1483            path: PathBuf::from("src/mod.ts"),
1484            export_name: "helper".to_string(),
1485            is_type_only: true,
1486            line: 42,
1487            col: 7,
1488            span_start: 100,
1489            is_re_export: true,
1490        };
1491        let json = serde_json::to_value(&e).unwrap();
1492        assert_eq!(json["path"], "src/mod.ts");
1493        assert_eq!(json["export_name"], "helper");
1494        assert_eq!(json["is_type_only"], true);
1495        assert_eq!(json["line"], 42);
1496        assert_eq!(json["col"], 7);
1497        assert_eq!(json["span_start"], 100);
1498        assert_eq!(json["is_re_export"], true);
1499    }
1500
1501    #[test]
1502    fn serialize_boundary_violation_fields() {
1503        let v = BoundaryViolation {
1504            from_path: PathBuf::from("src/ui/button.tsx"),
1505            to_path: PathBuf::from("src/db/queries.ts"),
1506            from_zone: "ui".to_string(),
1507            to_zone: "db".to_string(),
1508            import_specifier: "../db/queries".to_string(),
1509            line: 3,
1510            col: 0,
1511        };
1512        let json = serde_json::to_value(&v).unwrap();
1513        assert_eq!(json["from_path"], "src/ui/button.tsx");
1514        assert_eq!(json["to_path"], "src/db/queries.ts");
1515        assert_eq!(json["from_zone"], "ui");
1516        assert_eq!(json["to_zone"], "db");
1517        assert_eq!(json["import_specifier"], "../db/queries");
1518    }
1519
1520    #[test]
1521    fn serialize_unlisted_dependency_with_import_sites() {
1522        let d = UnlistedDependency {
1523            package_name: "chalk".to_string(),
1524            imported_from: vec![
1525                ImportSite {
1526                    path: PathBuf::from("a.ts"),
1527                    line: 1,
1528                    col: 0,
1529                },
1530                ImportSite {
1531                    path: PathBuf::from("b.ts"),
1532                    line: 5,
1533                    col: 3,
1534                },
1535            ],
1536        };
1537        let json = serde_json::to_value(&d).unwrap();
1538        assert_eq!(json["package_name"], "chalk");
1539        let sites = json["imported_from"].as_array().unwrap();
1540        assert_eq!(sites.len(), 2);
1541        assert_eq!(sites[0]["path"], "a.ts");
1542        assert_eq!(sites[1]["line"], 5);
1543    }
1544
1545    #[test]
1546    fn serialize_duplicate_export_with_locations() {
1547        let d = DuplicateExport {
1548            export_name: "Button".to_string(),
1549            locations: vec![
1550                DuplicateLocation {
1551                    path: PathBuf::from("src/a.ts"),
1552                    line: 10,
1553                    col: 0,
1554                },
1555                DuplicateLocation {
1556                    path: PathBuf::from("src/b.ts"),
1557                    line: 20,
1558                    col: 5,
1559                },
1560            ],
1561        };
1562        let json = serde_json::to_value(&d).unwrap();
1563        assert_eq!(json["export_name"], "Button");
1564        let locs = json["locations"].as_array().unwrap();
1565        assert_eq!(locs.len(), 2);
1566        assert_eq!(locs[0]["line"], 10);
1567        assert_eq!(locs[1]["col"], 5);
1568    }
1569
1570    #[test]
1571    fn serialize_type_only_dependency() {
1572        let d = TypeOnlyDependency {
1573            package_name: "@types/react".to_string(),
1574            path: PathBuf::from("package.json"),
1575            line: 12,
1576        };
1577        let json = serde_json::to_value(&d).unwrap();
1578        assert_eq!(json["package_name"], "@types/react");
1579        assert_eq!(json["line"], 12);
1580    }
1581
1582    #[test]
1583    fn serialize_test_only_dependency() {
1584        let d = TestOnlyDependency {
1585            package_name: "vitest".to_string(),
1586            path: PathBuf::from("package.json"),
1587            line: 8,
1588        };
1589        let json = serde_json::to_value(&d).unwrap();
1590        assert_eq!(json["package_name"], "vitest");
1591        assert_eq!(json["line"], 8);
1592    }
1593
1594    #[test]
1595    fn serialize_unused_member() {
1596        let m = UnusedMember {
1597            path: PathBuf::from("enums.ts"),
1598            parent_name: "Status".to_string(),
1599            member_name: "Pending".to_string(),
1600            kind: MemberKind::EnumMember,
1601            line: 3,
1602            col: 4,
1603        };
1604        let json = serde_json::to_value(&m).unwrap();
1605        assert_eq!(json["parent_name"], "Status");
1606        assert_eq!(json["member_name"], "Pending");
1607        assert_eq!(json["line"], 3);
1608    }
1609
1610    #[test]
1611    fn serialize_unresolved_import() {
1612        let i = UnresolvedImport {
1613            path: PathBuf::from("app.ts"),
1614            specifier: "./missing-module".to_string(),
1615            line: 7,
1616            col: 0,
1617            specifier_col: 21,
1618        };
1619        let json = serde_json::to_value(&i).unwrap();
1620        assert_eq!(json["specifier"], "./missing-module");
1621        assert_eq!(json["specifier_col"], 21);
1622    }
1623
1624    // ── deserialize: CircularDependency serde(default) fields ──
1625
1626    #[test]
1627    fn deserialize_circular_dependency_with_defaults() {
1628        // CircularDependency derives Deserialize; line/col/is_cross_package have #[serde(default)]
1629        let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
1630        let cd: CircularDependency = serde_json::from_str(json).unwrap();
1631        assert_eq!(cd.files.len(), 2);
1632        assert_eq!(cd.length, 2);
1633        assert_eq!(cd.line, 0);
1634        assert_eq!(cd.col, 0);
1635        assert!(!cd.is_cross_package);
1636    }
1637
1638    #[test]
1639    fn deserialize_circular_dependency_with_all_fields() {
1640        let json =
1641            r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
1642        let cd: CircularDependency = serde_json::from_str(json).unwrap();
1643        assert_eq!(cd.line, 5);
1644        assert_eq!(cd.col, 10);
1645        assert!(cd.is_cross_package);
1646    }
1647
1648    // ── clone produces independent copies ───────────────────────
1649
1650    #[test]
1651    fn clone_results_are_independent() {
1652        let mut r = AnalysisResults::default();
1653        r.unused_files.push(UnusedFile {
1654            path: PathBuf::from("a.ts"),
1655        });
1656        let mut cloned = r.clone();
1657        cloned.unused_files.push(UnusedFile {
1658            path: PathBuf::from("b.ts"),
1659        });
1660        assert_eq!(r.total_issues(), 1);
1661        assert_eq!(cloned.total_issues(), 2);
1662    }
1663
1664    // ── export_usages not counted in total_issues ───────────────
1665
1666    #[test]
1667    fn export_usages_not_counted_in_total_issues() {
1668        let mut r = AnalysisResults::default();
1669        r.export_usages.push(ExportUsage {
1670            path: PathBuf::from("mod.ts"),
1671            export_name: "foo".to_string(),
1672            line: 1,
1673            col: 0,
1674            reference_count: 3,
1675            reference_locations: vec![],
1676        });
1677        // export_usages is metadata, not an issue type
1678        assert_eq!(r.total_issues(), 0);
1679        assert!(!r.has_issues());
1680    }
1681
1682    // ── entry_point_summary not counted in total_issues ─────────
1683
1684    #[test]
1685    fn entry_point_summary_not_counted_in_total_issues() {
1686        let r = AnalysisResults {
1687            entry_point_summary: Some(EntryPointSummary {
1688                total: 10,
1689                by_source: vec![("config".to_string(), 10)],
1690            }),
1691            ..AnalysisResults::default()
1692        };
1693        assert_eq!(r.total_issues(), 0);
1694        assert!(!r.has_issues());
1695    }
1696}