Skip to main content

fallow_types/
results.rs

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