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