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