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