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