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