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