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, SecurityControlKind};
8use crate::output::IssueAction;
9use crate::output_dead_code::{
10    BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
11    EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeakFinding,
12    ReExportCycleFinding, TestOnlyDependencyFinding, TypeOnlyDependencyFinding,
13    UnlistedDependencyFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
14    UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedDependencyFinding,
15    UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
16    UnusedExportFinding, UnusedFileFinding, UnusedOptionalDependencyFinding, UnusedTypeFinding,
17};
18use crate::serde_path;
19use crate::suppress::{IssueKind, closest_known_kind_name};
20
21/// Summary of detected entry points, grouped by discovery source.
22///
23/// Used to surface entry-point detection status in human and JSON output,
24/// so library authors can verify that fallow found the right entry points.
25#[derive(Debug, Clone, Default)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct EntryPointSummary {
28    /// Total number of entry points detected.
29    pub total: usize,
30    /// Breakdown by source category (e.g., "package.json" -> 3, "plugin" -> 12).
31    /// Sorted by key for deterministic output.
32    pub by_source: Vec<(String, usize)>,
33}
34
35/// Complete analysis results.
36///
37/// # Examples
38///
39/// ```
40/// use fallow_types::output_dead_code::UnusedFileFinding;
41/// use fallow_types::results::{AnalysisResults, UnusedFile};
42/// use std::path::PathBuf;
43///
44/// let mut results = AnalysisResults::default();
45/// assert_eq!(results.total_issues(), 0);
46/// assert!(!results.has_issues());
47///
48/// results
49///     .unused_files
50///     .push(UnusedFileFinding::with_actions(UnusedFile {
51///         path: PathBuf::from("src/dead.ts"),
52///     }));
53/// assert_eq!(results.total_issues(), 1);
54/// assert!(results.has_issues());
55/// ```
56#[derive(Debug, Default, Clone, Serialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct AnalysisResults {
59    /// Files not reachable from any entry point. Wrapped in
60    /// [`UnusedFileFinding`] so each entry carries a typed `actions` array
61    /// natively, replacing the pre-2.76 post-pass injection.
62    pub unused_files: Vec<UnusedFileFinding>,
63    /// Exports never imported by other modules. Wrapped in
64    /// [`UnusedExportFinding`] so each entry carries a typed `actions`
65    /// array natively.
66    pub unused_exports: Vec<UnusedExportFinding>,
67    /// Type exports never imported by other modules. Wrapped in
68    /// [`UnusedTypeFinding`]: the inner [`UnusedExport`] struct is shared
69    /// with `unused_exports` but the wrapper emits a type-targeted fix
70    /// description.
71    pub unused_types: Vec<UnusedTypeFinding>,
72    /// Exported symbols whose public signature references same-file private
73    /// types. Wrapped in [`PrivateTypeLeakFinding`] so each entry carries a
74    /// typed `actions` array natively.
75    pub private_type_leaks: Vec<PrivateTypeLeakFinding>,
76    /// Dependencies listed in package.json but never imported. Wrapped in
77    /// [`UnusedDependencyFinding`] so each entry carries a typed `actions`
78    /// array natively. The fix action swaps from `remove-dependency` to
79    /// `move-dependency` when `used_in_workspaces` is non-empty.
80    pub unused_dependencies: Vec<UnusedDependencyFinding>,
81    /// Dev dependencies listed in package.json but never imported. Wrapped
82    /// in [`UnusedDevDependencyFinding`]: same bare struct as
83    /// `unused_dependencies` with a `devDependencies`-targeted fix
84    /// description.
85    pub unused_dev_dependencies: Vec<UnusedDevDependencyFinding>,
86    /// Optional dependencies listed in package.json but never imported.
87    /// Wrapped in [`UnusedOptionalDependencyFinding`] with an
88    /// `optionalDependencies`-targeted fix description.
89    pub unused_optional_dependencies: Vec<UnusedOptionalDependencyFinding>,
90    /// Enum members never accessed. Wrapped in
91    /// [`UnusedEnumMemberFinding`] so each entry carries a typed `actions`
92    /// array natively.
93    pub unused_enum_members: Vec<UnusedEnumMemberFinding>,
94    /// Class members never accessed. Wrapped in
95    /// [`UnusedClassMemberFinding`]: same inner [`UnusedMember`] struct as
96    /// `unused_enum_members`, with a class-targeted fix description and the
97    /// `auto_fixable: false` default to reflect dependency-injection
98    /// patterns.
99    pub unused_class_members: Vec<UnusedClassMemberFinding>,
100    /// Import specifiers that could not be resolved. Wrapped in
101    /// [`UnresolvedImportFinding`] so each entry carries a typed `actions`
102    /// array natively.
103    pub unresolved_imports: Vec<UnresolvedImportFinding>,
104    /// Dependencies used in code but not listed in package.json. Wrapped in
105    /// [`UnlistedDependencyFinding`].
106    pub unlisted_dependencies: Vec<UnlistedDependencyFinding>,
107    /// Exports with the same name across multiple modules. Wrapped in
108    /// [`DuplicateExportFinding`] so each entry carries a typed `actions`
109    /// array natively, with the position-0 `add-to-config` `ignoreExports`
110    /// snippet wired in at wrapper construction.
111    pub duplicate_exports: Vec<DuplicateExportFinding>,
112    /// Production dependencies only used via type-only imports (could be
113    /// devDependencies). Only populated in production mode. Wrapped in
114    /// [`TypeOnlyDependencyFinding`].
115    pub type_only_dependencies: Vec<TypeOnlyDependencyFinding>,
116    /// Production dependencies only imported by test files (could be
117    /// devDependencies). Wrapped in [`TestOnlyDependencyFinding`].
118    #[serde(default)]
119    pub test_only_dependencies: Vec<TestOnlyDependencyFinding>,
120    /// Circular dependency chains detected in the module graph. Wrapped in
121    /// [`CircularDependencyFinding`] so each entry carries a typed `actions`
122    /// array natively.
123    pub circular_dependencies: Vec<CircularDependencyFinding>,
124    /// Cycles or self-loops in the re-export edge subgraph (barrel files
125    /// re-exporting from each other in a loop). Wrapped in
126    /// [`ReExportCycleFinding`] so each entry carries a typed `actions`
127    /// array natively (a `refactor-re-export-cycle` informational primary
128    /// plus a `suppress-file` secondary; cycles are file-scoped so a single
129    /// suppression breaks the cycle).
130    #[serde(default)]
131    pub re_export_cycles: Vec<ReExportCycleFinding>,
132    /// Imports that cross architecture boundary rules. Wrapped in
133    /// [`BoundaryViolationFinding`] so each entry carries a typed `actions`
134    /// array natively.
135    #[serde(default)]
136    pub boundary_violations: Vec<BoundaryViolationFinding>,
137    /// Suppression comments or JSDoc tags that no longer match any issue.
138    #[serde(default)]
139    pub stale_suppressions: Vec<StaleSuppression>,
140    /// Entries in pnpm-workspace.yaml's catalog: or catalogs: sections not
141    /// referenced by any workspace package via the catalog: protocol. Wrapped
142    /// in [`UnusedCatalogEntryFinding`] so each entry carries a typed
143    /// `actions` array natively, with per-instance `auto_fixable` derived
144    /// from `hardcoded_consumers`.
145    #[serde(default)]
146    pub unused_catalog_entries: Vec<UnusedCatalogEntryFinding>,
147    /// Named groups under pnpm-workspace.yaml's catalogs: section that declare
148    /// no package entries. The top-level catalog: map is not reported. Wrapped
149    /// in [`EmptyCatalogGroupFinding`].
150    #[serde(default)]
151    pub empty_catalog_groups: Vec<EmptyCatalogGroupFinding>,
152    /// Workspace package.json references to catalogs (`catalog:` or
153    /// `catalog:<name>`) that do not declare the consumed package. pnpm install
154    /// will error until the named catalog grows to include the package or the
155    /// reference is switched / removed. Wrapped in
156    /// [`UnresolvedCatalogReferenceFinding`] with the discriminated
157    /// `add-catalog-entry` / `update-catalog-reference` primary at position 0.
158    #[serde(default)]
159    pub unresolved_catalog_references: Vec<UnresolvedCatalogReferenceFinding>,
160    /// Entries in pnpm-workspace.yaml's overrides: section, or package.json's
161    /// pnpm.overrides block, whose target package is not declared by any
162    /// workspace package and is not present in pnpm-lock.yaml. Default severity
163    /// is warn because projects without a readable lockfile fall back to
164    /// manifest-only checks; the hint field flags those conservative cases.
165    /// Wrapped in [`UnusedDependencyOverrideFinding`].
166    #[serde(default)]
167    pub unused_dependency_overrides: Vec<UnusedDependencyOverrideFinding>,
168    /// pnpm.overrides entries whose key or value does not parse as a valid
169    /// override spec (empty key, empty value, malformed selector, unbalanced
170    /// parent matcher). pnpm install will reject these. Default severity is
171    /// error. Wrapped in [`MisconfiguredDependencyOverrideFinding`].
172    #[serde(default)]
173    pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverrideFinding>,
174    /// Number of suppression entries that matched an issue during analysis.
175    /// Human output uses this for the suppression footer; it is skipped in
176    /// machine output to avoid changing the public JSON issue contract.
177    #[serde(skip)]
178    pub suppression_count: usize,
179    /// Suppression comments present in analyzed files this run (every present
180    /// marker, all kinds, not only consumed ones). Internal: read in-process by
181    /// `fallow impact` to distinguish a genuinely resolved finding from one
182    /// silenced by a `fallow-ignore`. Skipped during serialization, like
183    /// [`Self::suppression_count`], so the public JSON output contract is
184    /// unchanged.
185    #[serde(skip)]
186    pub active_suppressions: Vec<ActiveSuppression>,
187    /// Detected feature flag patterns. Advisory output, not included in issue counts.
188    /// Skipped during default serialization: injected separately in JSON output when enabled.
189    #[serde(skip)]
190    pub feature_flags: Vec<FeatureFlag>,
191    /// Local security candidates (e.g. `client-server-leak`). CANDIDATES for
192    /// downstream agent verification, NOT verified vulnerabilities. Off by
193    /// default; populated only when the corresponding `security_*` rule is
194    /// enabled (forced on by `fallow security`). Excluded from `total_issues`
195    /// and skipped during serialization so they never surface under bare
196    /// `fallow` or the `audit` gate; the `fallow security` command reads this
197    /// field and emits its own envelope. Mirrors [`Self::feature_flags`].
198    #[serde(skip)]
199    pub security_findings: Vec<SecurityFinding>,
200    /// In-band blind-spot count: number of `"use client"` files whose transitive
201    /// import cone contains a dynamic `import()` the reachability BFS cannot
202    /// follow. Surfaced by `fallow security` so a leak hidden behind an
203    /// unresolved edge is never silently reported as "clean". Skipped during
204    /// serialization like [`Self::security_findings`].
205    #[serde(skip)]
206    pub security_unresolved_edge_files: usize,
207    /// In-band blind-spot count: number of sink-shaped nodes the catalogue
208    /// detector could not flatten to a static callee path (dynamic dispatch,
209    /// computed members, aliased bindings). Surfaced by `fallow security` so an
210    /// empty catalogue result with a non-zero count is not reported as "clean".
211    /// Skipped during serialization like [`Self::security_findings`].
212    #[serde(skip)]
213    pub security_unresolved_callee_sites: usize,
214    /// Usage counts for all exports across the project. Used by the LSP for Code Lens.
215    /// Not included in issue counts -- this is metadata, not an issue type.
216    /// Skipped during serialization: this is internal LSP data, not part of the JSON output schema.
217    #[serde(skip)]
218    pub export_usages: Vec<ExportUsage>,
219    /// Summary of detected entry points, grouped by discovery source.
220    /// Not included in issue counts -- this is informational metadata.
221    /// Skipped during serialization: rendered separately in JSON output.
222    #[serde(skip)]
223    pub entry_point_summary: Option<EntryPointSummary>,
224}
225
226impl AnalysisResults {
227    /// Total number of issues found.
228    ///
229    /// Sums across all issue categories (unused files, exports, types,
230    /// dependencies, members, unresolved imports, unlisted deps, duplicates,
231    /// type-only deps, circular deps, and boundary violations).
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use fallow_types::output_dead_code::{UnresolvedImportFinding, UnusedFileFinding};
237    /// use fallow_types::results::{AnalysisResults, UnresolvedImport, UnusedFile};
238    /// use std::path::PathBuf;
239    ///
240    /// let mut results = AnalysisResults::default();
241    /// results
242    ///     .unused_files
243    ///     .push(UnusedFileFinding::with_actions(UnusedFile {
244    ///         path: PathBuf::from("a.ts"),
245    ///     }));
246    /// results
247    ///     .unresolved_imports
248    ///     .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
249    ///         path: PathBuf::from("b.ts"),
250    ///         specifier: "./missing".to_string(),
251    ///         line: 1,
252    ///         col: 0,
253    ///         specifier_col: 0,
254    ///     }));
255    /// assert_eq!(results.total_issues(), 2);
256    /// ```
257    #[must_use]
258    pub const fn total_issues(&self) -> usize {
259        self.unused_files.len()
260            + self.unused_exports.len()
261            + self.unused_types.len()
262            + self.private_type_leaks.len()
263            + self.unused_dependencies.len()
264            + self.unused_dev_dependencies.len()
265            + self.unused_optional_dependencies.len()
266            + self.unused_enum_members.len()
267            + self.unused_class_members.len()
268            + self.unresolved_imports.len()
269            + self.unlisted_dependencies.len()
270            + self.duplicate_exports.len()
271            + self.type_only_dependencies.len()
272            + self.test_only_dependencies.len()
273            + self.circular_dependencies.len()
274            + self.re_export_cycles.len()
275            + self.boundary_violations.len()
276            + self.stale_suppressions.len()
277            + self.unused_catalog_entries.len()
278            + self.empty_catalog_groups.len()
279            + self.unresolved_catalog_references.len()
280            + self.unused_dependency_overrides.len()
281            + self.misconfigured_dependency_overrides.len()
282    }
283
284    /// Whether any issues were found.
285    #[must_use]
286    pub const fn has_issues(&self) -> bool {
287        self.total_issues() > 0
288    }
289
290    /// Merge `other` into `self`, taking the union of every field.
291    ///
292    /// This is the single canonical way to combine two [`AnalysisResults`]
293    /// (the LSP merges per-project-root results through it). The method
294    /// exhaustively destructures `Self`, so adding a field to the struct
295    /// becomes a compile error here instead of a silently-dropped field. See
296    /// issue #444.
297    ///
298    /// Every `Vec` field is appended (callers dedup downstream where needed,
299    /// e.g. the LSP's identity-keyed `dedup_results`). `suppression_count`
300    /// sums; `entry_point_summary` keeps `self`'s value when present and
301    /// otherwise adopts `other`'s.
302    pub fn merge_into(&mut self, other: Self) {
303        let Self {
304            unused_files,
305            unused_exports,
306            unused_types,
307            private_type_leaks,
308            unused_dependencies,
309            unused_dev_dependencies,
310            unused_optional_dependencies,
311            unused_enum_members,
312            unused_class_members,
313            unresolved_imports,
314            unlisted_dependencies,
315            duplicate_exports,
316            type_only_dependencies,
317            test_only_dependencies,
318            circular_dependencies,
319            re_export_cycles,
320            boundary_violations,
321            stale_suppressions,
322            unused_catalog_entries,
323            empty_catalog_groups,
324            unresolved_catalog_references,
325            unused_dependency_overrides,
326            misconfigured_dependency_overrides,
327            suppression_count,
328            active_suppressions,
329            feature_flags,
330            security_findings,
331            security_unresolved_edge_files,
332            security_unresolved_callee_sites,
333            export_usages,
334            entry_point_summary,
335        } = other;
336
337        self.unused_files.extend(unused_files);
338        self.unused_exports.extend(unused_exports);
339        self.unused_types.extend(unused_types);
340        self.private_type_leaks.extend(private_type_leaks);
341        self.unused_dependencies.extend(unused_dependencies);
342        self.unused_dev_dependencies.extend(unused_dev_dependencies);
343        self.unused_optional_dependencies
344            .extend(unused_optional_dependencies);
345        self.unused_enum_members.extend(unused_enum_members);
346        self.unused_class_members.extend(unused_class_members);
347        self.unresolved_imports.extend(unresolved_imports);
348        self.unlisted_dependencies.extend(unlisted_dependencies);
349        self.duplicate_exports.extend(duplicate_exports);
350        self.type_only_dependencies.extend(type_only_dependencies);
351        self.test_only_dependencies.extend(test_only_dependencies);
352        self.circular_dependencies.extend(circular_dependencies);
353        self.re_export_cycles.extend(re_export_cycles);
354        self.boundary_violations.extend(boundary_violations);
355        self.stale_suppressions.extend(stale_suppressions);
356        self.unused_catalog_entries.extend(unused_catalog_entries);
357        self.empty_catalog_groups.extend(empty_catalog_groups);
358        self.unresolved_catalog_references
359            .extend(unresolved_catalog_references);
360        self.unused_dependency_overrides
361            .extend(unused_dependency_overrides);
362        self.misconfigured_dependency_overrides
363            .extend(misconfigured_dependency_overrides);
364        self.feature_flags.extend(feature_flags);
365        self.security_findings.extend(security_findings);
366        self.security_unresolved_edge_files += security_unresolved_edge_files;
367        self.security_unresolved_callee_sites += security_unresolved_callee_sites;
368        self.export_usages.extend(export_usages);
369        self.active_suppressions.extend(active_suppressions);
370        self.suppression_count += suppression_count;
371        if self.entry_point_summary.is_none() {
372            self.entry_point_summary = entry_point_summary;
373        }
374    }
375
376    /// Sort all result arrays for deterministic output ordering.
377    ///
378    /// Parallel collection (rayon, `FxHashMap` iteration) does not guarantee
379    /// insertion order, so the same project can produce different orderings
380    /// across runs. This method canonicalises every result list by sorting on
381    /// (path, line, col, name) so that JSON/SARIF/human output is stable.
382    #[expect(
383        clippy::too_many_lines,
384        reason = "one short sort_by per result array; splitting would add indirection without clarity"
385    )]
386    pub fn sort(&mut self) {
387        self.unused_files
388            .sort_by(|a, b| a.file.path.cmp(&b.file.path));
389
390        self.unused_exports.sort_by(|a, b| {
391            a.export
392                .path
393                .cmp(&b.export.path)
394                .then(a.export.line.cmp(&b.export.line))
395                .then(a.export.export_name.cmp(&b.export.export_name))
396        });
397
398        self.unused_types.sort_by(|a, b| {
399            a.export
400                .path
401                .cmp(&b.export.path)
402                .then(a.export.line.cmp(&b.export.line))
403                .then(a.export.export_name.cmp(&b.export.export_name))
404        });
405
406        self.private_type_leaks.sort_by(|a, b| {
407            a.leak
408                .path
409                .cmp(&b.leak.path)
410                .then(a.leak.line.cmp(&b.leak.line))
411                .then(a.leak.export_name.cmp(&b.leak.export_name))
412                .then(a.leak.type_name.cmp(&b.leak.type_name))
413        });
414
415        self.unused_dependencies.sort_by(|a, b| {
416            a.dep
417                .path
418                .cmp(&b.dep.path)
419                .then(a.dep.line.cmp(&b.dep.line))
420                .then(a.dep.package_name.cmp(&b.dep.package_name))
421        });
422
423        self.unused_dev_dependencies.sort_by(|a, b| {
424            a.dep
425                .path
426                .cmp(&b.dep.path)
427                .then(a.dep.line.cmp(&b.dep.line))
428                .then(a.dep.package_name.cmp(&b.dep.package_name))
429        });
430
431        self.unused_optional_dependencies.sort_by(|a, b| {
432            a.dep
433                .path
434                .cmp(&b.dep.path)
435                .then(a.dep.line.cmp(&b.dep.line))
436                .then(a.dep.package_name.cmp(&b.dep.package_name))
437        });
438
439        self.unused_enum_members.sort_by(|a, b| {
440            a.member
441                .path
442                .cmp(&b.member.path)
443                .then(a.member.line.cmp(&b.member.line))
444                .then(a.member.parent_name.cmp(&b.member.parent_name))
445                .then(a.member.member_name.cmp(&b.member.member_name))
446        });
447
448        self.unused_class_members.sort_by(|a, b| {
449            a.member
450                .path
451                .cmp(&b.member.path)
452                .then(a.member.line.cmp(&b.member.line))
453                .then(a.member.parent_name.cmp(&b.member.parent_name))
454                .then(a.member.member_name.cmp(&b.member.member_name))
455        });
456
457        self.unresolved_imports.sort_by(|a, b| {
458            a.import
459                .path
460                .cmp(&b.import.path)
461                .then(a.import.line.cmp(&b.import.line))
462                .then(a.import.col.cmp(&b.import.col))
463                .then(a.import.specifier.cmp(&b.import.specifier))
464        });
465
466        self.unlisted_dependencies
467            .sort_by(|a, b| a.dep.package_name.cmp(&b.dep.package_name));
468        for dep in &mut self.unlisted_dependencies {
469            dep.dep
470                .imported_from
471                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
472        }
473
474        self.duplicate_exports
475            .sort_by(|a, b| a.export.export_name.cmp(&b.export.export_name));
476        for dup in &mut self.duplicate_exports {
477            dup.export
478                .locations
479                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
480        }
481
482        self.type_only_dependencies.sort_by(|a, b| {
483            a.dep
484                .path
485                .cmp(&b.dep.path)
486                .then(a.dep.line.cmp(&b.dep.line))
487                .then(a.dep.package_name.cmp(&b.dep.package_name))
488        });
489
490        self.test_only_dependencies.sort_by(|a, b| {
491            a.dep
492                .path
493                .cmp(&b.dep.path)
494                .then(a.dep.line.cmp(&b.dep.line))
495                .then(a.dep.package_name.cmp(&b.dep.package_name))
496        });
497
498        self.circular_dependencies.sort_by(|a, b| {
499            a.cycle
500                .files
501                .cmp(&b.cycle.files)
502                .then(a.cycle.length.cmp(&b.cycle.length))
503        });
504
505        self.re_export_cycles
506            .sort_by(|a, b| a.cycle.files.cmp(&b.cycle.files));
507
508        self.boundary_violations.sort_by(|a, b| {
509            a.violation
510                .from_path
511                .cmp(&b.violation.from_path)
512                .then(a.violation.line.cmp(&b.violation.line))
513                .then(a.violation.col.cmp(&b.violation.col))
514                .then(a.violation.to_path.cmp(&b.violation.to_path))
515        });
516
517        self.stale_suppressions.sort_by(|a, b| {
518            a.path
519                .cmp(&b.path)
520                .then(a.line.cmp(&b.line))
521                .then(a.col.cmp(&b.col))
522        });
523
524        self.unused_catalog_entries.sort_by(|a, b| {
525            a.entry
526                .path
527                .cmp(&b.entry.path)
528                .then_with(|| {
529                    catalog_sort_key(&a.entry.catalog_name)
530                        .cmp(&catalog_sort_key(&b.entry.catalog_name))
531                })
532                .then(a.entry.catalog_name.cmp(&b.entry.catalog_name))
533                .then(a.entry.entry_name.cmp(&b.entry.entry_name))
534        });
535        for finding in &mut self.unused_catalog_entries {
536            finding.entry.hardcoded_consumers.sort();
537            finding.entry.hardcoded_consumers.dedup();
538        }
539
540        self.empty_catalog_groups.sort_by(|a, b| {
541            a.group
542                .path
543                .cmp(&b.group.path)
544                .then_with(|| {
545                    catalog_sort_key(&a.group.catalog_name)
546                        .cmp(&catalog_sort_key(&b.group.catalog_name))
547                })
548                .then(a.group.catalog_name.cmp(&b.group.catalog_name))
549                .then(a.group.line.cmp(&b.group.line))
550        });
551
552        self.unresolved_catalog_references.sort_by(|a, b| {
553            a.reference
554                .path
555                .cmp(&b.reference.path)
556                .then(a.reference.line.cmp(&b.reference.line))
557                .then_with(|| {
558                    catalog_sort_key(&a.reference.catalog_name)
559                        .cmp(&catalog_sort_key(&b.reference.catalog_name))
560                })
561                .then(a.reference.catalog_name.cmp(&b.reference.catalog_name))
562                .then(a.reference.entry_name.cmp(&b.reference.entry_name))
563        });
564        for finding in &mut self.unresolved_catalog_references {
565            finding.reference.available_in_catalogs.sort();
566            finding.reference.available_in_catalogs.dedup();
567        }
568
569        self.unused_dependency_overrides.sort_by(|a, b| {
570            a.entry
571                .path
572                .cmp(&b.entry.path)
573                .then(a.entry.line.cmp(&b.entry.line))
574                .then(a.entry.raw_key.cmp(&b.entry.raw_key))
575        });
576
577        self.misconfigured_dependency_overrides.sort_by(|a, b| {
578            a.entry
579                .path
580                .cmp(&b.entry.path)
581                .then(a.entry.line.cmp(&b.entry.line))
582                .then(a.entry.raw_key.cmp(&b.entry.raw_key))
583        });
584
585        self.feature_flags.sort_by(|a, b| {
586            a.path
587                .cmp(&b.path)
588                .then(a.line.cmp(&b.line))
589                .then(a.flag_name.cmp(&b.flag_name))
590        });
591
592        for usage in &mut self.export_usages {
593            usage.reference_locations.sort_by(|a, b| {
594                a.path
595                    .cmp(&b.path)
596                    .then(a.line.cmp(&b.line))
597                    .then(a.col.cmp(&b.col))
598            });
599        }
600        self.export_usages.sort_by(|a, b| {
601            a.path
602                .cmp(&b.path)
603                .then(a.line.cmp(&b.line))
604                .then(a.export_name.cmp(&b.export_name))
605        });
606    }
607}
608
609/// Sort key for catalog names: the default catalog ("default") sorts before any named catalog.
610fn catalog_sort_key(name: &str) -> (u8, &str) {
611    if name == "default" {
612        (0, name)
613    } else {
614        (1, name)
615    }
616}
617
618/// A file that is not reachable from any entry point.
619#[derive(Debug, Clone, Serialize)]
620#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
621pub struct UnusedFile {
622    /// Absolute path to the unused file.
623    #[serde(serialize_with = "serde_path::serialize")]
624    pub path: PathBuf,
625}
626
627/// An export that is never imported by other modules.
628#[derive(Debug, Clone, Serialize)]
629#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
630pub struct UnusedExport {
631    /// File containing the unused export.
632    #[serde(serialize_with = "serde_path::serialize")]
633    pub path: PathBuf,
634    /// Name of the unused export.
635    pub export_name: String,
636    /// Whether this is a type-only export.
637    pub is_type_only: bool,
638    /// 1-based line number of the export.
639    pub line: u32,
640    /// 0-based byte column offset.
641    pub col: u32,
642    /// Byte offset into the source file (used by the fix command).
643    pub span_start: u32,
644    /// Whether this finding comes from a barrel/index re-export rather than the source definition.
645    pub is_re_export: bool,
646}
647
648/// A public export signature that references a same-file private type.
649#[derive(Debug, Clone, Serialize)]
650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
651pub struct PrivateTypeLeak {
652    /// File containing the exported symbol.
653    #[serde(serialize_with = "serde_path::serialize")]
654    pub path: PathBuf,
655    /// Export whose public signature leaks the private type.
656    pub export_name: String,
657    /// Private type referenced by the public signature.
658    pub type_name: String,
659    /// 1-based line number of the leaking type reference.
660    pub line: u32,
661    /// 0-based byte column offset.
662    pub col: u32,
663    /// Byte offset of the type reference.
664    pub span_start: u32,
665}
666
667/// A dependency that is listed in package.json but never imported.
668#[derive(Debug, Clone, Serialize)]
669#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
670pub struct UnusedDependency {
671    /// Package name, including internal workspace package names.
672    pub package_name: String,
673    /// Whether this is in `dependencies`, `devDependencies`, or `optionalDependencies`.
674    pub location: DependencyLocation,
675    /// Path to the package.json where this dependency is listed.
676    /// For root deps this is `<root>/package.json`, for workspace deps it is `<ws>/package.json`.
677    #[serde(serialize_with = "serde_path::serialize")]
678    pub path: PathBuf,
679    /// 1-based line number of the dependency entry in package.json.
680    pub line: u32,
681    /// Workspace roots that import this package even though the declaring workspace does not.
682    #[serde(
683        serialize_with = "serde_path::serialize_vec",
684        skip_serializing_if = "Vec::is_empty"
685    )]
686    #[cfg_attr(feature = "schema", schemars(default))]
687    pub used_in_workspaces: Vec<PathBuf>,
688}
689
690/// Where in package.json a dependency is listed.
691///
692/// # Examples
693///
694/// ```
695/// use fallow_types::results::DependencyLocation;
696///
697/// // All three variants are constructible
698/// let loc = DependencyLocation::Dependencies;
699/// let dev = DependencyLocation::DevDependencies;
700/// let opt = DependencyLocation::OptionalDependencies;
701/// // Debug output includes the variant name
702/// assert!(format!("{loc:?}").contains("Dependencies"));
703/// assert!(format!("{dev:?}").contains("DevDependencies"));
704/// assert!(format!("{opt:?}").contains("OptionalDependencies"));
705/// ```
706#[derive(Debug, Clone, Serialize)]
707#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
708#[serde(rename_all = "camelCase")]
709pub enum DependencyLocation {
710    /// Listed in `dependencies`.
711    Dependencies,
712    /// Listed in `devDependencies`.
713    DevDependencies,
714    /// Listed in `optionalDependencies`.
715    OptionalDependencies,
716}
717
718/// An unused enum or class member.
719#[derive(Debug, Clone, Serialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721pub struct UnusedMember {
722    /// File containing the unused member.
723    #[serde(serialize_with = "serde_path::serialize")]
724    pub path: PathBuf,
725    /// Name of the parent enum or class.
726    pub parent_name: String,
727    /// Name of the unused member.
728    pub member_name: String,
729    /// Whether this is an enum member, class method, or class property.
730    pub kind: MemberKind,
731    /// 1-based line number.
732    pub line: u32,
733    /// 0-based byte column offset.
734    pub col: u32,
735}
736
737/// An import that could not be resolved.
738#[derive(Debug, Clone, Serialize)]
739#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
740pub struct UnresolvedImport {
741    /// File containing the unresolved import.
742    #[serde(serialize_with = "serde_path::serialize")]
743    pub path: PathBuf,
744    /// The import specifier that could not be resolved.
745    pub specifier: String,
746    /// 1-based line number.
747    pub line: u32,
748    /// 0-based byte column offset of the import statement.
749    pub col: u32,
750    /// 0-based byte column offset of the source string literal (the specifier in quotes).
751    /// Used by the LSP to underline just the specifier, not the entire import line.
752    pub specifier_col: u32,
753}
754
755/// A dependency used in code but not listed in package.json.
756#[derive(Debug, Clone, Serialize)]
757#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
758pub struct UnlistedDependency {
759    /// Package name, including internal workspace package names, that is
760    /// imported but not listed in package.json.
761    pub package_name: String,
762    /// Import sites where this unlisted dependency is used (file path, line, column).
763    pub imported_from: Vec<ImportSite>,
764}
765
766/// A location where an import occurs.
767#[derive(Debug, Clone, Serialize)]
768#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
769pub struct ImportSite {
770    /// File containing the import.
771    #[serde(serialize_with = "serde_path::serialize")]
772    pub path: PathBuf,
773    /// 1-based line number.
774    pub line: u32,
775    /// 0-based byte column offset.
776    pub col: u32,
777}
778
779/// An export that appears multiple times across the project.
780#[derive(Debug, Clone, Serialize)]
781#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
782pub struct DuplicateExport {
783    /// The duplicated export name.
784    pub export_name: String,
785    /// Locations where this export name appears.
786    pub locations: Vec<DuplicateLocation>,
787}
788
789/// A location where a duplicate export appears.
790#[derive(Debug, Clone, Serialize)]
791#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
792pub struct DuplicateLocation {
793    /// File containing the duplicate export.
794    #[serde(serialize_with = "serde_path::serialize")]
795    pub path: PathBuf,
796    /// 1-based line number.
797    pub line: u32,
798    /// 0-based byte column offset.
799    pub col: u32,
800}
801
802/// A production dependency that is only used via type-only imports.
803/// In production builds, type imports are erased, so this dependency
804/// is not needed at runtime and could be moved to devDependencies.
805#[derive(Debug, Clone, Serialize)]
806#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
807pub struct TypeOnlyDependency {
808    /// Production dependency that is only used via type-only imports.
809    pub package_name: String,
810    /// Path to the package.json where the dependency is listed.
811    #[serde(serialize_with = "serde_path::serialize")]
812    pub path: PathBuf,
813    /// 1-based line number of the dependency entry in package.json.
814    pub line: u32,
815}
816
817/// The kind of security candidate. Findings are CANDIDATES for downstream agent
818/// verification, NOT verified vulnerabilities.
819#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
820#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
821#[serde(rename_all = "kebab-case")]
822pub enum SecurityFindingKind {
823    /// A `"use client"` file transitively imports a module that reads a
824    /// non-public `process.env` secret (graph-structural; bespoke, not catalogue).
825    ClientServerLeak,
826    /// A syntactic sink site matched against the data-driven catalogue
827    /// (`security_matchers.toml`). Serializes `"tainted-sink"`; the CWE class is
828    /// carried in `category` + `cwe`. ONE variant covers all catalogue categories.
829    TaintedSink,
830}
831
832/// The role a hop plays in a security finding's structural import trace.
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
834#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
835#[serde(rename_all = "kebab-case")]
836pub enum TraceHopRole {
837    /// The `"use client"` boundary file the finding is anchored on.
838    ClientBoundary,
839    /// A module that reads an untrusted input source such as request data,
840    /// where the candidate's sink argument actually traces back to that read in
841    /// the same statement (arg-level, the strong intra-module association).
842    UntrustedSource,
843    /// A module that merely CONTAINS an untrusted-input source somewhere and is
844    /// import-reachable to the sink module (module-level, issue #885). This is a
845    /// reachability signal, NOT a proven value path: the specific source value
846    /// is not shown to reach the sink argument. Labeled distinctly from
847    /// `UntrustedSource` so a consumer never reads a module-level hop as a
848    /// value-flow proof.
849    ModuleSource,
850    /// An intermediate module on the transitive import path.
851    Intermediate,
852    /// The module that reads the secret.
853    SecretSource,
854    /// The syntactic sink site of a catalogue-driven `tainted-sink` candidate
855    /// (the single hop the `tainted_sink` detector emits). Distinct from
856    /// `SecretSource`, which is specific to the `client-server-leak` rule.
857    Sink,
858}
859
860/// One hop in a security finding's structural trace. Stored as an absolute path
861/// internally; JSON serialization strips the project root via
862/// `serde_path::serialize`.
863#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
864#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
865pub struct TraceHop {
866    /// File on this hop of the import chain.
867    #[serde(serialize_with = "serde_path::serialize")]
868    pub path: PathBuf,
869    /// 1-based line number. Import-chain hops point at the import site; the
870    /// terminal secret-source hop points at the source module when extraction
871    /// does not carry a more precise member-access span.
872    pub line: u32,
873    /// 0-based byte column offset.
874    pub col: u32,
875    /// Role of this hop in the chain.
876    pub role: TraceHopRole,
877}
878
879/// How strongly the untrusted-source signal is associated with the sink, a
880/// structured discriminator so a consumer can tier candidates without parsing
881/// the human `evidence` prose. Present only when
882/// [`SecurityReachability::reachable_from_untrusted_source`] is true. Neither
883/// value proves exploitability; both are ranking signals (issue #885 doctrine:
884/// rank, never gate).
885#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
886#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
887#[serde(rename_all = "kebab-case")]
888pub enum TaintConfidence {
889    /// The sink's argument traces back to a known untrusted-source read in the
890    /// SAME statement / module (the intra-module back-trace, issue #859). The
891    /// strong, high-value candidate: a specific source expression is implicated.
892    ArgLevel,
893    /// The sink merely lives in a module that is import-reachable from a module
894    /// containing an untrusted source (issue #885). The weak candidate: only the
895    /// module is implicated, not a specific value path to the sink argument.
896    ModuleLevel,
897}
898
899/// Graph-derived reachability ranking signal for a security candidate. Computed
900/// from the existing module graph after detection, never proven exploitable.
901/// Used to surface candidates that sit on a request/runtime-reachable surface,
902/// receive same-module source evidence, or are import-reachable from an
903/// untrusted-source module above isolated helpers or scripts.
904///
905/// This is a relative-ordering signal, NOT a `confidence` or `signal_strength`
906/// score: fallow does not prove the path is exploitable.
907#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
908#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
909pub struct SecurityReachability {
910    /// Whether the anchor module is reachable from a runtime/application entry
911    /// point (route handlers, server entry, framework runtime roots), the
912    /// closest graph proxy for an external/request input surface. Code reachable
913    /// only from test entry points does not count.
914    pub reachable_from_entry: bool,
915    /// Whether the anchor module is reachable over value imports from a module
916    /// that reads a known untrusted input source. Module-level only: this does
917    /// not prove a specific source value reaches the sink argument.
918    #[serde(default)]
919    pub reachable_from_untrusted_source: bool,
920    /// Structured tier of the untrusted-source association: `arg-level` when the
921    /// sink argument traces to a same-module source read (strong), `module-level`
922    /// when only the module is import-reachable from a source (weak). Present
923    /// exactly when `reachable_from_untrusted_source` is true, so a consumer can
924    /// separate strong from weak candidates from this field alone without parsing
925    /// the `evidence` string. Not an exploitability proof.
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub taint_confidence: Option<TaintConfidence>,
928    /// Number of value-import hops from the untrusted-source module to the sink
929    /// module when `reachable_from_untrusted_source` is true.
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub untrusted_source_hop_count: Option<u32>,
932    /// Module-level import path from the untrusted-source module to the sink
933    /// anchor. Empty when no source module reaches this candidate. The path is a
934    /// ranking explanation, not a value-flow proof.
935    #[serde(default, skip_serializing_if = "Vec::is_empty")]
936    pub untrusted_source_trace: Vec<TraceHop>,
937    /// Number of distinct modules that transitively depend on the anchor module
938    /// (fan-in via the graph's reverse-dependency index). A higher value means a
939    /// wider surface: more call sites could route untrusted input into the sink.
940    pub blast_radius: u32,
941    /// Whether the anchor module participates in an architecture-boundary
942    /// violation found in the same run (as the importing or imported file).
943    /// Optional pairing: a candidate that also crosses a declared boundary is a
944    /// stronger review target.
945    pub crosses_boundary: bool,
946}
947
948/// Dead-code cross-link attached to a security candidate when fallow's dead-code
949/// pass reports the same anchor as removable code.
950#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
951#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
952pub struct SecurityDeadCodeContext {
953    /// Dead-code issue kind that matched the security candidate.
954    pub kind: SecurityDeadCodeKind,
955    /// Unused export name when `kind` is `unused-export`.
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub export_name: Option<String>,
958    /// Dead-code finding line when available.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub line: Option<u32>,
961    /// Agent-facing guidance for deciding between deletion and hardening.
962    pub guidance: String,
963}
964
965/// Dead-code issue kind linked to a security candidate.
966#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
967#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
968#[serde(rename_all = "kebab-case")]
969pub enum SecurityDeadCodeKind {
970    /// The candidate's anchor file is also reported as an unused file.
971    UnusedFile,
972    /// The candidate's anchor sits on an unused export declaration.
973    UnusedExport,
974}
975
976/// The sink slot of a [`SecurityCandidate`]: a self-contained description of the
977/// matched sink site. Echoes the finding's own span (`path`/`line`/`col`) plus
978/// the catalogue `category`/`cwe` and the captured `callee`, so an agent can act
979/// on `candidate.sink` in isolation (e.g. after fanning a finding out to a
980/// sub-agent) without reading the parent finding.
981#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
983pub struct SecurityCandidateSink {
984    /// File of the sink site. Absolute internally; JSON strips the project root
985    /// via `serde_path::serialize`.
986    #[serde(serialize_with = "serde_path::serialize")]
987    pub path: PathBuf,
988    /// 1-based line of the sink site.
989    pub line: u32,
990    /// 0-based byte column of the sink site.
991    pub col: u32,
992    /// Catalogue category id of the sink (e.g. `"dangerous-html"`). `None` for
993    /// `client-server-leak`.
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub category: Option<String>,
996    /// CWE number declared by the catalogue entry. `None` for
997    /// `client-server-leak`; never fabricated beyond the catalogue's value.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub cwe: Option<u32>,
1000    /// The sink callee (the dangerous function or member path, e.g.
1001    /// `"el.innerHTML"`, `"child_process.exec"`) captured by the catalogue match.
1002    /// `None` for `client-server-leak` and matches that name no callee.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub callee: Option<String>,
1005}
1006
1007/// A declared architecture-zone crossing, recovered by correlating a finding's
1008/// anchor against the run's architecture-boundary violations.
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1010#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1011pub struct SecurityZoneCrossing {
1012    /// Zone the importing side belongs to.
1013    pub from: String,
1014    /// Zone the imported side belongs to.
1015    pub to: String,
1016}
1017
1018/// The boundary slot of a [`SecurityCandidate`]: which structural boundaries the
1019/// candidate's flow crosses. A flow that crosses a client/server or module
1020/// boundary is a stronger review target than a self-contained one; the boundary
1021/// is fallow's structural signal over a pure source-sink match.
1022///
1023/// Two further boundary kinds are RESERVED for a follow-up and are deliberately
1024/// absent here rather than emitted as always-false: `export_visibility` (is the
1025/// sink on a publicly-exported symbol?) and a package boundary (does the flow
1026/// cross an npm-package edge?). Both need new graph derivation that does not
1027/// exist today; emitting them as `false` would misreport "we checked and it does
1028/// not cross" when fallow has not checked at all.
1029#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1030#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1031pub struct SecurityCandidateBoundary {
1032    /// Whether the finding crosses a client/server boundary (a `"use client"`
1033    /// file appears in the trace). True only for `client-server-leak` today;
1034    /// `tainted-sink` candidates carry no client/server marker.
1035    pub client_server: bool,
1036    /// Whether an untrusted source reaches the sink across one or more
1037    /// value-import (module) hops. Derived from the reachability hop count.
1038    pub cross_module: bool,
1039    /// The architecture-zone crossing when the anchor participates in a declared
1040    /// boundary-rule violation in the same run. `None` when it crosses no
1041    /// declared zone boundary.
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub architecture_zone: Option<SecurityZoneCrossing>,
1044}
1045
1046/// Network-destination context for a `secret-to-network` candidate (#890): where
1047/// the secret-bearing network call sends its data. Present only on
1048/// network-category candidates. A consuming agent uses it to triage exfil
1049/// (dynamic / untrusted destination) from intended auth (a literal provider
1050/// host) without re-reading source.
1051#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1052#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1053pub struct SecurityNetworkContext {
1054    /// The network call's destination as a static URL string literal, or absent
1055    /// when the destination is DYNAMIC (not a literal). A dynamic destination is
1056    /// the higher-signal exfil case; a literal provider host is usually intended
1057    /// auth.
1058    #[serde(default, skip_serializing_if = "Option::is_none")]
1059    pub destination: Option<String>,
1060}
1061
1062/// An agent-actionable candidate record on a [`SecurityFinding`]. fallow fills
1063/// `source_kind`, `sink`, and `boundary`. The exploitability IMPACT is
1064/// deliberately NOT a field: `severity` on the parent finding is only a
1065/// review-priority tier, while deciding exploitability remains the consuming
1066/// agent's job. A perpetually-null `impact` key would only train consumers to
1067/// ignore it. The agent reads this record, then writes its own impact verdict
1068/// downstream.
1069#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1071pub struct SecurityCandidate {
1072    /// The kind of untrusted input that reaches the sink, as a stable catalogue
1073    /// source id (`"http-request-input"`, `"process-env"`, `"process-argv"`,
1074    /// `"message-event-data"`, `"location-input"`, ...). `None`/absent when no
1075    /// untrusted source was matched (always `None` for `client-server-leak`).
1076    /// This is an OPEN string set, driven by the data-driven source catalogue; a
1077    /// consumer should treat an unknown id as "untrusted source of unknown kind"
1078    /// and never drop the candidate on that basis.
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub source_kind: Option<String>,
1081    /// The sink the candidate fires on, self-contained so the record is
1082    /// actionable without reading the parent finding.
1083    pub sink: SecurityCandidateSink,
1084    /// The structural boundary the flow crosses.
1085    pub boundary: SecurityCandidateBoundary,
1086    /// Network-destination context, present only on `secret-to-network` (#890)
1087    /// candidates: the host the secret-bearing call targets, so an agent can
1088    /// triage exfil from intended auth. Absent for every other category.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub network: Option<SecurityNetworkContext>,
1091}
1092
1093/// One endpoint (source or sink node) of a [`SecurityTaintFlow`].
1094#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1095#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1096pub struct TaintEndpoint {
1097    /// File of the endpoint. Absolute internally; JSON strips the project root.
1098    #[serde(serialize_with = "serde_path::serialize")]
1099    pub path: PathBuf,
1100    /// 1-based line of the endpoint.
1101    pub line: u32,
1102    /// 0-based byte column of the endpoint.
1103    pub col: u32,
1104}
1105
1106/// Compact taint-flow path shape. The ordered per-hop trace is NOT duplicated
1107/// here: it lives on [`SecurityReachability::untrusted_source_trace`]. This
1108/// carries only the flow's structural summary (intra-module flow plus the
1109/// cross-module hop count) so consumers do not parse two copies of the hops.
1110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1112pub struct TaintPath {
1113    /// Whether the source and sink sit in the same module (no import hop between
1114    /// them); the source-to-sink association is intra-module.
1115    pub intra_module: bool,
1116    /// Number of value-import hops from the untrusted-source module to the sink
1117    /// module. Zero for an intra-module flow.
1118    pub cross_module_hops: u32,
1119}
1120
1121/// A source-to-sink taint-flow triple, emitted only when an untrusted source is
1122/// import-reachable to the sink (`reachability.reachable_from_untrusted_source`).
1123/// The `{ source, sink, path }` shape matches the model agent SAST tooling
1124/// expects (cf. Semgrep `taint_source` / `taint_sink`, SARIF `threadFlows`).
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1127pub struct SecurityTaintFlow {
1128    /// The untrusted-source endpoint (first hop of the reachability trace).
1129    pub source: TaintEndpoint,
1130    /// The sink endpoint (terminal hop of the reachability trace / the anchor).
1131    pub sink: TaintEndpoint,
1132    /// Compact flow shape: same-module flag plus module hop count. The full
1133    /// ordered path is `reachability.untrusted_source_trace`.
1134    pub path: TaintPath,
1135}
1136
1137/// Runtime coverage state for the function enclosing a security sink.
1138/// This is production-observation evidence, not an exploitability verdict.
1139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1141#[serde(rename_all = "kebab-case")]
1142pub enum SecurityRuntimeState {
1143    /// The sink sits inside a runtime hot path.
1144    RuntimeHot,
1145    /// The sink sits inside a tracked function with zero production invocations.
1146    RuntimeCold,
1147    /// The sink sits inside a tracked function the runtime layer marked as safe
1148    /// to delete because it was never executed.
1149    NeverExecuted,
1150    /// The sink sits inside a function that executed, but below the low-traffic
1151    /// threshold.
1152    LowTraffic,
1153    /// Runtime coverage could not classify the enclosing function.
1154    CoverageUnavailable,
1155    /// A static enclosing function was found, but the runtime report carried no
1156    /// matching evidence for it.
1157    RuntimeUnknown,
1158}
1159
1160/// Runtime coverage context attached to a security candidate when
1161/// `fallow security --runtime-coverage` is supplied.
1162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1164pub struct SecurityRuntimeContext {
1165    /// Runtime state for the enclosing function.
1166    pub state: SecurityRuntimeState,
1167    /// Enclosing function name from static extraction.
1168    pub function: String,
1169    /// 1-based line where the enclosing function starts.
1170    pub line: u32,
1171    /// Observed invocation count when the runtime report provides it.
1172    #[serde(default, skip_serializing_if = "Option::is_none")]
1173    pub invocations: Option<u64>,
1174    /// Runtime coverage stable function id, when available.
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub stable_id: Option<String>,
1177    /// Short candidate-framed explanation of the runtime evidence.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub evidence: Option<String>,
1180}
1181
1182/// Verification-priority tier for a security candidate. This is ranking, not an
1183/// exploitability verdict.
1184#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1186#[serde(rename_all = "lowercase")]
1187pub enum SecuritySeverity {
1188    /// Highest-priority candidate based on reachability, boundary, or runtime-hot signals.
1189    High,
1190    /// Candidate has source-reachability evidence but no high-priority signal.
1191    Medium,
1192    /// Candidate has no source-reachability or boundary signal.
1193    Low,
1194}
1195
1196/// Defensive control found on an attack-surface path.
1197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1198#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1199pub struct SecurityDefensiveControl {
1200    /// Control family.
1201    pub kind: SecurityControlKind,
1202    /// File of the control site. Absolute internally; JSON strips the project root.
1203    #[serde(serialize_with = "serde_path::serialize")]
1204    pub path: PathBuf,
1205    /// 1-based line of the control site.
1206    pub line: u32,
1207    /// 0-based byte column of the control site.
1208    pub col: u32,
1209    /// Flattened callee path or a stable synthetic guard name.
1210    pub callee: String,
1211}
1212
1213/// Agent-facing defensive-boundary verification context for one surface path.
1214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1216pub struct SecurityDefensiveBoundary {
1217    /// Known controls detected along this path.
1218    pub controls: Vec<SecurityDefensiveControl>,
1219    /// Verification question for the consuming agent. It is a prompt, not a
1220    /// missing-guard verdict.
1221    pub verification_prompt: String,
1222}
1223
1224/// One untrusted entry to reachable sink path for `fallow security --surface`.
1225#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1226#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1227pub struct SecurityAttackSurfaceEntry {
1228    /// The untrusted-source endpoint.
1229    pub source: TaintEndpoint,
1230    /// The reachable sink endpoint and catalogue metadata.
1231    pub sink: SecurityCandidateSink,
1232    /// Ordered source to sink path. Same shape as the reachability trace so
1233    /// consumers can reuse existing path handling.
1234    pub path: Vec<TraceHop>,
1235    /// Defensive-boundary context detected on this path.
1236    pub defensive_boundary: SecurityDefensiveBoundary,
1237}
1238
1239/// A local security CANDIDATE for downstream agent verification, NOT a verified
1240/// vulnerability. Emitted only by `fallow security`, never under bare `fallow`
1241/// or the `audit` gate. There is deliberately no `confidence` or
1242/// `signal_strength` field: fallow does not prove exploitability, so the trace
1243/// (its hops and length) is the only honest signal.
1244#[derive(Debug, Clone, Serialize)]
1245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1246pub struct SecurityFinding {
1247    /// Stable per-finding correlation id, identical across runs for the same
1248    /// rule + anchor path + line. An autonomous agent that triaged this
1249    /// candidate on a prior run uses it to correlate the candidate after a
1250    /// rebase. Equal to the SARIF `partialFingerprints` value for the same
1251    /// finding (one shared helper computes both).
1252    pub finding_id: String,
1253    /// The rule that produced this candidate.
1254    pub kind: SecurityFindingKind,
1255    /// The catalogue category id (e.g. `"dangerous-html"`). `None` for
1256    /// `ClientServerLeak`; `Some` for `TaintedSink`.
1257    #[serde(default, skip_serializing_if = "Option::is_none")]
1258    pub category: Option<String>,
1259    /// The CWE number declared by the matched catalogue entry. `None` for
1260    /// `ClientServerLeak`; never fabricated beyond the catalogue's value.
1261    #[serde(default, skip_serializing_if = "Option::is_none")]
1262    pub cwe: Option<u32>,
1263    /// File the finding is anchored on (the client boundary). Absolute
1264    /// internally; JSON strips the project root via `serde_path::serialize`.
1265    #[serde(serialize_with = "serde_path::serialize")]
1266    pub path: PathBuf,
1267    /// 1-based line number of the anchor.
1268    pub line: u32,
1269    /// 0-based byte column offset of the anchor.
1270    pub col: u32,
1271    /// Agent/human-readable evidence (e.g. the named env var the chain reaches).
1272    pub evidence: String,
1273    /// Whether the sink argument was associated with a known untrusted source by
1274    /// the intra-module source-to-sink back-trace (issue #859): a local binding
1275    /// referenced in the argument was sourced from a catalogue source path
1276    /// (`req.query`, `process.argv`, message-event `data`, etc.). `true` ranks
1277    /// the candidate higher and annotates the evidence; `false` does NOT
1278    /// suppress the finding (the association is conservative, never a proof, and
1279    /// fallow prefers false-negatives over false-positives). Always `false` for
1280    /// `ClientServerLeak`. Skipped from JSON when `false` for output stability.
1281    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1282    pub source_backed: bool,
1283    /// Internal cross-pass carrier (NEVER serialized): the (1-based line, 0-based
1284    /// col) of the arg-level source read, resolved by the detector when
1285    /// `source_backed` is true and a concrete read span was captured. The ranking
1286    /// pass uses it to anchor the taint trace's source node at the real read
1287    /// instead of the module import line. `None` for module-level findings and
1288    /// for arg-level findings with no concrete read span (synthetic
1289    /// framework-param / helper-return sources), where the trace falls back to
1290    /// the sink site.
1291    #[serde(skip)]
1292    pub source_read: Option<(u32, u32)>,
1293    /// Verification-priority tier derived from existing reachability, boundary,
1294    /// source-backed, and runtime signals. Candidate-only: this does not prove
1295    /// exploitability and does not change gates.
1296    pub severity: SecuritySeverity,
1297    /// Structural import-hop trace from the client boundary to the secret source.
1298    /// The hop count is the uncalibrated signal; fallow does not prove the path
1299    /// is exploitable.
1300    pub trace: Vec<TraceHop>,
1301    /// Machine-actionable next steps. Always emitted (possibly empty for
1302    /// forward-compat). For security candidates this is a single file-level
1303    /// suppress hint (`auto_fixable: false`); there is no auto-fix because
1304    /// verification is the agent's job, not fallow's.
1305    pub actions: Vec<IssueAction>,
1306    /// Dead-code cross-link when the same sink candidate sits in code fallow also
1307    /// reports as removable. Agents should verify the dead-code finding and delete
1308    /// the code instead of hardening the sink when deletion is safe.
1309    #[serde(default, skip_serializing_if = "Option::is_none")]
1310    pub dead_code: Option<SecurityDeadCodeContext>,
1311    /// Graph-derived reachability ranking signal (issues #860 and #885). `None`
1312    /// until the post-detection ranking pass fills it; additive on the wire
1313    /// (skipped when absent). Drives the order findings are emitted in:
1314    /// runtime-reachable candidates sort first, followed by source-backed and
1315    /// source-reachable candidates, then wider blast radius.
1316    #[serde(default, skip_serializing_if = "Option::is_none")]
1317    pub reachability: Option<SecurityReachability>,
1318    /// Agent-actionable candidate record: the untrusted input kind, the sink,
1319    /// and the boundary the flow crosses. fallow fills these three slots; the
1320    /// exploitability verdict is the agent's job and is not a field here. Always
1321    /// present.
1322    pub candidate: SecurityCandidate,
1323    /// Source-to-sink taint-flow triple, present only when an untrusted source
1324    /// is import-reachable to this sink. Absent (skipped) otherwise.
1325    #[serde(default, skip_serializing_if = "Option::is_none")]
1326    pub taint_flow: Option<SecurityTaintFlow>,
1327    /// Production runtime coverage context for the function enclosing this
1328    /// security sink. Present only when `fallow security --runtime-coverage`
1329    /// runs and the candidate is a `tainted-sink`.
1330    #[serde(default, skip_serializing_if = "Option::is_none")]
1331    pub runtime: Option<SecurityRuntimeContext>,
1332    /// Internal projection used by `fallow security --surface`. The CLI strips
1333    /// this from per-finding JSON and promotes it to the top-level
1334    /// `attack_surface` field only when requested.
1335    #[serde(default, skip_serializing_if = "Option::is_none")]
1336    pub attack_surface: Option<SecurityAttackSurfaceEntry>,
1337}
1338
1339/// A pnpm catalog entry declared in pnpm-workspace.yaml that no workspace package
1340/// references via the `catalog:` protocol.
1341///
1342/// The default catalog (top-level `catalog:` key) uses `catalog_name: "default"`.
1343/// Named catalogs (under `catalogs.<name>:`) use their declared name.
1344#[derive(Debug, Clone, Serialize)]
1345#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1346pub struct UnusedCatalogEntry {
1347    /// Package name declared in the catalog (e.g. `"react"`, `"@scope/lib"`).
1348    pub entry_name: String,
1349    /// Catalog group: `"default"` for the top-level `catalog:` map, or the
1350    /// named catalog key for entries declared under `catalogs.<name>:`.
1351    pub catalog_name: String,
1352    /// Path to `pnpm-workspace.yaml`, relative to the analyzed root.
1353    #[serde(serialize_with = "serde_path::serialize")]
1354    pub path: PathBuf,
1355    /// 1-based line number of the catalog entry within `pnpm-workspace.yaml`.
1356    pub line: u32,
1357    /// Workspace `package.json` files that declare the same package with a
1358    /// hardcoded version range instead of `catalog:`. Empty when no consumer
1359    /// uses a hardcoded version. Sorted lexicographically for deterministic
1360    /// output.
1361    #[serde(
1362        default,
1363        serialize_with = "serde_path::serialize_vec",
1364        skip_serializing_if = "Vec::is_empty"
1365    )]
1366    pub hardcoded_consumers: Vec<PathBuf>,
1367}
1368
1369/// A named `catalogs.<name>:` group in `pnpm-workspace.yaml` with no package entries.
1370#[derive(Debug, Clone, Serialize)]
1371#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1372pub struct EmptyCatalogGroup {
1373    /// Catalog group name declared under the top-level `catalogs:` map.
1374    pub catalog_name: String,
1375    /// Path to `pnpm-workspace.yaml`, relative to the analyzed root.
1376    #[serde(serialize_with = "serde_path::serialize")]
1377    pub path: PathBuf,
1378    /// 1-based line number of the empty group header within `pnpm-workspace.yaml`.
1379    pub line: u32,
1380}
1381
1382/// A workspace package.json reference (`catalog:` or `catalog:<name>`) that points
1383/// at a catalog which does not declare the consumed package.
1384///
1385/// `pnpm install` errors at install time with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_CATALOG_PROTOCOL`
1386/// when this happens. fallow surfaces it statically so the failure is caught at
1387/// `fallow dead-code` time, before any install.
1388///
1389/// The default catalog (bare `catalog:` references the top-level `catalog:` map)
1390/// uses `catalog_name: "default"`. Named catalogs (`catalog:react17`) use the
1391/// declared catalog name.
1392#[derive(Debug, Clone, Serialize)]
1393#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1394pub struct UnresolvedCatalogReference {
1395    /// Package name being referenced via the catalog protocol (e.g. `"react"`).
1396    pub entry_name: String,
1397    /// Catalog group the reference points at: `"default"` for bare `catalog:` references,
1398    /// or the named catalog key for `catalog:<name>` references.
1399    pub catalog_name: String,
1400    /// Absolute path to the consumer `package.json`. Matches the storage
1401    /// convention used by every path-anchored finding type (`UnusedFile`,
1402    /// `UnresolvedImport`, `UnusedExport`, etc.) so the shared filtering
1403    /// pipelines (`filter_results_by_changed_files`, per-file overrides,
1404    /// audit attribution) work without a separate root-join pass. JSON
1405    /// output strips the project-root prefix via `serde_path::serialize`.
1406    #[serde(serialize_with = "serde_path::serialize")]
1407    pub path: PathBuf,
1408    /// 1-based line number of the dependency entry in the consumer `package.json`.
1409    pub line: u32,
1410    /// Other catalogs (in the same `pnpm-workspace.yaml`) that DO declare this
1411    /// package. Empty when no catalog has the package. Sorted lexicographically.
1412    /// Lets agents and humans decide whether to switch the reference to a
1413    /// different catalog or to add the entry to the named catalog.
1414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1415    pub available_in_catalogs: Vec<String>,
1416}
1417
1418/// Where an override entry was declared. Serialized as the filename label
1419/// (`"pnpm-workspace.yaml"` or `"package.json"`) so the value in JSON output
1420/// matches the value users write in `ignoreDependencyOverrides[].source`.
1421#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1422#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1423pub enum DependencyOverrideSource {
1424    /// Top-level `overrides:` key in `pnpm-workspace.yaml`.
1425    #[serde(rename = "pnpm-workspace.yaml")]
1426    PnpmWorkspaceYaml,
1427    /// `pnpm.overrides` in a root `package.json`.
1428    #[serde(rename = "package.json")]
1429    PnpmPackageJson,
1430}
1431
1432impl DependencyOverrideSource {
1433    /// Stable string label matching the serde rename. Used in baseline keys,
1434    /// audit keys, jq comparisons, and `ignoreDependencyOverrides[].source`.
1435    #[must_use]
1436    pub const fn as_label(&self) -> &'static str {
1437        match self {
1438            Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
1439            Self::PnpmPackageJson => "package.json",
1440        }
1441    }
1442}
1443
1444impl std::fmt::Display for DependencyOverrideSource {
1445    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1446        f.write_str(self.as_label())
1447    }
1448}
1449
1450/// An entry in pnpm's `overrides:` map (or the legacy `pnpm.overrides` in
1451/// `package.json`) whose target package is not declared in any workspace
1452/// `package.json` and is not present in `pnpm-lock.yaml`. Projects without a
1453/// readable lockfile fall back to package manifest checks; the `hint` field
1454/// flags that conservative mode.
1455#[derive(Debug, Clone, Serialize)]
1456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1457pub struct UnusedDependencyOverride {
1458    /// The full original override key as written in the source (e.g.
1459    /// `"react>react-dom"`, `"@types/react@<18"`). Preserved for round-trip
1460    /// reporting so agents see the unmodified spelling.
1461    pub raw_key: String,
1462    /// The target package the override rewrites (e.g. `"react-dom"` for
1463    /// `"react>react-dom"`, `"@types/react"` for `"@types/react@<18"`).
1464    pub target_package: String,
1465    /// Optional parent package (left side of `>`). `None` for bare-target keys.
1466    #[serde(default, skip_serializing_if = "Option::is_none")]
1467    pub parent_package: Option<String>,
1468    /// Optional version selector on the target (e.g. `Some("<18")` for
1469    /// `"@types/react@<18"`).
1470    #[serde(default, skip_serializing_if = "Option::is_none")]
1471    pub version_constraint: Option<String>,
1472    /// The right-hand side of the entry: the version pnpm should force.
1473    pub version_range: String,
1474    /// File the override was declared in. Matches the value users write in
1475    /// `ignoreDependencyOverrides[].source`.
1476    pub source: DependencyOverrideSource,
1477    /// Path to the source file. `pnpm-workspace.yaml` or a `package.json`,
1478    /// stored as an absolute filesystem path so `--changed-since` and
1479    /// per-file `overrides.rules` can compare directly against the analyzer's
1480    /// changed-set / per-path rule lookups. JSON serialization strips the
1481    /// project root via `serde_path::serialize`, matching the
1482    /// `UnresolvedCatalogReference` convention.
1483    #[serde(serialize_with = "serde_path::serialize")]
1484    pub path: PathBuf,
1485    /// 1-based line number of the entry within the source file.
1486    pub line: u32,
1487    /// Soft hint reminding consumers to verify the override before removal.
1488    /// Emitted on every unused-override finding (both bare-target and
1489    /// parent-chain shapes) because projects without a readable lockfile still
1490    /// use the conservative package-manifest fallback.
1491    #[serde(default, skip_serializing_if = "Option::is_none")]
1492    pub hint: Option<String>,
1493}
1494
1495/// Why a dependency-override entry is misconfigured. `pnpm install` would
1496/// either fail at install time or silently no-op on these entries; surfacing
1497/// them statically catches the issue before pnpm does.
1498#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1500#[serde(rename_all = "kebab-case")]
1501pub enum DependencyOverrideMisconfigReason {
1502    /// The override key could not be parsed into a recognised pnpm shape
1503    /// (e.g. dangling `>`, missing target, garbage characters).
1504    UnparsableKey,
1505    /// The override value is missing, empty, or contains line breaks.
1506    EmptyValue,
1507}
1508
1509impl DependencyOverrideMisconfigReason {
1510    /// Human-readable summary of the reason.
1511    #[must_use]
1512    pub const fn describe(self) -> &'static str {
1513        match self {
1514            Self::UnparsableKey => "override key cannot be parsed",
1515            Self::EmptyValue => "override value is missing or empty",
1516        }
1517    }
1518}
1519
1520/// An override entry whose key or value is malformed. Default severity is
1521/// `error` because pnpm refuses to install (or silently produces a no-op
1522/// override) when it encounters these shapes.
1523#[derive(Debug, Clone, Serialize)]
1524#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1525pub struct MisconfiguredDependencyOverride {
1526    /// The full original override key as written in the source.
1527    pub raw_key: String,
1528    /// Parsed target package name when the key was syntactically valid (the
1529    /// `EmptyValue` reason path). `None` for `UnparsableKey` findings whose
1530    /// key could not be parsed at all. Used by JSON `add-to-config` actions to
1531    /// emit a paste-ready `ignoreDependencyOverrides` value that matches the
1532    /// suppression matcher (which also keys on `target_package`); avoids the
1533    /// pitfall where `raw_key` like `"react@<18"` would not match the rule
1534    /// that targets package `"react"`.
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub target_package: Option<String>,
1537    /// The right-hand side of the entry, exactly as written. Empty when the
1538    /// value was missing.
1539    pub raw_value: String,
1540    /// Classifier for the misconfiguration. 'unparsable-key' = the key is not a
1541    /// valid pnpm shape; 'empty-value' = the value is missing, empty, or
1542    /// contains line breaks.
1543    pub reason: DependencyOverrideMisconfigReason,
1544    /// Where the override entry was declared.
1545    pub source: DependencyOverrideSource,
1546    /// Path to the source file. Stored as an absolute filesystem path so
1547    /// `--changed-since` and per-file `overrides.rules` can compare directly.
1548    /// JSON serialization strips the project root via `serde_path::serialize`.
1549    #[serde(serialize_with = "serde_path::serialize")]
1550    pub path: PathBuf,
1551    /// 1-based line number of the entry within the source file.
1552    pub line: u32,
1553}
1554
1555/// A production dependency that is only imported by test files.
1556/// Since it is never used in production code, it could be moved to devDependencies.
1557#[derive(Debug, Clone, Serialize)]
1558#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1559pub struct TestOnlyDependency {
1560    /// Production dependency that is only imported by test files — consider
1561    /// moving to devDependencies.
1562    pub package_name: String,
1563    /// Path to the package.json where the dependency is listed.
1564    #[serde(serialize_with = "serde_path::serialize")]
1565    pub path: PathBuf,
1566    /// 1-based line number of the dependency entry in package.json.
1567    pub line: u32,
1568}
1569
1570/// One import hop in a circular dependency: the file containing the import
1571/// and where that import statement sits.
1572///
1573/// `edges[i]` is the import IN `path` (the hop SOURCE, equal to the cycle's
1574/// `files[i]`) that points to the NEXT file in the cycle
1575/// (`files[(i + 1) % files.len()]`); the target is not repeated here to keep
1576/// the wire compact. Enables a per-file diagnostic squiggly anchored under
1577/// the offending import rather than a single squiggly on the first file.
1578///
1579/// `col` is a 0-based BYTE column, matching the cycle's top-level `col`;
1580/// converting it to a UTF-16 code-unit column for LSP clients is a tracked
1581/// follow-up shared with the existing field.
1582#[derive(Debug, Clone, Serialize, Deserialize)]
1583#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1584pub struct CircularDependencyEdge {
1585    /// The file containing the import (the hop SOURCE; equal to `files[i]`).
1586    #[serde(serialize_with = "serde_path::serialize")]
1587    pub path: PathBuf,
1588    /// 1-based line number of the import statement pointing to the next file.
1589    pub line: u32,
1590    /// 0-based byte column offset of the import statement.
1591    pub col: u32,
1592}
1593
1594/// A circular dependency chain detected in the module graph.
1595///
1596/// The `line` and `col` fields carry `#[serde(default)]` so callers reading
1597/// historical baseline JSON without these fields can still deserialize the
1598/// struct, but the JSON output layer always emits them (u32 always
1599/// serializes, never via `skip_serializing_if`). The schemars derive sees
1600/// the serde defaults and marks both fields optional in the generated
1601/// schema; the explicit `extend("required" = ...)` override here keeps the
1602/// schema's `required` array honest about what the JSON output actually
1603/// contains.
1604///
1605/// `edges` is deliberately kept OUT of the `required` extend: it is
1606/// `#[serde(default)]` (so historical baseline JSON without it still
1607/// deserializes) and the output layer always emits it, but listing it in
1608/// `required` would make pre-upgrade JSON fail validation against the new
1609/// schema. It is a normal additive field: always present in current output,
1610/// optional for backward compatibility.
1611#[derive(Debug, Clone, Serialize, Deserialize)]
1612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1613#[cfg_attr(feature = "schema", schemars(extend("required" = ["files", "length", "line", "col"])))]
1614pub struct CircularDependency {
1615    /// Files forming the cycle, in import order.
1616    #[serde(serialize_with = "serde_path::serialize_vec")]
1617    pub files: Vec<PathBuf>,
1618    /// Number of files in the cycle.
1619    pub length: usize,
1620    /// 1-based line number of the import that starts the cycle (in the first file).
1621    #[serde(default)]
1622    pub line: u32,
1623    /// 0-based byte column offset of the import that starts the cycle.
1624    #[serde(default)]
1625    pub col: u32,
1626    /// Per-file import anchors, one entry per hop in cycle order: `edges[i]`
1627    /// is the import in `files[i]` pointing to `files[(i + 1) % len]`. Always
1628    /// the same length as `files`. Drives the per-file LSP diagnostic
1629    /// squiggly. `#[serde(default)]` so pre-`edges` baselines deserialize;
1630    /// always emitted on output but intentionally not in the schema's
1631    /// `required` set (see the struct doc).
1632    #[serde(default)]
1633    pub edges: Vec<CircularDependencyEdge>,
1634    /// Whether this cycle crosses workspace package boundaries.
1635    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1636    pub is_cross_package: bool,
1637}
1638
1639/// A cycle or self-loop in the re-export edge subgraph.
1640///
1641/// Detected by Tarjan SCC over `(barrel, source)` re-export edges in
1642/// `crates/graph/src/graph/re_exports/`. A multi-node cycle is a strongly
1643/// connected component of size >= 2; a self-loop is a barrel that re-exports
1644/// from itself (often a rename leftover or accidental `export * from './'`).
1645/// Both are structural bugs because chain propagation through the loop is a
1646/// no-op: any symbol consumers think they are re-exporting through the cycle
1647/// silently fails to resolve.
1648#[derive(Debug, Clone, Serialize, Deserialize)]
1649#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1650pub struct ReExportCycle {
1651    /// Files participating in the cycle, sorted lexicographically. For a
1652    /// self-loop, exactly one entry.
1653    #[serde(serialize_with = "serde_path::serialize_vec")]
1654    pub files: Vec<PathBuf>,
1655    /// Which structural shape this finding describes.
1656    pub kind: ReExportCycleKind,
1657}
1658
1659/// Discriminator for [`ReExportCycle`]: which structural shape was detected.
1660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1662#[serde(rename_all = "kebab-case")]
1663pub enum ReExportCycleKind {
1664    /// Two or more barrel files re-export from each other in a loop
1665    /// (SCC of size >= 2).
1666    MultiNode,
1667    /// A single barrel file re-exports from itself.
1668    SelfLoop,
1669}
1670
1671/// An import that crosses an architecture boundary rule.
1672#[derive(Debug, Clone, Serialize)]
1673#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1674pub struct BoundaryViolation {
1675    /// The file making the disallowed import.
1676    #[serde(serialize_with = "serde_path::serialize")]
1677    pub from_path: PathBuf,
1678    /// The file being imported that violates the boundary.
1679    #[serde(serialize_with = "serde_path::serialize")]
1680    pub to_path: PathBuf,
1681    /// The zone the importing file belongs to.
1682    pub from_zone: String,
1683    /// The zone the imported file belongs to.
1684    pub to_zone: String,
1685    /// The raw import specifier from the source file.
1686    pub import_specifier: String,
1687    /// 1-based line number of the import statement in the source file.
1688    pub line: u32,
1689    /// 0-based byte column offset of the import statement.
1690    pub col: u32,
1691}
1692
1693/// The origin of a stale suppression: inline comment or JSDoc tag.
1694#[derive(Debug, Clone, Serialize)]
1695#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1696#[serde(rename_all = "snake_case", tag = "type")]
1697pub enum SuppressionOrigin {
1698    /// A `// fallow-ignore-next-line` or `// fallow-ignore-file` comment.
1699    Comment {
1700        /// The issue kind token from the comment (e.g., "unused-exports"), or None for blanket.
1701        #[serde(default, skip_serializing_if = "Option::is_none")]
1702        issue_kind: Option<String>,
1703        /// Whether this was a file-level suppression.
1704        is_file_level: bool,
1705        /// Whether `issue_kind` parses to a known `IssueKind`. False when the
1706        /// token is a typo or refers to a kind that was renamed or removed in
1707        /// a newer fallow release. JSON consumers (CI annotations, MCP agents,
1708        /// VS Code) branch on this to choose the right next-step text.
1709        /// Omitted from the wire when `true` so producers that have not yet
1710        /// adopted the field stay byte-compatible. See issue #449.
1711        #[serde(default = "default_true", skip_serializing_if = "is_true")]
1712        kind_known: bool,
1713    },
1714    /// An `@expected-unused` JSDoc tag on an export.
1715    JsdocTag {
1716        /// The name of the export that was tagged.
1717        export_name: String,
1718    },
1719}
1720
1721#[expect(
1722    clippy::trivially_copy_pass_by_ref,
1723    reason = "serde skip_serializing_if takes a reference by contract"
1724)]
1725const fn is_true(b: &bool) -> bool {
1726    *b
1727}
1728
1729/// Default for `SuppressionOrigin::Comment.kind_known` when the field is
1730/// absent from a deserialized payload, paired with `skip_serializing_if = is_true`
1731/// so schemars marks the field non-required in the generated JSON Schema AND
1732/// the absent case round-trips to the recognized-kind interpretation.
1733/// Referenced by the always-emitted `#[serde(default = "default_true")]`
1734/// attribute. Today `SuppressionOrigin` derives only `Serialize`, so serde
1735/// itself never calls this; schemars (under the `schema` feature) reads the
1736/// attribute textually to mark `kind_known` non-required. The `cfg_attr`
1737/// applies `#[expect(dead_code)]` only on builds WITHOUT the `schema` feature
1738/// (where the function is genuinely dead): under the feature schemars
1739/// references it, the lint does not fire, and an unconditional `#[expect]`
1740/// would be unfulfilled. The function stays un-gated so a future
1741/// `Deserialize` derive on `SuppressionOrigin` does not produce a missing-
1742/// function compile error on non-`schema` builds.
1743#[cfg_attr(
1744    not(feature = "schema"),
1745    expect(
1746        dead_code,
1747        reason = "referenced via #[serde(default = ...)]; only consumed by schemars under the `schema` feature, dead on default builds today"
1748    )
1749)]
1750const fn default_true() -> bool {
1751    true
1752}
1753
1754/// A suppression comment or JSDoc tag that no longer matches any issue.
1755#[derive(Debug, Clone, Serialize)]
1756#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1757pub struct StaleSuppression {
1758    /// File containing the stale suppression.
1759    #[serde(serialize_with = "serde_path::serialize")]
1760    pub path: PathBuf,
1761    /// 1-based line number of the suppression comment or tag.
1762    pub line: u32,
1763    /// 0-based byte column offset.
1764    pub col: u32,
1765    /// The origin and details of the stale suppression.
1766    pub origin: SuppressionOrigin,
1767}
1768
1769impl StaleSuppression {
1770    /// Produce a human-readable description of this stale suppression.
1771    #[must_use]
1772    pub fn description(&self) -> String {
1773        match &self.origin {
1774            SuppressionOrigin::Comment {
1775                issue_kind,
1776                is_file_level,
1777                ..
1778            } => {
1779                let directive = if *is_file_level {
1780                    "fallow-ignore-file"
1781                } else {
1782                    "fallow-ignore-next-line"
1783                };
1784                match issue_kind {
1785                    Some(kind) => format!("// {directive} {kind}"),
1786                    None => format!("// {directive}"),
1787                }
1788            }
1789            SuppressionOrigin::JsdocTag { export_name } => {
1790                format!("@expected-unused on {export_name}")
1791            }
1792        }
1793    }
1794
1795    /// Produce an explanation of why this suppression is stale.
1796    ///
1797    /// For comment suppressions where `kind_known == false`, surfaces the
1798    /// unknown token plus a Levenshtein "did you mean?" hint when one is
1799    /// within edit distance 2. Other tokens on the same comment line still
1800    /// apply normally (see issue #449).
1801    #[must_use]
1802    pub fn explanation(&self) -> String {
1803        match &self.origin {
1804            SuppressionOrigin::Comment {
1805                issue_kind,
1806                is_file_level,
1807                kind_known,
1808            } => {
1809                let scope = if *is_file_level {
1810                    "in this file"
1811                } else {
1812                    "on the next line"
1813                };
1814                match issue_kind {
1815                    Some(kind) if !*kind_known => match closest_known_kind_name(kind) {
1816                        Some(suggestion) => format!(
1817                            "'{kind}' is not a recognized fallow issue kind. Did you mean '{suggestion}'? Other tokens on this line still apply."
1818                        ),
1819                        None => format!(
1820                            "'{kind}' is not a recognized fallow issue kind. Other tokens on this line still apply."
1821                        ),
1822                    },
1823                    Some(kind) => format!("no {kind} issue found {scope}"),
1824                    None => format!("no issues found {scope}"),
1825                }
1826            }
1827            SuppressionOrigin::JsdocTag { export_name } => {
1828                format!("{export_name} is now used")
1829            }
1830        }
1831    }
1832
1833    /// The suppressed `IssueKind`, if this was a comment suppression with a specific known kind.
1834    ///
1835    /// Returns `None` for unknown-kind comments (`kind_known == false`) and
1836    /// for JSDoc tags.
1837    #[must_use]
1838    pub fn suppressed_kind(&self) -> Option<IssueKind> {
1839        match &self.origin {
1840            SuppressionOrigin::Comment {
1841                issue_kind,
1842                kind_known: true,
1843                ..
1844            } => issue_kind.as_deref().and_then(IssueKind::parse),
1845            SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => None,
1846        }
1847    }
1848
1849    /// Per-format display message combining `description()` and `explanation()`
1850    /// for the unknown-kind case so SARIF, CodeClimate, and compact consumers
1851    /// surface the typo-fix copy and Levenshtein hint without needing to
1852    /// branch on `origin.kind_known` themselves. Stale-but-known and JSDoc
1853    /// origins keep the bare `description()` so existing wire bytes stay
1854    /// unchanged. See issue #449.
1855    #[must_use]
1856    pub fn display_message(&self) -> String {
1857        match &self.origin {
1858            SuppressionOrigin::Comment {
1859                kind_known: false, ..
1860            } => format!("{} ({})", self.description(), self.explanation()),
1861            SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => {
1862                self.description()
1863            }
1864        }
1865    }
1866}
1867
1868/// A suppression comment present in an analyzed file this run.
1869///
1870/// This is the "active-suppression state" the Fallow Impact value report needs
1871/// to tell a genuinely resolved finding (the code was fixed) from one merely
1872/// silenced by a newly-added `fallow-ignore`. It captures every PRESENT marker,
1873/// not only the ones a detector consumed: complexity and code-duplication
1874/// suppressions are consumed in the CLI layer rather than the core suppression
1875/// context, so presence is the single uniform signal that covers all impact
1876/// categories. A present-but-stale marker is harmless because impact keys on a
1877/// suppression that newly appeared between two recorded runs. It is internal:
1878/// never serialized into the public JSON output schema (the field on
1879/// [`AnalysisResults`] is `#[serde(skip)]`), only read in-process by
1880/// `fallow impact`.
1881#[derive(Debug, Clone)]
1882pub struct ActiveSuppression {
1883    /// Absolute path to the file carrying the suppression comment.
1884    pub path: PathBuf,
1885    /// The suppressed issue kind in kebab-case (e.g. `"unused-export"`), or
1886    /// `None` for a blanket marker that suppresses every kind on its target.
1887    pub kind: Option<String>,
1888    /// Whether this is a `fallow-ignore-file` (file-level) marker rather than a
1889    /// `fallow-ignore-next-line` marker.
1890    pub is_file_level: bool,
1891}
1892
1893/// The detection method used to identify a feature flag.
1894#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1895#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1896#[serde(rename_all = "snake_case")]
1897pub enum FlagKind {
1898    /// Environment variable check (e.g., `process.env.FEATURE_X`).
1899    EnvironmentVariable,
1900    /// Feature flag SDK call (e.g., `useFlag('name')`, `variation('name', false)`).
1901    SdkCall,
1902    /// Config object property access (e.g., `config.features.newCheckout`).
1903    ConfigObject,
1904}
1905
1906/// Detection confidence for a feature flag finding.
1907#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1908#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1909#[serde(rename_all = "snake_case")]
1910pub enum FlagConfidence {
1911    /// Low confidence: heuristic match (config object patterns).
1912    Low,
1913    /// Medium confidence: pattern match with some ambiguity.
1914    Medium,
1915    /// High confidence: unambiguous pattern (env vars, direct SDK calls).
1916    High,
1917}
1918
1919/// A detected feature flag use site.
1920#[derive(Debug, Clone, Serialize)]
1921#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1922pub struct FeatureFlag {
1923    /// File containing the feature flag usage.
1924    #[serde(serialize_with = "serde_path::serialize")]
1925    pub path: PathBuf,
1926    /// Name or identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
1927    pub flag_name: String,
1928    /// How the flag was detected.
1929    pub kind: FlagKind,
1930    /// Detection confidence level.
1931    pub confidence: FlagConfidence,
1932    /// 1-based line number.
1933    pub line: u32,
1934    /// 0-based byte column offset.
1935    pub col: u32,
1936    /// Start byte offset of the guarded code block (if-branch span), if detected.
1937    #[serde(skip)]
1938    pub guard_span_start: Option<u32>,
1939    /// End byte offset of the guarded code block (if-branch span), if detected.
1940    #[serde(skip)]
1941    pub guard_span_end: Option<u32>,
1942    /// SDK or provider name (e.g., "LaunchDarkly", "Statsig"), if detected from SDK call.
1943    #[serde(default, skip_serializing_if = "Option::is_none")]
1944    pub sdk_name: Option<String>,
1945    /// Line range of the guarded code block (derived from guard_span + line_offsets).
1946    /// Used for cross-reference with dead code findings.
1947    #[serde(skip)]
1948    pub guard_line_start: Option<u32>,
1949    /// End line of the guarded code block.
1950    #[serde(skip)]
1951    pub guard_line_end: Option<u32>,
1952    /// Unused exports found within the guarded code block.
1953    /// Populated by cross-reference with dead code analysis.
1954    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1955    pub guarded_dead_exports: Vec<String>,
1956}
1957
1958// Size assertion: FeatureFlag is stored in a Vec per analysis run.
1959const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
1960
1961/// Usage count for an export symbol. Used by the LSP Code Lens to show
1962/// reference counts above each export declaration.
1963#[derive(Debug, Clone, Serialize)]
1964#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1965pub struct ExportUsage {
1966    /// File containing the export.
1967    #[serde(serialize_with = "serde_path::serialize")]
1968    pub path: PathBuf,
1969    /// Name of the exported symbol.
1970    pub export_name: String,
1971    /// 1-based line number.
1972    pub line: u32,
1973    /// 0-based byte column offset.
1974    pub col: u32,
1975    /// Number of files that reference this export.
1976    pub reference_count: usize,
1977    /// Locations where this export is referenced. Used by the LSP Code Lens
1978    /// to enable click-to-navigate via `editor.action.showReferences`.
1979    pub reference_locations: Vec<ReferenceLocation>,
1980}
1981
1982/// A location where an export is referenced (import site in another file).
1983#[derive(Debug, Clone, Serialize)]
1984#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1985pub struct ReferenceLocation {
1986    /// File containing the import that references the export.
1987    #[serde(serialize_with = "serde_path::serialize")]
1988    pub path: PathBuf,
1989    /// 1-based line number.
1990    pub line: u32,
1991    /// 0-based byte column offset.
1992    pub col: u32,
1993}
1994
1995#[cfg(test)]
1996mod tests {
1997    use super::*;
1998    use crate::output_dead_code::{
1999        BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
2000        UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
2001        UnusedTypeFinding,
2002    };
2003
2004    #[test]
2005    fn empty_results_no_issues() {
2006        let results = AnalysisResults::default();
2007        assert_eq!(results.total_issues(), 0);
2008        assert!(!results.has_issues());
2009    }
2010
2011    #[test]
2012    fn results_with_unused_file() {
2013        let mut results = AnalysisResults::default();
2014        results
2015            .unused_files
2016            .push(UnusedFileFinding::with_actions(UnusedFile {
2017                path: PathBuf::from("test.ts"),
2018            }));
2019        assert_eq!(results.total_issues(), 1);
2020        assert!(results.has_issues());
2021    }
2022
2023    #[test]
2024    fn results_with_unused_export() {
2025        let mut results = AnalysisResults::default();
2026        results
2027            .unused_exports
2028            .push(UnusedExportFinding::with_actions(UnusedExport {
2029                path: PathBuf::from("test.ts"),
2030                export_name: "foo".to_string(),
2031                is_type_only: false,
2032                line: 1,
2033                col: 0,
2034                span_start: 0,
2035                is_re_export: false,
2036            }));
2037        assert_eq!(results.total_issues(), 1);
2038        assert!(results.has_issues());
2039    }
2040
2041    fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
2042        UnusedExport {
2043            path: PathBuf::from(path),
2044            export_name: export_name.to_string(),
2045            is_type_only,
2046            line: 1,
2047            col: 0,
2048            span_start: 0,
2049            is_re_export: false,
2050        }
2051    }
2052
2053    fn test_unused_dependency(
2054        package_name: &str,
2055        location: DependencyLocation,
2056    ) -> UnusedDependency {
2057        UnusedDependency {
2058            package_name: package_name.to_string(),
2059            location,
2060            path: PathBuf::from("package.json"),
2061            line: 5,
2062            used_in_workspaces: Vec::new(),
2063        }
2064    }
2065
2066    fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
2067        UnusedMember {
2068            path: PathBuf::from("members.ts"),
2069            parent_name: "Parent".to_string(),
2070            member_name: member_name.to_string(),
2071            kind,
2072            line: 1,
2073            col: 0,
2074        }
2075    }
2076
2077    #[test]
2078    fn results_total_counts_all_types() {
2079        let results = AnalysisResults {
2080            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
2081                path: PathBuf::from("a.ts"),
2082            })],
2083            unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
2084                "b.ts", "x", false,
2085            ))],
2086            unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
2087                "c.ts", "T", true,
2088            ))],
2089            unused_dependencies: vec![UnusedDependencyFinding::with_actions(
2090                test_unused_dependency("dep", DependencyLocation::Dependencies),
2091            )],
2092            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2093                test_unused_dependency("dev", DependencyLocation::DevDependencies),
2094            )],
2095            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
2096                "A",
2097                MemberKind::EnumMember,
2098            ))],
2099            unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
2100                "m",
2101                MemberKind::ClassMethod,
2102            ))],
2103            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
2104                path: PathBuf::from("f.ts"),
2105                specifier: "./missing".to_string(),
2106                line: 1,
2107                col: 0,
2108                specifier_col: 0,
2109            })],
2110            unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
2111                UnlistedDependency {
2112                    package_name: "unlisted".to_string(),
2113                    imported_from: vec![ImportSite {
2114                        path: PathBuf::from("g.ts"),
2115                        line: 1,
2116                        col: 0,
2117                    }],
2118                },
2119            )],
2120            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
2121                export_name: "dup".to_string(),
2122                locations: vec![
2123                    DuplicateLocation {
2124                        path: PathBuf::from("h.ts"),
2125                        line: 15,
2126                        col: 0,
2127                    },
2128                    DuplicateLocation {
2129                        path: PathBuf::from("i.ts"),
2130                        line: 30,
2131                        col: 0,
2132                    },
2133                ],
2134            })],
2135            unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
2136                test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
2137            )],
2138            type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
2139                TypeOnlyDependency {
2140                    package_name: "type-only".to_string(),
2141                    path: PathBuf::from("package.json"),
2142                    line: 8,
2143                },
2144            )],
2145            test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
2146                TestOnlyDependency {
2147                    package_name: "test-only".to_string(),
2148                    path: PathBuf::from("package.json"),
2149                    line: 9,
2150                },
2151            )],
2152            circular_dependencies: vec![CircularDependencyFinding::with_actions(
2153                CircularDependency {
2154                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2155                    length: 2,
2156                    line: 3,
2157                    col: 0,
2158                    edges: Vec::new(),
2159                    is_cross_package: false,
2160                },
2161            )],
2162            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
2163                from_path: PathBuf::from("src/ui/Button.tsx"),
2164                to_path: PathBuf::from("src/db/queries.ts"),
2165                from_zone: "ui".to_string(),
2166                to_zone: "database".to_string(),
2167                import_specifier: "../db/queries".to_string(),
2168                line: 3,
2169                col: 0,
2170            })],
2171            ..Default::default()
2172        };
2173
2174        // 15 categories, one of each
2175        assert_eq!(results.total_issues(), 15);
2176        assert!(results.has_issues());
2177    }
2178
2179    // ── total_issues / has_issues consistency ──────────────────
2180
2181    #[test]
2182    fn total_issues_and_has_issues_are_consistent() {
2183        let results = AnalysisResults::default();
2184        assert_eq!(results.total_issues(), 0);
2185        assert!(!results.has_issues());
2186        assert_eq!(results.total_issues() > 0, results.has_issues());
2187    }
2188
2189    // ── total_issues counts each category independently ─────────
2190
2191    #[test]
2192    fn total_issues_sums_all_categories_independently() {
2193        let mut results = AnalysisResults::default();
2194        results
2195            .unused_files
2196            .push(UnusedFileFinding::with_actions(UnusedFile {
2197                path: PathBuf::from("a.ts"),
2198            }));
2199        assert_eq!(results.total_issues(), 1);
2200
2201        results
2202            .unused_files
2203            .push(UnusedFileFinding::with_actions(UnusedFile {
2204                path: PathBuf::from("b.ts"),
2205            }));
2206        assert_eq!(results.total_issues(), 2);
2207
2208        results
2209            .unresolved_imports
2210            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2211                path: PathBuf::from("c.ts"),
2212                specifier: "./missing".to_string(),
2213                line: 1,
2214                col: 0,
2215                specifier_col: 0,
2216            }));
2217        assert_eq!(results.total_issues(), 3);
2218    }
2219
2220    // ── default is truly empty ──────────────────────────────────
2221
2222    #[test]
2223    fn default_results_all_fields_empty() {
2224        let r = AnalysisResults::default();
2225        assert!(r.unused_files.is_empty());
2226        assert!(r.unused_exports.is_empty());
2227        assert!(r.unused_types.is_empty());
2228        assert!(r.unused_dependencies.is_empty());
2229        assert!(r.unused_dev_dependencies.is_empty());
2230        assert!(r.unused_optional_dependencies.is_empty());
2231        assert!(r.unused_enum_members.is_empty());
2232        assert!(r.unused_class_members.is_empty());
2233        assert!(r.unresolved_imports.is_empty());
2234        assert!(r.unlisted_dependencies.is_empty());
2235        assert!(r.duplicate_exports.is_empty());
2236        assert!(r.type_only_dependencies.is_empty());
2237        assert!(r.test_only_dependencies.is_empty());
2238        assert!(r.circular_dependencies.is_empty());
2239        assert!(r.boundary_violations.is_empty());
2240        assert!(r.unused_catalog_entries.is_empty());
2241        assert!(r.unresolved_catalog_references.is_empty());
2242        assert!(r.export_usages.is_empty());
2243    }
2244
2245    // ── EntryPointSummary ────────────────────────────────────────
2246
2247    #[test]
2248    fn entry_point_summary_default() {
2249        let summary = EntryPointSummary::default();
2250        assert_eq!(summary.total, 0);
2251        assert!(summary.by_source.is_empty());
2252    }
2253
2254    #[test]
2255    fn entry_point_summary_not_in_default_results() {
2256        let r = AnalysisResults::default();
2257        assert!(r.entry_point_summary.is_none());
2258    }
2259
2260    #[test]
2261    fn entry_point_summary_some_preserves_data() {
2262        let r = AnalysisResults {
2263            entry_point_summary: Some(EntryPointSummary {
2264                total: 5,
2265                by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
2266            }),
2267            ..AnalysisResults::default()
2268        };
2269        let summary = r.entry_point_summary.as_ref().unwrap();
2270        assert_eq!(summary.total, 5);
2271        assert_eq!(summary.by_source.len(), 2);
2272        assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
2273    }
2274
2275    // ── sort: unused_files by path ──────────────────────────────
2276
2277    #[test]
2278    fn sort_unused_files_by_path() {
2279        let mut r = AnalysisResults::default();
2280        r.unused_files
2281            .push(UnusedFileFinding::with_actions(UnusedFile {
2282                path: PathBuf::from("z.ts"),
2283            }));
2284        r.unused_files
2285            .push(UnusedFileFinding::with_actions(UnusedFile {
2286                path: PathBuf::from("a.ts"),
2287            }));
2288        r.unused_files
2289            .push(UnusedFileFinding::with_actions(UnusedFile {
2290                path: PathBuf::from("m.ts"),
2291            }));
2292        r.sort();
2293        let paths: Vec<_> = r
2294            .unused_files
2295            .iter()
2296            .map(|f| f.file.path.to_string_lossy().to_string())
2297            .collect();
2298        assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
2299    }
2300
2301    // ── sort: unused_exports by path, line, name ────────────────
2302
2303    #[test]
2304    fn sort_unused_exports_by_path_line_name() {
2305        let mut r = AnalysisResults::default();
2306        let mk = |path: &str, line: u32, name: &str| {
2307            UnusedExportFinding::with_actions(UnusedExport {
2308                path: PathBuf::from(path),
2309                export_name: name.to_string(),
2310                is_type_only: false,
2311                line,
2312                col: 0,
2313                span_start: 0,
2314                is_re_export: false,
2315            })
2316        };
2317        r.unused_exports.push(mk("b.ts", 5, "beta"));
2318        r.unused_exports.push(mk("a.ts", 10, "zeta"));
2319        r.unused_exports.push(mk("a.ts", 10, "alpha"));
2320        r.unused_exports.push(mk("a.ts", 1, "gamma"));
2321        r.sort();
2322        let keys: Vec<_> = r
2323            .unused_exports
2324            .iter()
2325            .map(|e| {
2326                format!(
2327                    "{}:{}:{}",
2328                    e.export.path.to_string_lossy(),
2329                    e.export.line,
2330                    e.export.export_name
2331                )
2332            })
2333            .collect();
2334        assert_eq!(
2335            keys,
2336            vec![
2337                "a.ts:1:gamma",
2338                "a.ts:10:alpha",
2339                "a.ts:10:zeta",
2340                "b.ts:5:beta"
2341            ]
2342        );
2343    }
2344
2345    // ── sort: unused_types (same sort as unused_exports) ────────
2346
2347    #[test]
2348    fn sort_unused_types_by_path_line_name() {
2349        let mut r = AnalysisResults::default();
2350        let mk = |path: &str, line: u32, name: &str| {
2351            UnusedTypeFinding::with_actions(UnusedExport {
2352                path: PathBuf::from(path),
2353                export_name: name.to_string(),
2354                is_type_only: true,
2355                line,
2356                col: 0,
2357                span_start: 0,
2358                is_re_export: false,
2359            })
2360        };
2361        r.unused_types.push(mk("z.ts", 1, "Z"));
2362        r.unused_types.push(mk("a.ts", 1, "A"));
2363        r.sort();
2364        assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
2365        assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
2366    }
2367
2368    // ── sort: unused_dependencies by path, line, name ───────────
2369
2370    #[test]
2371    fn sort_unused_dependencies_by_path_line_name() {
2372        let mut r = AnalysisResults::default();
2373        let mk = |path: &str, line: u32, name: &str| {
2374            UnusedDependencyFinding::with_actions(UnusedDependency {
2375                package_name: name.to_string(),
2376                location: DependencyLocation::Dependencies,
2377                path: PathBuf::from(path),
2378                line,
2379                used_in_workspaces: Vec::new(),
2380            })
2381        };
2382        r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
2383        r.unused_dependencies.push(mk("a/package.json", 5, "react"));
2384        r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
2385        r.sort();
2386        let names: Vec<_> = r
2387            .unused_dependencies
2388            .iter()
2389            .map(|d| d.dep.package_name.as_str())
2390            .collect();
2391        assert_eq!(names, vec!["axios", "react", "zlib"]);
2392    }
2393
2394    // ── sort: unused_dev_dependencies ───────────────────────────
2395
2396    #[test]
2397    fn sort_unused_dev_dependencies() {
2398        let mut r = AnalysisResults::default();
2399        r.unused_dev_dependencies
2400            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2401                package_name: "vitest".to_string(),
2402                location: DependencyLocation::DevDependencies,
2403                path: PathBuf::from("package.json"),
2404                line: 10,
2405                used_in_workspaces: Vec::new(),
2406            }));
2407        r.unused_dev_dependencies
2408            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2409                package_name: "jest".to_string(),
2410                location: DependencyLocation::DevDependencies,
2411                path: PathBuf::from("package.json"),
2412                line: 5,
2413                used_in_workspaces: Vec::new(),
2414            }));
2415        r.sort();
2416        assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
2417        assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
2418    }
2419
2420    // ── sort: unused_optional_dependencies ──────────────────────
2421
2422    #[test]
2423    fn sort_unused_optional_dependencies() {
2424        let mut r = AnalysisResults::default();
2425        r.unused_optional_dependencies
2426            .push(UnusedOptionalDependencyFinding::with_actions(
2427                UnusedDependency {
2428                    package_name: "zod".to_string(),
2429                    location: DependencyLocation::OptionalDependencies,
2430                    path: PathBuf::from("package.json"),
2431                    line: 3,
2432                    used_in_workspaces: Vec::new(),
2433                },
2434            ));
2435        r.unused_optional_dependencies
2436            .push(UnusedOptionalDependencyFinding::with_actions(
2437                UnusedDependency {
2438                    package_name: "ajv".to_string(),
2439                    location: DependencyLocation::OptionalDependencies,
2440                    path: PathBuf::from("package.json"),
2441                    line: 2,
2442                    used_in_workspaces: Vec::new(),
2443                },
2444            ));
2445        r.sort();
2446        assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
2447        assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
2448    }
2449
2450    // ── sort: unused_enum_members by path, line, parent, member ─
2451
2452    #[test]
2453    fn sort_unused_enum_members_by_path_line_parent_member() {
2454        let mut r = AnalysisResults::default();
2455        let mk = |path: &str, line: u32, parent: &str, member: &str| {
2456            UnusedEnumMemberFinding::with_actions(UnusedMember {
2457                path: PathBuf::from(path),
2458                parent_name: parent.to_string(),
2459                member_name: member.to_string(),
2460                kind: MemberKind::EnumMember,
2461                line,
2462                col: 0,
2463            })
2464        };
2465        r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
2466        r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
2467        r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
2468        r.sort();
2469        let keys: Vec<_> = r
2470            .unused_enum_members
2471            .iter()
2472            .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
2473            .collect();
2474        assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
2475    }
2476
2477    // ── sort: unused_class_members by path, line, parent, member
2478
2479    #[test]
2480    fn sort_unused_class_members() {
2481        let mut r = AnalysisResults::default();
2482        let mk = |path: &str, line: u32, parent: &str, member: &str| {
2483            UnusedClassMemberFinding::with_actions(UnusedMember {
2484                path: PathBuf::from(path),
2485                parent_name: parent.to_string(),
2486                member_name: member.to_string(),
2487                kind: MemberKind::ClassMethod,
2488                line,
2489                col: 0,
2490            })
2491        };
2492        r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
2493        r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
2494        r.sort();
2495        assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
2496        assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
2497    }
2498
2499    // ── sort: unresolved_imports by path, line, col, specifier ──
2500
2501    #[test]
2502    fn sort_unresolved_imports_by_path_line_col_specifier() {
2503        let mut r = AnalysisResults::default();
2504        let mk = |path: &str, line: u32, col: u32, spec: &str| {
2505            UnresolvedImportFinding::with_actions(UnresolvedImport {
2506                path: PathBuf::from(path),
2507                specifier: spec.to_string(),
2508                line,
2509                col,
2510                specifier_col: 0,
2511            })
2512        };
2513        r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
2514        r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
2515        r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
2516        r.sort();
2517        let specs: Vec<_> = r
2518            .unresolved_imports
2519            .iter()
2520            .map(|i| i.import.specifier.as_str())
2521            .collect();
2522        assert_eq!(specs, vec!["./m", "./a", "./z"]);
2523    }
2524
2525    // ── sort: unlisted_dependencies + inner imported_from ───────
2526
2527    #[test]
2528    fn sort_unlisted_dependencies_by_name_and_inner_sites() {
2529        let mut r = AnalysisResults::default();
2530        r.unlisted_dependencies
2531            .push(UnlistedDependencyFinding::with_actions(
2532                UnlistedDependency {
2533                    package_name: "zod".to_string(),
2534                    imported_from: vec![
2535                        ImportSite {
2536                            path: PathBuf::from("b.ts"),
2537                            line: 10,
2538                            col: 0,
2539                        },
2540                        ImportSite {
2541                            path: PathBuf::from("a.ts"),
2542                            line: 1,
2543                            col: 0,
2544                        },
2545                    ],
2546                },
2547            ));
2548        r.unlisted_dependencies
2549            .push(UnlistedDependencyFinding::with_actions(
2550                UnlistedDependency {
2551                    package_name: "axios".to_string(),
2552                    imported_from: vec![ImportSite {
2553                        path: PathBuf::from("c.ts"),
2554                        line: 1,
2555                        col: 0,
2556                    }],
2557                },
2558            ));
2559        r.sort();
2560
2561        // Outer sort: by package_name
2562        assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
2563        assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
2564
2565        // Inner sort: imported_from sorted by path, then line
2566        let zod_sites: Vec<_> = r.unlisted_dependencies[1]
2567            .dep
2568            .imported_from
2569            .iter()
2570            .map(|s| s.path.to_string_lossy().to_string())
2571            .collect();
2572        assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
2573    }
2574
2575    // ── sort: duplicate_exports + inner locations ───────────────
2576
2577    #[test]
2578    fn sort_duplicate_exports_by_name_and_inner_locations() {
2579        let mut r = AnalysisResults::default();
2580        r.duplicate_exports
2581            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2582                export_name: "z".to_string(),
2583                locations: vec![
2584                    DuplicateLocation {
2585                        path: PathBuf::from("c.ts"),
2586                        line: 1,
2587                        col: 0,
2588                    },
2589                    DuplicateLocation {
2590                        path: PathBuf::from("a.ts"),
2591                        line: 5,
2592                        col: 0,
2593                    },
2594                ],
2595            }));
2596        r.duplicate_exports
2597            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2598                export_name: "a".to_string(),
2599                locations: vec![DuplicateLocation {
2600                    path: PathBuf::from("b.ts"),
2601                    line: 1,
2602                    col: 0,
2603                }],
2604            }));
2605        r.sort();
2606
2607        // Outer sort: by export_name
2608        assert_eq!(r.duplicate_exports[0].export.export_name, "a");
2609        assert_eq!(r.duplicate_exports[1].export.export_name, "z");
2610
2611        // Inner sort: locations sorted by path, then line
2612        let z_locs: Vec<_> = r.duplicate_exports[1]
2613            .export
2614            .locations
2615            .iter()
2616            .map(|l| l.path.to_string_lossy().to_string())
2617            .collect();
2618        assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
2619    }
2620
2621    // ── sort: type_only_dependencies ────────────────────────────
2622
2623    #[test]
2624    fn sort_type_only_dependencies() {
2625        let mut r = AnalysisResults::default();
2626        r.type_only_dependencies
2627            .push(TypeOnlyDependencyFinding::with_actions(
2628                TypeOnlyDependency {
2629                    package_name: "zod".to_string(),
2630                    path: PathBuf::from("package.json"),
2631                    line: 10,
2632                },
2633            ));
2634        r.type_only_dependencies
2635            .push(TypeOnlyDependencyFinding::with_actions(
2636                TypeOnlyDependency {
2637                    package_name: "ajv".to_string(),
2638                    path: PathBuf::from("package.json"),
2639                    line: 5,
2640                },
2641            ));
2642        r.sort();
2643        assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
2644        assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
2645    }
2646
2647    // ── sort: test_only_dependencies ────────────────────────────
2648
2649    #[test]
2650    fn sort_test_only_dependencies() {
2651        let mut r = AnalysisResults::default();
2652        r.test_only_dependencies
2653            .push(TestOnlyDependencyFinding::with_actions(
2654                TestOnlyDependency {
2655                    package_name: "vitest".to_string(),
2656                    path: PathBuf::from("package.json"),
2657                    line: 15,
2658                },
2659            ));
2660        r.test_only_dependencies
2661            .push(TestOnlyDependencyFinding::with_actions(
2662                TestOnlyDependency {
2663                    package_name: "jest".to_string(),
2664                    path: PathBuf::from("package.json"),
2665                    line: 10,
2666                },
2667            ));
2668        r.sort();
2669        assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
2670        assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
2671    }
2672
2673    // ── sort: circular_dependencies by files, then length ───────
2674
2675    #[test]
2676    fn sort_circular_dependencies_by_files_then_length() {
2677        let mut r = AnalysisResults::default();
2678        r.circular_dependencies
2679            .push(CircularDependencyFinding::with_actions(
2680                CircularDependency {
2681                    files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
2682                    length: 2,
2683                    line: 1,
2684                    col: 0,
2685                    edges: Vec::new(),
2686                    is_cross_package: false,
2687                },
2688            ));
2689        r.circular_dependencies
2690            .push(CircularDependencyFinding::with_actions(
2691                CircularDependency {
2692                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2693                    length: 2,
2694                    line: 1,
2695                    col: 0,
2696                    edges: Vec::new(),
2697                    is_cross_package: true,
2698                },
2699            ));
2700        r.sort();
2701        assert_eq!(
2702            r.circular_dependencies[0].cycle.files[0],
2703            PathBuf::from("a.ts")
2704        );
2705        assert_eq!(
2706            r.circular_dependencies[1].cycle.files[0],
2707            PathBuf::from("b.ts")
2708        );
2709    }
2710
2711    // ── sort: boundary_violations by from_path, line, col, to_path
2712
2713    #[test]
2714    fn sort_boundary_violations() {
2715        let mut r = AnalysisResults::default();
2716        let mk = |from: &str, line: u32, col: u32, to: &str| {
2717            BoundaryViolationFinding::with_actions(BoundaryViolation {
2718                from_path: PathBuf::from(from),
2719                to_path: PathBuf::from(to),
2720                from_zone: "a".to_string(),
2721                to_zone: "b".to_string(),
2722                import_specifier: to.to_string(),
2723                line,
2724                col,
2725            })
2726        };
2727        r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
2728        r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
2729        r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
2730        r.sort();
2731        let from_paths: Vec<_> = r
2732            .boundary_violations
2733            .iter()
2734            .map(|v| {
2735                format!(
2736                    "{}:{}",
2737                    v.violation.from_path.to_string_lossy(),
2738                    v.violation.line
2739                )
2740            })
2741            .collect();
2742        assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
2743    }
2744
2745    // ── sort: export_usages + inner reference_locations ─────────
2746
2747    #[test]
2748    fn sort_export_usages_and_inner_reference_locations() {
2749        let mut r = AnalysisResults::default();
2750        r.export_usages.push(ExportUsage {
2751            path: PathBuf::from("z.ts"),
2752            export_name: "foo".to_string(),
2753            line: 1,
2754            col: 0,
2755            reference_count: 2,
2756            reference_locations: vec![
2757                ReferenceLocation {
2758                    path: PathBuf::from("c.ts"),
2759                    line: 10,
2760                    col: 0,
2761                },
2762                ReferenceLocation {
2763                    path: PathBuf::from("a.ts"),
2764                    line: 5,
2765                    col: 0,
2766                },
2767            ],
2768        });
2769        r.export_usages.push(ExportUsage {
2770            path: PathBuf::from("a.ts"),
2771            export_name: "bar".to_string(),
2772            line: 1,
2773            col: 0,
2774            reference_count: 1,
2775            reference_locations: vec![ReferenceLocation {
2776                path: PathBuf::from("b.ts"),
2777                line: 1,
2778                col: 0,
2779            }],
2780        });
2781        r.sort();
2782
2783        // Outer sort: by path, then line, then export_name
2784        assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
2785        assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
2786
2787        // Inner sort: reference_locations sorted by path, line, col
2788        let refs: Vec<_> = r.export_usages[1]
2789            .reference_locations
2790            .iter()
2791            .map(|l| l.path.to_string_lossy().to_string())
2792            .collect();
2793        assert_eq!(refs, vec!["a.ts", "c.ts"]);
2794    }
2795
2796    // ── sort: empty results does not panic ──────────────────────
2797
2798    #[test]
2799    fn sort_empty_results_is_noop() {
2800        let mut r = AnalysisResults::default();
2801        r.sort(); // should not panic
2802        assert_eq!(r.total_issues(), 0);
2803    }
2804
2805    // ── sort: single-element lists remain stable ────────────────
2806
2807    #[test]
2808    fn sort_single_element_lists_stable() {
2809        let mut r = AnalysisResults::default();
2810        r.unused_files
2811            .push(UnusedFileFinding::with_actions(UnusedFile {
2812                path: PathBuf::from("only.ts"),
2813            }));
2814        r.sort();
2815        assert_eq!(r.unused_files[0].file.path, PathBuf::from("only.ts"));
2816    }
2817
2818    // ── serialization ──────────────────────────────────────────
2819
2820    #[test]
2821    fn serialize_empty_results() {
2822        let r = AnalysisResults::default();
2823        let json = serde_json::to_value(&r).unwrap();
2824
2825        // All arrays should be present and empty
2826        assert!(json["unused_files"].as_array().unwrap().is_empty());
2827        assert!(json["unused_exports"].as_array().unwrap().is_empty());
2828        assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
2829
2830        // Skipped fields should be absent
2831        assert!(json.get("export_usages").is_none());
2832        assert!(json.get("entry_point_summary").is_none());
2833    }
2834
2835    #[test]
2836    fn serialize_unused_file_path() {
2837        let r = UnusedFile {
2838            path: PathBuf::from("src/utils/index.ts"),
2839        };
2840        let json = serde_json::to_value(&r).unwrap();
2841        assert_eq!(json["path"], "src/utils/index.ts");
2842    }
2843
2844    #[test]
2845    fn serialize_dependency_location_camel_case() {
2846        let dep = UnusedDependency {
2847            package_name: "react".to_string(),
2848            location: DependencyLocation::DevDependencies,
2849            path: PathBuf::from("package.json"),
2850            line: 5,
2851            used_in_workspaces: Vec::new(),
2852        };
2853        let json = serde_json::to_value(&dep).unwrap();
2854        assert_eq!(json["location"], "devDependencies");
2855
2856        let dep2 = UnusedDependency {
2857            package_name: "react".to_string(),
2858            location: DependencyLocation::Dependencies,
2859            path: PathBuf::from("package.json"),
2860            line: 3,
2861            used_in_workspaces: Vec::new(),
2862        };
2863        let json2 = serde_json::to_value(&dep2).unwrap();
2864        assert_eq!(json2["location"], "dependencies");
2865
2866        let dep3 = UnusedDependency {
2867            package_name: "fsevents".to_string(),
2868            location: DependencyLocation::OptionalDependencies,
2869            path: PathBuf::from("package.json"),
2870            line: 7,
2871            used_in_workspaces: Vec::new(),
2872        };
2873        let json3 = serde_json::to_value(&dep3).unwrap();
2874        assert_eq!(json3["location"], "optionalDependencies");
2875    }
2876
2877    #[test]
2878    fn serialize_circular_dependency_skips_false_cross_package() {
2879        let cd = CircularDependency {
2880            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2881            length: 2,
2882            line: 1,
2883            col: 0,
2884            edges: Vec::new(),
2885            is_cross_package: false,
2886        };
2887        let json = serde_json::to_value(&cd).unwrap();
2888        // skip_serializing_if = "std::ops::Not::not" means false is skipped
2889        assert!(json.get("is_cross_package").is_none());
2890    }
2891
2892    #[test]
2893    fn serialize_circular_dependency_includes_true_cross_package() {
2894        let cd = CircularDependency {
2895            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2896            length: 2,
2897            line: 1,
2898            col: 0,
2899            edges: Vec::new(),
2900            is_cross_package: true,
2901        };
2902        let json = serde_json::to_value(&cd).unwrap();
2903        assert_eq!(json["is_cross_package"], true);
2904    }
2905
2906    #[test]
2907    fn serialize_unused_export_fields() {
2908        let e = UnusedExport {
2909            path: PathBuf::from("src/mod.ts"),
2910            export_name: "helper".to_string(),
2911            is_type_only: true,
2912            line: 42,
2913            col: 7,
2914            span_start: 100,
2915            is_re_export: true,
2916        };
2917        let json = serde_json::to_value(&e).unwrap();
2918        assert_eq!(json["path"], "src/mod.ts");
2919        assert_eq!(json["export_name"], "helper");
2920        assert_eq!(json["is_type_only"], true);
2921        assert_eq!(json["line"], 42);
2922        assert_eq!(json["col"], 7);
2923        assert_eq!(json["span_start"], 100);
2924        assert_eq!(json["is_re_export"], true);
2925    }
2926
2927    #[test]
2928    fn serialize_boundary_violation_fields() {
2929        let v = BoundaryViolation {
2930            from_path: PathBuf::from("src/ui/button.tsx"),
2931            to_path: PathBuf::from("src/db/queries.ts"),
2932            from_zone: "ui".to_string(),
2933            to_zone: "db".to_string(),
2934            import_specifier: "../db/queries".to_string(),
2935            line: 3,
2936            col: 0,
2937        };
2938        let json = serde_json::to_value(&v).unwrap();
2939        assert_eq!(json["from_path"], "src/ui/button.tsx");
2940        assert_eq!(json["to_path"], "src/db/queries.ts");
2941        assert_eq!(json["from_zone"], "ui");
2942        assert_eq!(json["to_zone"], "db");
2943        assert_eq!(json["import_specifier"], "../db/queries");
2944    }
2945
2946    #[test]
2947    fn serialize_unlisted_dependency_with_import_sites() {
2948        let d = UnlistedDependency {
2949            package_name: "chalk".to_string(),
2950            imported_from: vec![
2951                ImportSite {
2952                    path: PathBuf::from("a.ts"),
2953                    line: 1,
2954                    col: 0,
2955                },
2956                ImportSite {
2957                    path: PathBuf::from("b.ts"),
2958                    line: 5,
2959                    col: 3,
2960                },
2961            ],
2962        };
2963        let json = serde_json::to_value(&d).unwrap();
2964        assert_eq!(json["package_name"], "chalk");
2965        let sites = json["imported_from"].as_array().unwrap();
2966        assert_eq!(sites.len(), 2);
2967        assert_eq!(sites[0]["path"], "a.ts");
2968        assert_eq!(sites[1]["line"], 5);
2969    }
2970
2971    #[test]
2972    fn serialize_duplicate_export_with_locations() {
2973        let d = DuplicateExport {
2974            export_name: "Button".to_string(),
2975            locations: vec![
2976                DuplicateLocation {
2977                    path: PathBuf::from("src/a.ts"),
2978                    line: 10,
2979                    col: 0,
2980                },
2981                DuplicateLocation {
2982                    path: PathBuf::from("src/b.ts"),
2983                    line: 20,
2984                    col: 5,
2985                },
2986            ],
2987        };
2988        let json = serde_json::to_value(&d).unwrap();
2989        assert_eq!(json["export_name"], "Button");
2990        let locs = json["locations"].as_array().unwrap();
2991        assert_eq!(locs.len(), 2);
2992        assert_eq!(locs[0]["line"], 10);
2993        assert_eq!(locs[1]["col"], 5);
2994    }
2995
2996    #[test]
2997    fn serialize_type_only_dependency() {
2998        let d = TypeOnlyDependency {
2999            package_name: "@types/react".to_string(),
3000            path: PathBuf::from("package.json"),
3001            line: 12,
3002        };
3003        let json = serde_json::to_value(&d).unwrap();
3004        assert_eq!(json["package_name"], "@types/react");
3005        assert_eq!(json["line"], 12);
3006    }
3007
3008    #[test]
3009    fn serialize_test_only_dependency() {
3010        let d = TestOnlyDependency {
3011            package_name: "vitest".to_string(),
3012            path: PathBuf::from("package.json"),
3013            line: 8,
3014        };
3015        let json = serde_json::to_value(&d).unwrap();
3016        assert_eq!(json["package_name"], "vitest");
3017        assert_eq!(json["line"], 8);
3018    }
3019
3020    #[test]
3021    fn serialize_unused_member() {
3022        let m = UnusedMember {
3023            path: PathBuf::from("enums.ts"),
3024            parent_name: "Status".to_string(),
3025            member_name: "Pending".to_string(),
3026            kind: MemberKind::EnumMember,
3027            line: 3,
3028            col: 4,
3029        };
3030        let json = serde_json::to_value(&m).unwrap();
3031        assert_eq!(json["parent_name"], "Status");
3032        assert_eq!(json["member_name"], "Pending");
3033        assert_eq!(json["line"], 3);
3034    }
3035
3036    #[test]
3037    fn serialize_unresolved_import() {
3038        let i = UnresolvedImport {
3039            path: PathBuf::from("app.ts"),
3040            specifier: "./missing-module".to_string(),
3041            line: 7,
3042            col: 0,
3043            specifier_col: 21,
3044        };
3045        let json = serde_json::to_value(&i).unwrap();
3046        assert_eq!(json["specifier"], "./missing-module");
3047        assert_eq!(json["specifier_col"], 21);
3048    }
3049
3050    // ── deserialize: CircularDependency serde(default) fields ──
3051
3052    #[test]
3053    fn deserialize_circular_dependency_with_defaults() {
3054        // CircularDependency derives Deserialize; line/col/is_cross_package have #[serde(default)]
3055        let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
3056        let cd: CircularDependency = serde_json::from_str(json).unwrap();
3057        assert_eq!(cd.files.len(), 2);
3058        assert_eq!(cd.length, 2);
3059        assert_eq!(cd.line, 0);
3060        assert_eq!(cd.col, 0);
3061        assert!(!cd.is_cross_package);
3062    }
3063
3064    #[test]
3065    fn deserialize_circular_dependency_with_all_fields() {
3066        let json =
3067            r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
3068        let cd: CircularDependency = serde_json::from_str(json).unwrap();
3069        assert_eq!(cd.line, 5);
3070        assert_eq!(cd.col, 10);
3071        assert!(cd.is_cross_package);
3072    }
3073
3074    // ── clone produces independent copies ───────────────────────
3075
3076    #[test]
3077    fn clone_results_are_independent() {
3078        let mut r = AnalysisResults::default();
3079        r.unused_files
3080            .push(UnusedFileFinding::with_actions(UnusedFile {
3081                path: PathBuf::from("a.ts"),
3082            }));
3083        let mut cloned = r.clone();
3084        cloned
3085            .unused_files
3086            .push(UnusedFileFinding::with_actions(UnusedFile {
3087                path: PathBuf::from("b.ts"),
3088            }));
3089        assert_eq!(r.total_issues(), 1);
3090        assert_eq!(cloned.total_issues(), 2);
3091    }
3092
3093    // ── export_usages not counted in total_issues ───────────────
3094
3095    #[test]
3096    fn export_usages_not_counted_in_total_issues() {
3097        let mut r = AnalysisResults::default();
3098        r.export_usages.push(ExportUsage {
3099            path: PathBuf::from("mod.ts"),
3100            export_name: "foo".to_string(),
3101            line: 1,
3102            col: 0,
3103            reference_count: 3,
3104            reference_locations: vec![],
3105        });
3106        // export_usages is metadata, not an issue type
3107        assert_eq!(r.total_issues(), 0);
3108        assert!(!r.has_issues());
3109    }
3110
3111    // ── entry_point_summary not counted in total_issues ─────────
3112
3113    #[test]
3114    fn entry_point_summary_not_counted_in_total_issues() {
3115        let r = AnalysisResults {
3116            entry_point_summary: Some(EntryPointSummary {
3117                total: 10,
3118                by_source: vec![("config".to_string(), 10)],
3119            }),
3120            ..AnalysisResults::default()
3121        };
3122        assert_eq!(r.total_issues(), 0);
3123        assert!(!r.has_issues());
3124    }
3125}