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