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