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::{
8    MemberKind, SecurityControlKind, SecurityUrlShape, SkippedSecurityCalleeExpressionKind,
9    SkippedSecurityCalleeReason,
10};
11use crate::output::IssueAction;
12use crate::output_dead_code::{
13    BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
14    CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
15    DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
16    MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
17    MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
18    PropDrillingChainFinding, ReExportCycleFinding, RouteCollisionFinding,
19    TestOnlyDependencyFinding, ThinWrapperFinding, TypeOnlyDependencyFinding,
20    UnlistedDependencyFinding, UnprovidedInjectFinding, UnrenderedComponentFinding,
21    UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
22    UnusedClassMemberFinding, UnusedComponentEmitFinding, UnusedComponentPropFinding,
23    UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
24    UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
25    UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
26    UnusedTypeFinding,
27};
28use crate::serde_path;
29use crate::suppress::{IssueKind, closest_known_kind_name};
30
31/// Summary of detected entry points, grouped by discovery source.
32///
33/// Used to surface entry-point detection status in human and JSON output,
34/// so library authors can verify that fallow found the right entry points.
35#[derive(Debug, Clone, Default)]
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37pub struct EntryPointSummary {
38    /// Total number of entry points detected.
39    pub total: usize,
40    /// Breakdown by source category (e.g., "package.json" -> 3, "plugin" -> 12).
41    /// Sorted by key for deterministic output.
42    pub by_source: Vec<(String, usize)>,
43}
44
45/// Per-component render fan-in counts plus the precomputed concentration
46/// aggregates.
47///
48/// DESCRIPTIVE blast-radius signal (NOT a rule, finding, or threshold): the
49/// component-graph analogue of module-level fan-in. Module fan-in counts
50/// importing MODULES; render fan-in counts JSX render CALL SITES (a shared
51/// `<Button>` is rendered in far more places than it is imported).
52///
53/// `per_component` is the internal carrier (keyed for hotspot path annotation),
54/// `#[serde(skip)]` on [`AnalysisResults`] so it never appears under bare
55/// `fallow` / `audit`; the aggregates feed the descriptive `VitalSigns` block
56/// (`p95_render_fan_in` / `render_fan_in_high_pct` / `max_render_fan_in`).
57///
58/// UNDERCOUNT is the documented safe direction: a child rendered via a JSX
59/// spread, a dynamic / `createElement(var)` form, or a member-expression tag
60/// (`<Lib.Button/>`) is not resolved by the shared `ChildResolver` and so
61/// increments no component's fan-in. A true high-fan-in component can only be
62/// undersold, never falsely flagged. A rare name-collision over-credit is
63/// possible via the default-import sole-component fallback (inherited verbatim
64/// from the prop-drilling / thin-wrapper resolver); low-harm for a descriptive,
65/// non-gating metric.
66#[derive(Debug, Clone, Default)]
67pub struct RenderFanInMetric {
68    /// Per-component render-site + distinct-parent counts. Keyed by
69    /// `(component file path, component name)` so the hotspot surface can map a
70    /// file back to its top component's fan-in. Components rendered nowhere ARE
71    /// included as a real `0` so the percentile distribution is not skewed.
72    pub per_component: Vec<RenderFanInComponent>,
73    /// 95th-percentile DISTINCT-PARENTS render fan-in across components (the
74    /// per-component distribution analogue of the module-fan-in p95). `None` on
75    /// an empty population. Mirrors `compute_coupling_concentration`.
76    pub p95_distinct_parents: Option<u32>,
77    /// Percentage of components whose distinct-parents render fan-in exceeds the
78    /// `max(p95, 10)` threshold (the same floor coupling concentration uses).
79    /// `None` on an empty population.
80    pub high_pct: Option<f64>,
81    /// The single highest DISTINCT-PARENTS count across all components (the
82    /// headline blast-radius number: the most distinct render LOCATIONS any one
83    /// component is rendered from, the honest edit-ripple count). `None` on an
84    /// empty population. `render_sites` (incl. repeats) is secondary per-component
85    /// context, never the headline.
86    pub max_distinct_parents: Option<u32>,
87}
88
89/// One component's render fan-in detail: how many JSX render SITES target it and
90/// how many DISTINCT parent components render it.
91#[derive(Debug, Clone)]
92pub struct RenderFanInComponent {
93    /// Absolute path of the file declaring the component.
94    pub file: PathBuf,
95    /// The component name.
96    pub component: String,
97    /// Total JSX render SITES that resolve to this component across the project
98    /// (each capitalized / member JSX tag is one site). SECONDARY context ("incl.
99    /// repeats"): a single parent rendering one child five times is five sites but
100    /// one distinct parent, so render_sites overcounts blast radius.
101    pub render_sites: u32,
102    /// Distinct `(parent_file, parent_component)` keys that render this
103    /// component. The HEADLINE blast-radius axis: the honest count of distinct
104    /// render LOCATIONS, the percentiled distribution analogue of "distinct
105    /// importers".
106    pub distinct_parents: u32,
107}
108
109/// Complete analysis results.
110///
111/// # Examples
112///
113/// ```
114/// use fallow_types::output_dead_code::UnusedFileFinding;
115/// use fallow_types::results::{AnalysisResults, UnusedFile};
116/// use std::path::PathBuf;
117///
118/// let mut results = AnalysisResults::default();
119/// assert_eq!(results.total_issues(), 0);
120/// assert!(!results.has_issues());
121///
122/// results
123///     .unused_files
124///     .push(UnusedFileFinding::with_actions(UnusedFile {
125///         path: PathBuf::from("src/dead.ts"),
126///     }));
127/// assert_eq!(results.total_issues(), 1);
128/// assert!(results.has_issues());
129/// ```
130#[derive(Debug, Default, Clone, Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct AnalysisResults {
133    /// Files not reachable from any entry point. Wrapped in
134    /// [`UnusedFileFinding`] so each entry carries a typed `actions` array
135    /// natively, replacing the pre-2.76 post-pass injection.
136    pub unused_files: Vec<UnusedFileFinding>,
137    /// Exports never imported by other modules. Wrapped in
138    /// [`UnusedExportFinding`] so each entry carries a typed `actions`
139    /// array natively.
140    pub unused_exports: Vec<UnusedExportFinding>,
141    /// Type exports never imported by other modules. Wrapped in
142    /// [`UnusedTypeFinding`]: the inner [`UnusedExport`] struct is shared
143    /// with `unused_exports` but the wrapper emits a type-targeted fix
144    /// description.
145    pub unused_types: Vec<UnusedTypeFinding>,
146    /// Exported symbols whose public signature references same-file private
147    /// types. Wrapped in [`PrivateTypeLeakFinding`] so each entry carries a
148    /// typed `actions` array natively.
149    pub private_type_leaks: Vec<PrivateTypeLeakFinding>,
150    /// Dependencies listed in package.json but never imported. Wrapped in
151    /// [`UnusedDependencyFinding`] so each entry carries a typed `actions`
152    /// array natively. The fix action swaps from `remove-dependency` to
153    /// `move-dependency` when `used_in_workspaces` is non-empty.
154    pub unused_dependencies: Vec<UnusedDependencyFinding>,
155    /// Dev dependencies listed in package.json but never imported. Wrapped
156    /// in [`UnusedDevDependencyFinding`]: same bare struct as
157    /// `unused_dependencies` with a `devDependencies`-targeted fix
158    /// description.
159    pub unused_dev_dependencies: Vec<UnusedDevDependencyFinding>,
160    /// Optional dependencies listed in package.json but never imported.
161    /// Wrapped in [`UnusedOptionalDependencyFinding`] with an
162    /// `optionalDependencies`-targeted fix description.
163    pub unused_optional_dependencies: Vec<UnusedOptionalDependencyFinding>,
164    /// Enum members never accessed. Wrapped in
165    /// [`UnusedEnumMemberFinding`] so each entry carries a typed `actions`
166    /// array natively.
167    pub unused_enum_members: Vec<UnusedEnumMemberFinding>,
168    /// Class members never accessed. Wrapped in
169    /// [`UnusedClassMemberFinding`]: same inner [`UnusedMember`] struct as
170    /// `unused_enum_members`, with a class-targeted fix description and the
171    /// `auto_fixable: false` default to reflect dependency-injection
172    /// patterns.
173    pub unused_class_members: Vec<UnusedClassMemberFinding>,
174    /// Store members (Pinia `state` / `getters` / `actions` key, or a
175    /// setup-store returned key) declared but never accessed by any consumer
176    /// project-wide. Wrapped in [`UnusedStoreMemberFinding`]: same inner
177    /// [`UnusedMember`] struct as `unused_class_members`, with a
178    /// store-targeted fix description. Cross-graph: the store binding is
179    /// imported (the module is reachable) yet a specific member is dead.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub unused_store_members: Vec<UnusedStoreMemberFinding>,
182    /// Import specifiers that could not be resolved. Wrapped in
183    /// [`UnresolvedImportFinding`] so each entry carries a typed `actions`
184    /// array natively.
185    pub unresolved_imports: Vec<UnresolvedImportFinding>,
186    /// Dependencies used in code but not listed in package.json. Wrapped in
187    /// [`UnlistedDependencyFinding`].
188    pub unlisted_dependencies: Vec<UnlistedDependencyFinding>,
189    /// Exports with the same name across multiple modules. Wrapped in
190    /// [`DuplicateExportFinding`] so each entry carries a typed `actions`
191    /// array natively, with the position-0 `add-to-config` `ignoreExports`
192    /// snippet wired in at wrapper construction.
193    pub duplicate_exports: Vec<DuplicateExportFinding>,
194    /// Production dependencies only used via type-only imports (could be
195    /// devDependencies). Only populated in production mode. Wrapped in
196    /// [`TypeOnlyDependencyFinding`].
197    pub type_only_dependencies: Vec<TypeOnlyDependencyFinding>,
198    /// Production dependencies only imported by test files (could be
199    /// devDependencies). Wrapped in [`TestOnlyDependencyFinding`].
200    #[serde(default)]
201    pub test_only_dependencies: Vec<TestOnlyDependencyFinding>,
202    /// Circular dependency chains detected in the module graph. Wrapped in
203    /// [`CircularDependencyFinding`] so each entry carries a typed `actions`
204    /// array natively.
205    pub circular_dependencies: Vec<CircularDependencyFinding>,
206    /// Cycles or self-loops in the re-export edge subgraph (barrel files
207    /// re-exporting from each other in a loop). Wrapped in
208    /// [`ReExportCycleFinding`] so each entry carries a typed `actions`
209    /// array natively (a `refactor-re-export-cycle` informational primary
210    /// plus a `suppress-file` secondary; cycles are file-scoped so a single
211    /// suppression breaks the cycle).
212    #[serde(default)]
213    pub re_export_cycles: Vec<ReExportCycleFinding>,
214    /// Imports that cross architecture boundary rules. Wrapped in
215    /// [`BoundaryViolationFinding`] so each entry carries a typed `actions`
216    /// array natively.
217    #[serde(default)]
218    pub boundary_violations: Vec<BoundaryViolationFinding>,
219    /// Files that matched no architecture boundary zone while
220    /// `boundaries.coverage.requireAllFiles` was enabled.
221    #[serde(default)]
222    pub boundary_coverage_violations: Vec<BoundaryCoverageViolationFinding>,
223    /// Calls from zoned files to callees forbidden for that zone via
224    /// `boundaries.calls.forbidden`. Wrapped in
225    /// [`BoundaryCallViolationFinding`] so each entry carries a typed
226    /// `actions` array natively.
227    #[serde(default)]
228    pub boundary_call_violations: Vec<BoundaryCallViolationFinding>,
229    /// Banned calls and banned imports matched by declarative rule packs
230    /// (`rulePacks` config). Wrapped in [`PolicyViolationFinding`] so each
231    /// entry carries a typed `actions` array natively. Each finding carries
232    /// its effective per-rule severity.
233    #[serde(default)]
234    pub policy_violations: Vec<PolicyViolationFinding>,
235    /// Suppression comments or JSDoc tags that no longer match any issue.
236    #[serde(default)]
237    pub stale_suppressions: Vec<StaleSuppression>,
238    /// Entries in pnpm-workspace.yaml's catalog: or catalogs: sections not
239    /// referenced by any workspace package via the catalog: protocol. Wrapped
240    /// in [`UnusedCatalogEntryFinding`] so each entry carries a typed
241    /// `actions` array natively, with per-instance `auto_fixable` derived
242    /// from `hardcoded_consumers`.
243    #[serde(default)]
244    pub unused_catalog_entries: Vec<UnusedCatalogEntryFinding>,
245    /// Named groups under pnpm-workspace.yaml's catalogs: section that declare
246    /// no package entries. The top-level catalog: map is not reported. Wrapped
247    /// in [`EmptyCatalogGroupFinding`].
248    #[serde(default)]
249    pub empty_catalog_groups: Vec<EmptyCatalogGroupFinding>,
250    /// Workspace package.json references to catalogs (`catalog:` or
251    /// `catalog:<name>`) that do not declare the consumed package. pnpm install
252    /// will error until the named catalog grows to include the package or the
253    /// reference is switched / removed. Wrapped in
254    /// [`UnresolvedCatalogReferenceFinding`] with the discriminated
255    /// `add-catalog-entry` / `update-catalog-reference` primary at position 0.
256    #[serde(default)]
257    pub unresolved_catalog_references: Vec<UnresolvedCatalogReferenceFinding>,
258    /// Entries in pnpm-workspace.yaml's overrides: section, or package.json's
259    /// pnpm.overrides block, whose target package is not declared by any
260    /// workspace package and is not present in pnpm-lock.yaml. Default severity
261    /// is warn because projects without a readable lockfile fall back to
262    /// manifest-only checks; the hint field flags those conservative cases.
263    /// Wrapped in [`UnusedDependencyOverrideFinding`].
264    #[serde(default)]
265    pub unused_dependency_overrides: Vec<UnusedDependencyOverrideFinding>,
266    /// pnpm.overrides entries whose key or value does not parse as a valid
267    /// override spec (empty key, empty value, malformed selector, unbalanced
268    /// parent matcher). pnpm install will reject these. Default severity is
269    /// error. Wrapped in [`MisconfiguredDependencyOverrideFinding`].
270    #[serde(default)]
271    pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverrideFinding>,
272    /// `"use client"` files that export a Next.js server-only / route-segment
273    /// config name (e.g. `metadata`, `revalidate`, `GET`). Next.js rejects this
274    /// at build time. Wrapped in [`InvalidClientExportFinding`] so each entry
275    /// carries a typed `actions` array natively. Default severity is `warn`.
276    #[serde(default)]
277    pub invalid_client_exports: Vec<InvalidClientExportFinding>,
278    /// Barrel files that re-export BOTH a `"use client"` origin module AND a
279    /// server-only origin module (the Next.js App Router footgun). Wrapped in
280    /// [`MixedClientServerBarrelFinding`] so each entry carries a typed
281    /// `actions` array natively. Default severity is `warn`.
282    #[serde(default)]
283    pub mixed_client_server_barrels: Vec<MixedClientServerBarrelFinding>,
284    /// `"use client"` / `"use server"` directives written as expression
285    /// statements after a non-directive statement, so the RSC bundler parses
286    /// them as ordinary strings and silently ignores them. Wrapped in
287    /// [`MisplacedDirectiveFinding`] so each entry carries a typed `actions`
288    /// array natively. Default severity is `warn`.
289    #[serde(default)]
290    pub misplaced_directives: Vec<MisplacedDirectiveFinding>,
291    /// Vue `inject(KEY)` / Svelte `getContext(KEY)` calls whose symbol KEY is
292    /// provided nowhere in the project (the injected-never-provided dead-half).
293    /// Wrapped in [`UnprovidedInjectFinding`] so each entry carries a typed
294    /// `actions` array natively. Default severity is `warn`.
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub unprovided_injects: Vec<UnprovidedInjectFinding>,
297    /// Vue/Svelte single-file components that are reachable but rendered nowhere
298    /// (the imported-but-never-rendered dead-half). Wrapped in
299    /// [`UnrenderedComponentFinding`] so each entry carries a typed `actions`
300    /// array natively. Default severity is `warn`.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub unrendered_components: Vec<UnrenderedComponentFinding>,
303    /// Next.js App Router route files that resolve to the same URL within one
304    /// app-root (a guaranteed `next build` failure). Wrapped in
305    /// [`RouteCollisionFinding`] so each entry carries a typed `actions` array
306    /// natively. One finding per colliding file. Default severity is `warn`.
307    #[serde(default)]
308    pub route_collisions: Vec<RouteCollisionFinding>,
309    /// Sibling Next.js dynamic route segments at one tree position using
310    /// different param spellings (a dev / runtime error; `next build` does NOT
311    /// catch it). Wrapped in [`DynamicSegmentNameConflictFinding`] so each entry
312    /// carries a typed `actions` array natively. Default severity is `warn`.
313    #[serde(default)]
314    pub dynamic_segment_name_conflicts: Vec<DynamicSegmentNameConflictFinding>,
315    /// Vue `<script setup>` `defineProps` props referenced nowhere in their own
316    /// SFC (neither `<script>` nor `<template>`). Wrapped in
317    /// [`UnusedComponentPropFinding`] so each entry carries a typed `actions`
318    /// array natively. Default severity is `warn`.
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub unused_component_props: Vec<UnusedComponentPropFinding>,
321    /// Vue `<script setup>` `defineEmits` events emitted nowhere in their own SFC
322    /// (no `emit('<name>')` call). Wrapped in [`UnusedComponentEmitFinding`] so
323    /// each entry carries a typed `actions` array natively. Default severity is
324    /// `warn`.
325    #[serde(default, skip_serializing_if = "Vec::is_empty")]
326    pub unused_component_emits: Vec<UnusedComponentEmitFinding>,
327    /// Next.js Server Actions (exports of `"use server"` files) that no code in
328    /// the project references. Reclassified out of `unused_exports` for
329    /// `"use server"` files. Wrapped in [`UnusedServerActionFinding`] so each
330    /// entry carries a typed `actions` array natively. Default severity is
331    /// `warn`.
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub unused_server_actions: Vec<UnusedServerActionFinding>,
334    /// SvelteKit `+page.{ts,server.ts,js,server.js}` `load()` return-object keys
335    /// read by no consumer. Wrapped in [`UnusedLoadDataKeyFinding`] so each entry
336    /// carries a typed `actions` array natively. Default severity is `warn`.
337    #[serde(default, skip_serializing_if = "Vec::is_empty")]
338    pub unused_load_data_keys: Vec<UnusedLoadDataKeyFinding>,
339    /// `true` when the `unused-load-data-key` detector abstained project-wide
340    /// because a whole-object use of `page.data` / `$page.data` was seen
341    /// somewhere (S1 observability: an empty `unused_load_data_keys` with this
342    /// flag set is NOT a clean bill, it means the rule could not run safely).
343    /// Serialized only when `true` so the default JSON contract is unchanged.
344    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
345    pub unused_load_data_keys_global_abstain: bool,
346    /// React/Preact props forwarded unchanged through `>= N` intermediate
347    /// pass-through components until a consumer (located per-chain records).
348    /// Wrapped in [`PropDrillingChainFinding`] so each entry carries a typed
349    /// `actions` array natively. Health signal: the rule defaults to `off`
350    /// (opt-in), so this is dormant and populated ONLY when the user enables it.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub prop_drilling_chains: Vec<PropDrillingChainFinding>,
353    /// React/Preact components whose entire body is a single spread-forwarded
354    /// child render (`return <Child {...props}/>`): pure structural indirection,
355    /// a candidate for inlining at call sites. Wrapped in [`ThinWrapperFinding`]
356    /// so each entry carries a typed `actions` array natively. Health signal: the
357    /// rule defaults to `off` (opt-in), so this is dormant and populated ONLY
358    /// when the user enables it.
359    #[serde(default, skip_serializing_if = "Vec::is_empty")]
360    pub thin_wrappers: Vec<ThinWrapperFinding>,
361    /// React/Preact components that participate in a duplicate-prop-shape group:
362    /// three or more components across two or more files whose statically-known
363    /// prop NAME set is identical after stripping ubiquitous DOM / passthrough
364    /// names (a missing shared `Props` type / base component). Wrapped in
365    /// [`DuplicatePropShapeFinding`] so each entry carries a typed `actions`
366    /// array and its sibling roster natively. Health signal: the rule defaults to
367    /// `off` (opt-in), so this is dormant and populated ONLY when the user
368    /// enables it.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub duplicate_prop_shapes: Vec<DuplicatePropShapeFinding>,
371    /// Number of suppression entries that matched an issue during analysis.
372    /// Human output uses this for the suppression footer; it is skipped in
373    /// machine output to avoid changing the public JSON issue contract.
374    #[serde(skip)]
375    pub suppression_count: usize,
376    /// Suppression comments present in analyzed files this run (every present
377    /// marker, all kinds, not only consumed ones). Internal: read in-process by
378    /// `fallow impact` to distinguish a genuinely resolved finding from one
379    /// silenced by a `fallow-ignore`. Skipped during serialization, like
380    /// [`Self::suppression_count`], so the public JSON output contract is
381    /// unchanged.
382    #[serde(skip)]
383    pub active_suppressions: Vec<ActiveSuppression>,
384    /// Detected feature flag patterns. Advisory output, not included in issue counts.
385    /// Skipped during default serialization: injected separately in JSON output when enabled.
386    #[serde(skip)]
387    pub feature_flags: Vec<FeatureFlag>,
388    /// Local security candidates (e.g. `client-server-leak`). CANDIDATES for
389    /// downstream agent verification, NOT verified vulnerabilities. Off by
390    /// default; populated only when the corresponding `security_*` rule is
391    /// enabled (forced on by `fallow security`). Excluded from `total_issues`
392    /// and skipped during serialization so they never surface under bare
393    /// `fallow` or the `audit` gate; the `fallow security` command reads this
394    /// field and emits its own envelope. Mirrors [`Self::feature_flags`].
395    #[serde(skip)]
396    pub security_findings: Vec<SecurityFinding>,
397    /// In-band blind-spot count: number of `"use client"` files whose transitive
398    /// import cone contains a dynamic `import()` the reachability BFS cannot
399    /// follow. Surfaced by `fallow security` so a leak hidden behind an
400    /// unresolved edge is never silently reported as "clean". Skipped during
401    /// serialization like [`Self::security_findings`].
402    #[serde(skip)]
403    pub security_unresolved_edge_files: usize,
404    /// In-band blind-spot count: number of sink-shaped nodes the catalogue
405    /// detector could not flatten to a static callee path (dynamic dispatch,
406    /// computed members, aliased bindings). Surfaced by `fallow security` so an
407    /// empty catalogue result with a non-zero count is not reported as "clean".
408    /// Skipped during serialization like [`Self::security_findings`].
409    #[serde(skip)]
410    pub security_unresolved_callee_sites: usize,
411    /// Location samples for sink-shaped nodes the catalogue detector could not
412    /// flatten to a static callee path. Skipped during default serialization;
413    /// `fallow security` summarizes this metadata in its own envelope.
414    #[serde(skip)]
415    pub security_unresolved_callee_diagnostics: Vec<SecurityUnresolvedCalleeDiagnostic>,
416    /// Usage counts for all exports across the project. Used by the LSP for Code Lens.
417    /// Not included in issue counts -- this is metadata, not an issue type.
418    /// Skipped during serialization: this is internal LSP data, not part of the JSON output schema.
419    #[serde(skip)]
420    pub export_usages: Vec<ExportUsage>,
421    /// Summary of detected entry points, grouped by discovery source.
422    /// Not included in issue counts -- this is informational metadata.
423    /// Skipped during serialization: rendered separately in JSON output.
424    #[serde(skip)]
425    pub entry_point_summary: Option<EntryPointSummary>,
426    /// Per-component render fan-in (JSX render SITES + distinct parents) plus the
427    /// precomputed concentration aggregates. DESCRIPTIVE blast-radius signal, not
428    /// an issue type: the component-graph analogue of module fan-in. `None` on
429    /// non-React projects (the dep gate fails and `render_edges` is empty).
430    /// Skipped during serialization (internal carrier, like
431    /// [`Self::export_usages`]); the public surface is the `VitalSigns`
432    /// aggregate, so bare `fallow` / `audit` never serialize it. See
433    /// [`RenderFanInMetric`].
434    #[serde(skip)]
435    pub render_fan_in: Option<RenderFanInMetric>,
436}
437
438impl AnalysisResults {
439    /// Total number of issues found.
440    ///
441    /// Sums across all issue categories (unused files, exports, types,
442    /// dependencies, members, unresolved imports, unlisted deps, duplicates,
443    /// type-only deps, circular deps, and boundary violations).
444    ///
445    /// # Examples
446    ///
447    /// ```
448    /// use fallow_types::output_dead_code::{UnresolvedImportFinding, UnusedFileFinding};
449    /// use fallow_types::results::{AnalysisResults, UnresolvedImport, UnusedFile};
450    /// use std::path::PathBuf;
451    ///
452    /// let mut results = AnalysisResults::default();
453    /// results
454    ///     .unused_files
455    ///     .push(UnusedFileFinding::with_actions(UnusedFile {
456    ///         path: PathBuf::from("a.ts"),
457    ///     }));
458    /// results
459    ///     .unresolved_imports
460    ///     .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
461    ///         path: PathBuf::from("b.ts"),
462    ///         specifier: "./missing".to_string(),
463    ///         line: 1,
464    ///         col: 0,
465    ///         specifier_col: 0,
466    ///     }));
467    /// assert_eq!(results.total_issues(), 2);
468    /// ```
469    #[must_use]
470    pub const fn total_issues(&self) -> usize {
471        self.unused_files.len()
472            + self.unused_exports.len()
473            + self.unused_types.len()
474            + self.private_type_leaks.len()
475            + self.unused_dependencies.len()
476            + self.unused_dev_dependencies.len()
477            + self.unused_optional_dependencies.len()
478            + self.unused_enum_members.len()
479            + self.unused_class_members.len()
480            + self.unused_store_members.len()
481            + self.unresolved_imports.len()
482            + self.unlisted_dependencies.len()
483            + self.duplicate_exports.len()
484            + self.type_only_dependencies.len()
485            + self.test_only_dependencies.len()
486            + self.circular_dependencies.len()
487            + self.re_export_cycles.len()
488            + self.boundary_violations.len()
489            + self.boundary_coverage_violations.len()
490            + self.boundary_call_violations.len()
491            + self.policy_violations.len()
492            + self.stale_suppressions.len()
493            + self.unused_catalog_entries.len()
494            + self.empty_catalog_groups.len()
495            + self.unresolved_catalog_references.len()
496            + self.unused_dependency_overrides.len()
497            + self.misconfigured_dependency_overrides.len()
498            + self.invalid_client_exports.len()
499            + self.mixed_client_server_barrels.len()
500            + self.misplaced_directives.len()
501            + self.unprovided_injects.len()
502            + self.unrendered_components.len()
503            + self.route_collisions.len()
504            + self.dynamic_segment_name_conflicts.len()
505            + self.unused_component_props.len()
506            + self.unused_component_emits.len()
507            + self.unused_server_actions.len()
508            + self.unused_load_data_keys.len()
509    }
510
511    /// Whether any issues were found.
512    #[must_use]
513    pub const fn has_issues(&self) -> bool {
514        self.total_issues() > 0
515    }
516
517    /// Merge `other` into `self`, taking the union of every field.
518    ///
519    /// This is the single canonical way to combine two [`AnalysisResults`]
520    /// (the LSP merges per-project-root results through it). The method
521    /// exhaustively destructures `Self`, so adding a field to the struct
522    /// becomes a compile error here instead of a silently-dropped field. See
523    /// issue #444.
524    ///
525    /// Every `Vec` field is appended (callers dedup downstream where needed,
526    /// e.g. the LSP's identity-keyed `dedup_results`). `suppression_count`
527    /// sums; `entry_point_summary` keeps `self`'s value when present and
528    /// otherwise adopts `other`'s.
529    pub fn merge_into(&mut self, other: Self) {
530        let Self {
531            unused_files,
532            unused_exports,
533            unused_types,
534            private_type_leaks,
535            unused_dependencies,
536            unused_dev_dependencies,
537            unused_optional_dependencies,
538            unused_enum_members,
539            unused_class_members,
540            unused_store_members,
541            unresolved_imports,
542            unlisted_dependencies,
543            duplicate_exports,
544            type_only_dependencies,
545            test_only_dependencies,
546            circular_dependencies,
547            re_export_cycles,
548            boundary_violations,
549            boundary_coverage_violations,
550            boundary_call_violations,
551            policy_violations,
552            stale_suppressions,
553            unused_catalog_entries,
554            empty_catalog_groups,
555            unresolved_catalog_references,
556            unused_dependency_overrides,
557            misconfigured_dependency_overrides,
558            invalid_client_exports,
559            mixed_client_server_barrels,
560            misplaced_directives,
561            unprovided_injects,
562            unrendered_components,
563            route_collisions,
564            dynamic_segment_name_conflicts,
565            unused_component_props,
566            unused_component_emits,
567            unused_server_actions,
568            unused_load_data_keys,
569            unused_load_data_keys_global_abstain,
570            prop_drilling_chains,
571            thin_wrappers,
572            duplicate_prop_shapes,
573            suppression_count,
574            active_suppressions,
575            feature_flags,
576            security_findings,
577            security_unresolved_edge_files,
578            security_unresolved_callee_sites,
579            security_unresolved_callee_diagnostics,
580            export_usages,
581            entry_point_summary,
582            render_fan_in,
583        } = other;
584
585        self.unused_files.extend(unused_files);
586        self.unused_exports.extend(unused_exports);
587        self.unused_types.extend(unused_types);
588        self.private_type_leaks.extend(private_type_leaks);
589        self.unused_dependencies.extend(unused_dependencies);
590        self.unused_dev_dependencies.extend(unused_dev_dependencies);
591        self.unused_optional_dependencies
592            .extend(unused_optional_dependencies);
593        self.unused_enum_members.extend(unused_enum_members);
594        self.unused_class_members.extend(unused_class_members);
595        self.unused_store_members.extend(unused_store_members);
596        self.unresolved_imports.extend(unresolved_imports);
597        self.unlisted_dependencies.extend(unlisted_dependencies);
598        self.duplicate_exports.extend(duplicate_exports);
599        self.type_only_dependencies.extend(type_only_dependencies);
600        self.test_only_dependencies.extend(test_only_dependencies);
601        self.circular_dependencies.extend(circular_dependencies);
602        self.re_export_cycles.extend(re_export_cycles);
603        self.boundary_violations.extend(boundary_violations);
604        self.boundary_coverage_violations
605            .extend(boundary_coverage_violations);
606        self.boundary_call_violations
607            .extend(boundary_call_violations);
608        self.policy_violations.extend(policy_violations);
609        self.stale_suppressions.extend(stale_suppressions);
610        self.unused_catalog_entries.extend(unused_catalog_entries);
611        self.empty_catalog_groups.extend(empty_catalog_groups);
612        self.unresolved_catalog_references
613            .extend(unresolved_catalog_references);
614        self.unused_dependency_overrides
615            .extend(unused_dependency_overrides);
616        self.misconfigured_dependency_overrides
617            .extend(misconfigured_dependency_overrides);
618        self.invalid_client_exports.extend(invalid_client_exports);
619        self.mixed_client_server_barrels
620            .extend(mixed_client_server_barrels);
621        self.misplaced_directives.extend(misplaced_directives);
622        self.unprovided_injects.extend(unprovided_injects);
623        self.unrendered_components.extend(unrendered_components);
624        self.route_collisions.extend(route_collisions);
625        self.dynamic_segment_name_conflicts
626            .extend(dynamic_segment_name_conflicts);
627        self.unused_component_props.extend(unused_component_props);
628        self.unused_component_emits.extend(unused_component_emits);
629        self.unused_server_actions.extend(unused_server_actions);
630        self.unused_load_data_keys.extend(unused_load_data_keys);
631        self.unused_load_data_keys_global_abstain |= unused_load_data_keys_global_abstain;
632        self.prop_drilling_chains.extend(prop_drilling_chains);
633        self.thin_wrappers.extend(thin_wrappers);
634        self.duplicate_prop_shapes.extend(duplicate_prop_shapes);
635        self.feature_flags.extend(feature_flags);
636        self.security_findings.extend(security_findings);
637        self.security_unresolved_edge_files += security_unresolved_edge_files;
638        self.security_unresolved_callee_sites += security_unresolved_callee_sites;
639        self.security_unresolved_callee_diagnostics
640            .extend(security_unresolved_callee_diagnostics);
641        self.export_usages.extend(export_usages);
642        self.active_suppressions.extend(active_suppressions);
643        self.suppression_count += suppression_count;
644        if self.entry_point_summary.is_none() {
645            self.entry_point_summary = entry_point_summary;
646        }
647        if self.render_fan_in.is_none() {
648            self.render_fan_in = render_fan_in;
649        }
650    }
651
652    /// Sort all result arrays for deterministic output ordering.
653    ///
654    /// Parallel collection (rayon, `FxHashMap` iteration) does not guarantee
655    /// insertion order, so the same project can produce different orderings
656    /// across runs. This method canonicalises every result list by sorting on
657    /// (path, line, col, name) so that JSON/SARIF/human output is stable.
658    pub fn sort(&mut self) {
659        self.sort_core_findings();
660        self.sort_dependency_findings();
661        self.sort_graph_findings();
662        self.sort_catalog_findings();
663        self.sort_metadata_findings();
664        self.sort_export_usages();
665    }
666
667    fn sort_core_findings(&mut self) {
668        self.sort_core_declaration_findings();
669        self.sort_core_member_findings();
670        self.sort_core_framework_findings();
671        self.sort_core_route_and_load_findings();
672    }
673
674    fn sort_core_declaration_findings(&mut self) {
675        self.unused_files
676            .sort_by(|a, b| a.file.path.cmp(&b.file.path));
677
678        self.unused_exports.sort_by(|a, b| {
679            a.export
680                .path
681                .cmp(&b.export.path)
682                .then(a.export.line.cmp(&b.export.line))
683                .then(a.export.export_name.cmp(&b.export.export_name))
684        });
685
686        self.unused_types.sort_by(|a, b| {
687            a.export
688                .path
689                .cmp(&b.export.path)
690                .then(a.export.line.cmp(&b.export.line))
691                .then(a.export.export_name.cmp(&b.export.export_name))
692        });
693
694        self.private_type_leaks.sort_by(|a, b| {
695            a.leak
696                .path
697                .cmp(&b.leak.path)
698                .then(a.leak.line.cmp(&b.leak.line))
699                .then(a.leak.export_name.cmp(&b.leak.export_name))
700                .then(a.leak.type_name.cmp(&b.leak.type_name))
701        });
702
703        self.unused_dependencies.sort_by(|a, b| {
704            a.dep
705                .path
706                .cmp(&b.dep.path)
707                .then(a.dep.line.cmp(&b.dep.line))
708                .then(a.dep.package_name.cmp(&b.dep.package_name))
709        });
710
711        self.unused_dev_dependencies.sort_by(|a, b| {
712            a.dep
713                .path
714                .cmp(&b.dep.path)
715                .then(a.dep.line.cmp(&b.dep.line))
716                .then(a.dep.package_name.cmp(&b.dep.package_name))
717        });
718
719        self.unused_optional_dependencies.sort_by(|a, b| {
720            a.dep
721                .path
722                .cmp(&b.dep.path)
723                .then(a.dep.line.cmp(&b.dep.line))
724                .then(a.dep.package_name.cmp(&b.dep.package_name))
725        });
726    }
727
728    fn sort_core_member_findings(&mut self) {
729        self.unused_enum_members.sort_by(|a, b| {
730            a.member
731                .path
732                .cmp(&b.member.path)
733                .then(a.member.line.cmp(&b.member.line))
734                .then(a.member.parent_name.cmp(&b.member.parent_name))
735                .then(a.member.member_name.cmp(&b.member.member_name))
736        });
737
738        self.unused_class_members.sort_by(|a, b| {
739            a.member
740                .path
741                .cmp(&b.member.path)
742                .then(a.member.line.cmp(&b.member.line))
743                .then(a.member.parent_name.cmp(&b.member.parent_name))
744                .then(a.member.member_name.cmp(&b.member.member_name))
745        });
746
747        self.unused_store_members.sort_by(|a, b| {
748            a.member
749                .path
750                .cmp(&b.member.path)
751                .then(a.member.line.cmp(&b.member.line))
752                .then(a.member.parent_name.cmp(&b.member.parent_name))
753                .then(a.member.member_name.cmp(&b.member.member_name))
754        });
755
756        self.unresolved_imports.sort_by(|a, b| {
757            a.import
758                .path
759                .cmp(&b.import.path)
760                .then(a.import.line.cmp(&b.import.line))
761                .then(a.import.col.cmp(&b.import.col))
762                .then(a.import.specifier.cmp(&b.import.specifier))
763        });
764    }
765
766    fn sort_core_framework_findings(&mut self) {
767        self.invalid_client_exports.sort_by(|a, b| {
768            a.export
769                .path
770                .cmp(&b.export.path)
771                .then(a.export.line.cmp(&b.export.line))
772                .then(a.export.export_name.cmp(&b.export.export_name))
773        });
774
775        self.mixed_client_server_barrels.sort_by(|a, b| {
776            a.barrel
777                .path
778                .cmp(&b.barrel.path)
779                .then(a.barrel.line.cmp(&b.barrel.line))
780                .then(a.barrel.client_origin.cmp(&b.barrel.client_origin))
781                .then(a.barrel.server_origin.cmp(&b.barrel.server_origin))
782        });
783
784        self.misplaced_directives.sort_by(|a, b| {
785            a.directive_site
786                .path
787                .cmp(&b.directive_site.path)
788                .then(a.directive_site.line.cmp(&b.directive_site.line))
789                .then(a.directive_site.col.cmp(&b.directive_site.col))
790                .then(a.directive_site.directive.cmp(&b.directive_site.directive))
791        });
792
793        self.unprovided_injects.sort_by(|a, b| {
794            a.inject
795                .path
796                .cmp(&b.inject.path)
797                .then(a.inject.line.cmp(&b.inject.line))
798                .then(a.inject.col.cmp(&b.inject.col))
799                .then(a.inject.key_name.cmp(&b.inject.key_name))
800        });
801
802        self.unrendered_components.sort_by(|a, b| {
803            a.component
804                .path
805                .cmp(&b.component.path)
806                .then(a.component.line.cmp(&b.component.line))
807                .then(a.component.col.cmp(&b.component.col))
808                .then(a.component.component_name.cmp(&b.component.component_name))
809        });
810    }
811
812    fn sort_core_route_and_load_findings(&mut self) {
813        self.route_collisions.sort_by(|a, b| {
814            a.collision
815                .path
816                .cmp(&b.collision.path)
817                .then(a.collision.url.cmp(&b.collision.url))
818        });
819
820        self.dynamic_segment_name_conflicts.sort_by(|a, b| {
821            a.conflict
822                .path
823                .cmp(&b.conflict.path)
824                .then(a.conflict.position.cmp(&b.conflict.position))
825        });
826
827        self.unused_component_props.sort_by(|a, b| {
828            a.prop
829                .path
830                .cmp(&b.prop.path)
831                .then(a.prop.line.cmp(&b.prop.line))
832                .then(a.prop.prop_name.cmp(&b.prop.prop_name))
833        });
834
835        self.unused_component_emits.sort_by(|a, b| {
836            a.emit
837                .path
838                .cmp(&b.emit.path)
839                .then(a.emit.line.cmp(&b.emit.line))
840                .then(a.emit.emit_name.cmp(&b.emit.emit_name))
841        });
842
843        self.unused_server_actions.sort_by(|a, b| {
844            a.action
845                .path
846                .cmp(&b.action.path)
847                .then(a.action.line.cmp(&b.action.line))
848                .then(a.action.col.cmp(&b.action.col))
849                .then(a.action.action_name.cmp(&b.action.action_name))
850        });
851
852        self.unused_load_data_keys.sort_by(|a, b| {
853            a.key
854                .path
855                .cmp(&b.key.path)
856                .then(a.key.line.cmp(&b.key.line))
857                .then(a.key.col.cmp(&b.key.col))
858                .then(a.key.key_name.cmp(&b.key.key_name))
859        });
860    }
861
862    /// Sort prop-drilling chains by their source hop (first hop): file, line,
863    /// prop, depth, for deterministic output. Split out of `sort_core_findings`
864    /// to keep that function under the unit-size ceiling.
865    fn sort_prop_drilling_chains(&mut self) {
866        self.prop_drilling_chains.sort_by(|a, b| {
867            let a_src = a.chain.hops.first();
868            let b_src = b.chain.hops.first();
869            let a_file = a_src.map(|h| &h.file);
870            let b_file = b_src.map(|h| &h.file);
871            a_file
872                .cmp(&b_file)
873                .then_with(|| a_src.map(|h| h.line).cmp(&b_src.map(|h| h.line)))
874                .then(a.chain.prop.cmp(&b.chain.prop))
875                .then(a.chain.depth.cmp(&b.chain.depth))
876        });
877    }
878
879    /// Sort thin-wrapper findings by file, line, then component for
880    /// deterministic output.
881    fn sort_thin_wrappers(&mut self) {
882        self.thin_wrappers.sort_by(|a, b| {
883            a.wrapper
884                .file
885                .cmp(&b.wrapper.file)
886                .then(a.wrapper.line.cmp(&b.wrapper.line))
887                .then(a.wrapper.component.cmp(&b.wrapper.component))
888        });
889    }
890
891    /// Sort duplicate-prop-shape findings by the shared shape first (so a
892    /// group's members stay adjacent), then file, line, and component, for
893    /// deterministic output.
894    fn sort_duplicate_prop_shapes(&mut self) {
895        self.duplicate_prop_shapes.sort_by(|a, b| {
896            a.shape
897                .shape
898                .cmp(&b.shape.shape)
899                .then(a.shape.file.cmp(&b.shape.file))
900                .then(a.shape.line.cmp(&b.shape.line))
901                .then(a.shape.component.cmp(&b.shape.component))
902        });
903    }
904
905    fn sort_dependency_findings(&mut self) {
906        self.unlisted_dependencies
907            .sort_by(|a, b| a.dep.package_name.cmp(&b.dep.package_name));
908        for dep in &mut self.unlisted_dependencies {
909            dep.dep
910                .imported_from
911                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
912        }
913
914        self.duplicate_exports
915            .sort_by(|a, b| a.export.export_name.cmp(&b.export.export_name));
916        for dup in &mut self.duplicate_exports {
917            dup.export
918                .locations
919                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
920        }
921
922        self.type_only_dependencies.sort_by(|a, b| {
923            a.dep
924                .path
925                .cmp(&b.dep.path)
926                .then(a.dep.line.cmp(&b.dep.line))
927                .then(a.dep.package_name.cmp(&b.dep.package_name))
928        });
929
930        self.test_only_dependencies.sort_by(|a, b| {
931            a.dep
932                .path
933                .cmp(&b.dep.path)
934                .then(a.dep.line.cmp(&b.dep.line))
935                .then(a.dep.package_name.cmp(&b.dep.package_name))
936        });
937    }
938
939    fn sort_graph_findings(&mut self) {
940        self.circular_dependencies.sort_by(|a, b| {
941            a.cycle
942                .files
943                .cmp(&b.cycle.files)
944                .then(a.cycle.length.cmp(&b.cycle.length))
945        });
946
947        self.re_export_cycles
948            .sort_by(|a, b| a.cycle.files.cmp(&b.cycle.files));
949
950        self.boundary_violations.sort_by(|a, b| {
951            a.violation
952                .from_path
953                .cmp(&b.violation.from_path)
954                .then(a.violation.line.cmp(&b.violation.line))
955                .then(a.violation.col.cmp(&b.violation.col))
956                .then(a.violation.to_path.cmp(&b.violation.to_path))
957        });
958
959        self.boundary_coverage_violations.sort_by(|a, b| {
960            a.violation
961                .path
962                .cmp(&b.violation.path)
963                .then(a.violation.line.cmp(&b.violation.line))
964                .then(a.violation.col.cmp(&b.violation.col))
965        });
966
967        self.boundary_call_violations.sort_by(|a, b| {
968            a.violation
969                .path
970                .cmp(&b.violation.path)
971                .then(a.violation.line.cmp(&b.violation.line))
972                .then(a.violation.col.cmp(&b.violation.col))
973                .then(a.violation.callee.cmp(&b.violation.callee))
974        });
975
976        self.policy_violations.sort_by(|a, b| {
977            a.violation
978                .path
979                .cmp(&b.violation.path)
980                .then(a.violation.line.cmp(&b.violation.line))
981                .then(a.violation.col.cmp(&b.violation.col))
982                .then(a.violation.rule_id.cmp(&b.violation.rule_id))
983        });
984    }
985
986    fn sort_catalog_findings(&mut self) {
987        self.stale_suppressions.sort_by(|a, b| {
988            a.path
989                .cmp(&b.path)
990                .then(a.line.cmp(&b.line))
991                .then(a.col.cmp(&b.col))
992        });
993
994        self.unused_catalog_entries.sort_by(|a, b| {
995            a.entry
996                .path
997                .cmp(&b.entry.path)
998                .then_with(|| {
999                    catalog_sort_key(&a.entry.catalog_name)
1000                        .cmp(&catalog_sort_key(&b.entry.catalog_name))
1001                })
1002                .then(a.entry.catalog_name.cmp(&b.entry.catalog_name))
1003                .then(a.entry.entry_name.cmp(&b.entry.entry_name))
1004        });
1005        for finding in &mut self.unused_catalog_entries {
1006            finding.entry.hardcoded_consumers.sort();
1007            finding.entry.hardcoded_consumers.dedup();
1008        }
1009
1010        self.empty_catalog_groups.sort_by(|a, b| {
1011            a.group
1012                .path
1013                .cmp(&b.group.path)
1014                .then_with(|| {
1015                    catalog_sort_key(&a.group.catalog_name)
1016                        .cmp(&catalog_sort_key(&b.group.catalog_name))
1017                })
1018                .then(a.group.catalog_name.cmp(&b.group.catalog_name))
1019                .then(a.group.line.cmp(&b.group.line))
1020        });
1021
1022        self.unresolved_catalog_references.sort_by(|a, b| {
1023            a.reference
1024                .path
1025                .cmp(&b.reference.path)
1026                .then(a.reference.line.cmp(&b.reference.line))
1027                .then_with(|| {
1028                    catalog_sort_key(&a.reference.catalog_name)
1029                        .cmp(&catalog_sort_key(&b.reference.catalog_name))
1030                })
1031                .then(a.reference.catalog_name.cmp(&b.reference.catalog_name))
1032                .then(a.reference.entry_name.cmp(&b.reference.entry_name))
1033        });
1034        for finding in &mut self.unresolved_catalog_references {
1035            finding.reference.available_in_catalogs.sort();
1036            finding.reference.available_in_catalogs.dedup();
1037        }
1038
1039        self.unused_dependency_overrides.sort_by(|a, b| {
1040            a.entry
1041                .path
1042                .cmp(&b.entry.path)
1043                .then(a.entry.line.cmp(&b.entry.line))
1044                .then(a.entry.raw_key.cmp(&b.entry.raw_key))
1045        });
1046    }
1047
1048    fn sort_metadata_findings(&mut self) {
1049        self.sort_prop_drilling_chains();
1050        self.sort_thin_wrappers();
1051        self.sort_duplicate_prop_shapes();
1052
1053        self.misconfigured_dependency_overrides.sort_by(|a, b| {
1054            a.entry
1055                .path
1056                .cmp(&b.entry.path)
1057                .then(a.entry.line.cmp(&b.entry.line))
1058                .then(a.entry.raw_key.cmp(&b.entry.raw_key))
1059        });
1060
1061        self.feature_flags.sort_by(|a, b| {
1062            a.path
1063                .cmp(&b.path)
1064                .then(a.line.cmp(&b.line))
1065                .then(a.flag_name.cmp(&b.flag_name))
1066        });
1067
1068        self.security_unresolved_callee_diagnostics.sort_by(|a, b| {
1069            a.path
1070                .cmp(&b.path)
1071                .then(a.line.cmp(&b.line))
1072                .then(a.col.cmp(&b.col))
1073                .then(a.reason.cmp(&b.reason))
1074                .then(a.expression_kind.cmp(&b.expression_kind))
1075        });
1076    }
1077
1078    fn sort_export_usages(&mut self) {
1079        for usage in &mut self.export_usages {
1080            usage.reference_locations.sort_by(|a, b| {
1081                a.path
1082                    .cmp(&b.path)
1083                    .then(a.line.cmp(&b.line))
1084                    .then(a.col.cmp(&b.col))
1085            });
1086        }
1087        self.export_usages.sort_by(|a, b| {
1088            a.path
1089                .cmp(&b.path)
1090                .then(a.line.cmp(&b.line))
1091                .then(a.export_name.cmp(&b.export_name))
1092        });
1093    }
1094}
1095
1096/// Sort key for catalog names: the default catalog ("default") sorts before any named catalog.
1097fn catalog_sort_key(name: &str) -> (u8, &str) {
1098    if name == "default" {
1099        (0, name)
1100    } else {
1101        (1, name)
1102    }
1103}
1104
1105/// A file that is not reachable from any entry point.
1106#[derive(Debug, Clone, Serialize)]
1107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1108pub struct UnusedFile {
1109    /// Absolute path to the unused file.
1110    #[serde(serialize_with = "serde_path::serialize")]
1111    pub path: PathBuf,
1112}
1113
1114/// An export that is never imported by other modules.
1115#[derive(Debug, Clone, Serialize)]
1116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1117pub struct UnusedExport {
1118    /// File containing the unused export.
1119    #[serde(serialize_with = "serde_path::serialize")]
1120    pub path: PathBuf,
1121    /// Name of the unused export.
1122    pub export_name: String,
1123    /// Whether this is a type-only export.
1124    pub is_type_only: bool,
1125    /// 1-based line number of the export.
1126    pub line: u32,
1127    /// 0-based byte column offset.
1128    pub col: u32,
1129    /// Byte offset into the source file (used by the fix command).
1130    pub span_start: u32,
1131    /// Whether this finding comes from a barrel/index re-export rather than the source definition.
1132    pub is_re_export: bool,
1133}
1134
1135/// A public export signature that references a same-file private type.
1136#[derive(Debug, Clone, Serialize)]
1137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1138pub struct PrivateTypeLeak {
1139    /// File containing the exported symbol.
1140    #[serde(serialize_with = "serde_path::serialize")]
1141    pub path: PathBuf,
1142    /// Export whose public signature leaks the private type.
1143    pub export_name: String,
1144    /// Private type referenced by the public signature.
1145    pub type_name: String,
1146    /// 1-based line number of the leaking type reference.
1147    pub line: u32,
1148    /// 0-based byte column offset.
1149    pub col: u32,
1150    /// Byte offset of the type reference.
1151    pub span_start: u32,
1152}
1153
1154/// A `"use client"` file that exports a Next.js server-only / route-segment
1155/// config name. Next.js rejects this combination at build time; fallow catches
1156/// it statically before the build runs.
1157#[derive(Debug, Clone, Serialize)]
1158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1159pub struct InvalidClientExport {
1160    /// File carrying the `"use client"` directive and the illegal export.
1161    #[serde(serialize_with = "serde_path::serialize")]
1162    pub path: PathBuf,
1163    /// Name of the server-only / route-config export that is illegal in a
1164    /// client file (e.g. `metadata`, `generateMetadata`, `revalidate`, `GET`).
1165    pub export_name: String,
1166    /// The file-level directive that makes the export illegal. Always
1167    /// `"use client"` today; carried so the message can name it verbatim.
1168    pub directive: String,
1169    /// 1-based line number of the export.
1170    pub line: u32,
1171    /// 0-based byte column offset of the export.
1172    pub col: u32,
1173}
1174
1175/// A barrel file that re-exports BOTH a `"use client"` origin module AND a
1176/// server-only origin module. Importing one name from such a barrel drags the
1177/// other's directive context across the React Server Components boundary (the
1178/// Next.js App Router footgun); fallow catches it statically.
1179#[derive(Debug, Clone, Serialize)]
1180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1181pub struct MixedClientServerBarrel {
1182    /// The barrel file re-exporting both a client and a server-only origin.
1183    #[serde(serialize_with = "serde_path::serialize")]
1184    pub path: PathBuf,
1185    /// The `"use client"` origin's relative path or specifier as written in the
1186    /// barrel's offending re-export.
1187    pub client_origin: String,
1188    /// The server-only origin's relative path or specifier as written in the
1189    /// barrel's offending re-export.
1190    pub server_origin: String,
1191    /// 1-based line number of the barrel's first offending re-export.
1192    pub line: u32,
1193    /// 0-based byte column offset of the barrel's first offending re-export.
1194    pub col: u32,
1195}
1196
1197/// A `"use client"` / `"use server"` directive written as an expression
1198/// statement after a non-directive statement (an import, a const). The RSC
1199/// bundler only honors a directive in the leading prologue, so once any
1200/// statement precedes it the string is parsed as an ordinary expression and
1201/// silently ignored: the intended client/server boundary never takes effect.
1202/// The fix is to move the directive to the very top of the file.
1203#[derive(Debug, Clone, Serialize)]
1204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1205pub struct MisplacedDirective {
1206    /// The file carrying the misplaced directive.
1207    #[serde(serialize_with = "serde_path::serialize")]
1208    pub path: PathBuf,
1209    /// The directive string as written, either `"use client"` or
1210    /// `"use server"` (without the surrounding quotes).
1211    pub directive: String,
1212    /// 1-based line number of the misplaced directive statement.
1213    pub line: u32,
1214    /// 0-based byte column offset of the misplaced directive statement.
1215    pub col: u32,
1216}
1217
1218/// A Vue `inject(KEY)` or Svelte `getContext(KEY)` whose symbol KEY is
1219/// `provide`/`setContext`'d nowhere in the analyzed project. The key is a
1220/// symbol with cross-file identity, so an unmatched key is a real dead-half DI
1221/// link: at runtime the inject returns `undefined`, surfaced only at render.
1222/// The fix is binary: provide the key somewhere, or remove the dead inject.
1223#[derive(Debug, Clone, Serialize)]
1224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1225pub struct UnprovidedInject {
1226    /// The file carrying the orphan inject / getContext call.
1227    #[serde(serialize_with = "serde_path::serialize")]
1228    pub path: PathBuf,
1229    /// The injected key identifier as written at the call site.
1230    pub key_name: String,
1231    /// Which framework's DI API this came from: `"vue"` or `"svelte"`.
1232    pub framework: String,
1233    /// 1-based line number of the inject / getContext call.
1234    pub line: u32,
1235    /// 0-based byte column offset of the inject / getContext call.
1236    pub col: u32,
1237}
1238
1239/// A Next.js Server Action (an export of a `"use server"` file) that no code in
1240/// the analyzed project references: no import-and-call, no `action={fn}` JSX
1241/// binding, no `<form action={fn}>`. This is the cross-graph "declared but zero
1242/// consumers" direction, reclassified out of `unused-export` for `"use server"`
1243/// files so the finding carries the action-specific signal. It does NOT mean the
1244/// endpoint is unreachable: Next still registers the action id, so it stays
1245/// POST-able. It means no project code calls it (likely forgotten / dead, and a
1246/// candidate for removal to shrink surface area).
1247#[derive(Debug, Clone, Serialize)]
1248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1249pub struct UnusedServerAction {
1250    /// The `"use server"` file that exports the unreferenced action.
1251    #[serde(serialize_with = "serde_path::serialize")]
1252    pub path: PathBuf,
1253    /// The exported action name as written, or `"default"` for a default export.
1254    pub action_name: String,
1255    /// 1-based line number of the export.
1256    pub line: u32,
1257    /// 0-based byte column offset of the export.
1258    pub col: u32,
1259}
1260
1261/// A SvelteKit `+page.{ts,server.ts,js,server.js}` `load()` return-object key
1262/// read by no consumer: not off the sibling `+page.svelte`'s `data.<key>`, nor
1263/// project-wide via `page.data.<key>` / `$page.data.<key>`. A dead load key runs
1264/// a real server/DB fetch cost on every request for data nothing renders. The
1265/// fix is a human call (delete the key, or wire a consumer): a load fetch may
1266/// have side effects, so there is no safe auto-fix.
1267#[derive(Debug, Clone, Serialize)]
1268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1269pub struct UnusedLoadDataKey {
1270    /// The producer `+page.{ts,server.ts,js,server.js}` file declaring the key.
1271    #[serde(serialize_with = "serde_path::serialize")]
1272    pub path: PathBuf,
1273    /// The returned-object key name read by no consumer.
1274    pub key_name: String,
1275    /// 1-based line number of the key in the return object.
1276    pub line: u32,
1277    /// 0-based byte column offset of the key.
1278    pub col: u32,
1279    /// The route directory relative to the project root (`src/routes/blog`), for
1280    /// agent remediation and per-route trend aggregation. `None` when not
1281    /// determinable.
1282    #[serde(default, skip_serializing_if = "Option::is_none")]
1283    pub route_dir: Option<String>,
1284}
1285
1286/// A Vue/Svelte single-file component (the default export of a `.vue`/`.svelte`
1287/// file) that is reachable in the module graph but rendered NOWHERE in the
1288/// project: no `<Tag>`, no `:is`/`this=` binding, no `components`/`app.component`
1289/// registration, no `h()`/auto-import use, and no script value-read. It survives
1290/// `unused-file` (a barrel re-export keeps it reachable) and `unused-export`
1291/// (the re-export counts as a use), yet no file actually instantiates it.
1292#[derive(Debug, Clone, Serialize)]
1293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1294pub struct UnrenderedComponent {
1295    /// The component file that is reachable but rendered nowhere.
1296    #[serde(serialize_with = "serde_path::serialize")]
1297    pub path: PathBuf,
1298    /// The component name (the `.vue`/`.svelte` file stem, PascalCase).
1299    pub component_name: String,
1300    /// Which framework this component belongs to: `"vue"` or `"svelte"`.
1301    pub framework: String,
1302    /// A barrel/file that re-exports this component, kept for the remediation
1303    /// trace ("reachable via X, rendered nowhere"). Absolute in memory,
1304    /// serialized workspace-relative (like `path`); `None` when not determinable.
1305    #[serde(
1306        serialize_with = "serde_path::serialize_option",
1307        skip_serializing_if = "Option::is_none"
1308    )]
1309    pub reachable_via: Option<PathBuf>,
1310    /// 1-based line number of the component (the file head; SFCs have no explicit
1311    /// default-export statement).
1312    pub line: u32,
1313    /// 0-based byte column offset.
1314    pub col: u32,
1315}
1316
1317/// A Vue `<script setup>` `defineProps` declared prop that is referenced NOWHERE
1318/// inside its own single-file component (neither `<script>` nor `<template>`).
1319/// Single-file finding, zero-FP doctrine: the whole file abstains on any
1320/// fallthrough / expose / model / unharvestable-type signal.
1321#[derive(Debug, Clone, Serialize)]
1322#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1323pub struct UnusedComponentProp {
1324    /// The `.vue` SFC declaring the unused prop.
1325    #[serde(serialize_with = "serde_path::serialize")]
1326    pub path: PathBuf,
1327    /// The component name (the `.vue` file stem).
1328    pub component_name: String,
1329    /// The declared prop name that is never referenced.
1330    pub prop_name: String,
1331    /// 1-based line number of the prop declaration.
1332    pub line: u32,
1333    /// 0-based byte column offset of the prop declaration.
1334    pub col: u32,
1335}
1336
1337/// A Vue `<script setup>` `defineEmits` declared event that is EMITTED nowhere
1338/// inside its own single-file component (no `emit('<name>')` call). Single-file
1339/// finding, zero-FP doctrine: the whole file abstains on any
1340/// unharvestable / dynamic-emit / whole-object-use / `defineModel` signal.
1341#[derive(Debug, Clone, Serialize)]
1342#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1343pub struct UnusedComponentEmit {
1344    /// The `.vue` SFC declaring the unused emit.
1345    #[serde(serialize_with = "serde_path::serialize")]
1346    pub path: PathBuf,
1347    /// The component name (the `.vue` file stem).
1348    pub component_name: String,
1349    /// The declared emit event name that is never emitted.
1350    pub emit_name: String,
1351    /// 1-based line number of the emit declaration.
1352    pub line: u32,
1353    /// 0-based byte column offset of the emit declaration.
1354    pub col: u32,
1355}
1356
1357/// One hop in a prop-drilling chain: a component that received the prop and
1358/// passed it along (or, at the chain ends, the source that owns it and the
1359/// consumer that substantively reads it).
1360#[derive(Debug, Clone, Serialize)]
1361#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1362pub struct PropDrillHop {
1363    /// The file containing this hop's component.
1364    #[serde(serialize_with = "serde_path::serialize")]
1365    pub file: PathBuf,
1366    /// 1-based line of the component definition (or the prop declaration at the
1367    /// source hop). Anchors a jump-to-source for the agent.
1368    pub line: u32,
1369    /// The component name at this hop.
1370    pub component: String,
1371}
1372
1373/// A located prop-drilling chain: a received prop forwarded unchanged through
1374/// `>= N` intermediate pass-through components, each of which only re-passes it,
1375/// until a component that substantively consumes it. The high-confidence signal
1376/// is "the received identifier is used ONLY as the root of forwarded child-JSX
1377/// attribute values", not the attribute name matching. Health signal (rule
1378/// defaults to `off`, opt-in): a small capped penalty plus a `health --hotspots`
1379/// surface, and located per-chain records so CI / an agent can act ("colocate or
1380/// lift to context at hop B"). Zero-FP doctrine: any spread / `cloneElement` /
1381/// element-as-prop / render-prop / context-provider / dynamic shape in the path
1382/// abstains the whole chain.
1383#[derive(Debug, Clone, Serialize)]
1384#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1385pub struct PropDrillingChain {
1386    /// The drilled prop name as declared at the chain SOURCE.
1387    pub prop: String,
1388    /// The chain depth = the number of components the prop is forwarded THROUGH
1389    /// (source + intermediates + consumer = `hops.len()`). Always `>= N`.
1390    pub depth: u32,
1391    /// The ordered hop trail from source to consumer. The first hop owns the
1392    /// prop, the middle hops are pass-throughs, the last hop consumes it. The
1393    /// finding anchor is the first hop (`path` / `line` for suppression + CI).
1394    pub hops: Vec<PropDrillHop>,
1395}
1396
1397/// A located thin-wrapper / passthrough component: a React/Preact component
1398/// whose entire body is `return <Child {...props}/>` (a single spread-forwarded
1399/// child render, no host wrapper, no own value-add). It is pure structural
1400/// indirection, a CANDIDATE for inlining at call sites or deleting. Health
1401/// signal (rule defaults to `off`, opt-in): never a correctness error. Zero-FP
1402/// doctrine: `forwardRef` / `memo` / exported / context-provider /
1403/// `cloneElement` / render-prop / named-attr / unresolved-child wrappers all
1404/// abstain (each is an intentional indirection or unprovable shape).
1405#[derive(Debug, Clone, Serialize)]
1406#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1407pub struct ThinWrapper {
1408    /// The file containing the wrapper component.
1409    #[serde(serialize_with = "serde_path::serialize")]
1410    pub file: PathBuf,
1411    /// 1-based line of the wrapper component definition (the finding anchor for
1412    /// jump-to-source and line-level suppression).
1413    pub line: u32,
1414    /// The wrapper component name.
1415    pub component: String,
1416    /// The single child component the wrapper forwards its props to (as written
1417    /// at the render site).
1418    pub child_component: String,
1419}
1420
1421/// One member of a duplicate-prop-shape group: the OTHER components that share
1422/// the same significant prop-name set, listed in each member's
1423/// `sharing_components`. Path-sorted for stable output. A located reference (no
1424/// `shape`, which is carried once on the owning [`DuplicatePropShape`]).
1425#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1426#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1427pub struct DuplicatePropShapeMember {
1428    /// The file containing the sibling component.
1429    #[serde(serialize_with = "serde_path::serialize")]
1430    pub file: PathBuf,
1431    /// 1-based line of the sibling component definition.
1432    pub line: u32,
1433    /// The sibling component name.
1434    pub component: String,
1435}
1436
1437/// A React/Preact component that participates in a duplicate-prop-shape GROUP:
1438/// three or more distinct components across two or more files whose
1439/// statically-harvested, fully-known prop NAME set is byte-for-byte IDENTICAL
1440/// after excluding a fixed denylist of ubiquitous DOM / render-passthrough prop
1441/// names, with the REMAINING significant set holding four or more members. This
1442/// is a structural-refactor health signal (extract a shared `Props` type or a
1443/// base component), never a correctness error and never an auto-fix. One finding
1444/// is emitted per participating component; `sharing_components` lists the other
1445/// members of the same group. Health signal: the rule defaults to `off`
1446/// (opt-in), so this is dormant until enabled. Exact full-set identity only: a
1447/// superset / subset relationship does NOT group (so the finding always fits one
1448/// extracted shared type).
1449#[derive(Debug, Clone, Serialize)]
1450#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1451pub struct DuplicatePropShape {
1452    /// The file containing this component.
1453    #[serde(serialize_with = "serde_path::serialize")]
1454    pub file: PathBuf,
1455    /// 1-based line of this component definition (the finding anchor for
1456    /// jump-to-source and line-level suppression).
1457    pub line: u32,
1458    /// This component name.
1459    pub component: String,
1460    /// The shared SIGNIFICANT prop-name set (sorted, denylist-stripped). The
1461    /// unit being grouped; identical across every member of the group.
1462    pub shape: Vec<String>,
1463    /// The total number of components in this group (this one plus every
1464    /// sibling).
1465    pub group_size: u32,
1466    /// The OTHER components sharing this exact prop shape (path-sorted). A
1467    /// file-level-suppressed member drops from its own finding but still appears
1468    /// here, because the group is real regardless of suppression.
1469    pub sharing_components: Vec<DuplicatePropShapeMember>,
1470}
1471
1472/// Two or more Next.js App Router route files that resolve to the SAME URL
1473/// within one app-root. Next.js fails the build ("You cannot have two parallel
1474/// pages that resolve to the same path"); fallow catches it statically and
1475/// names every colliding file at once. One finding is emitted per colliding
1476/// file; `conflicting_paths` lists the sibling files that share the URL.
1477#[derive(Debug, Clone, Serialize)]
1478#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1479pub struct RouteCollision {
1480    /// This colliding route file (a `page` or `route` leaf).
1481    #[serde(serialize_with = "serde_path::serialize")]
1482    pub path: PathBuf,
1483    /// The URL pathname this file resolves to within its app-root, after
1484    /// stripping route groups `(x)` and parallel-slot `@slot` prefixes (e.g.
1485    /// `/about`, `/api/health`, `/blog/:slug`).
1486    pub url: String,
1487    /// The other route files that resolve to the same URL within the same
1488    /// app-root. Path-sorted for stable output / fingerprints.
1489    #[serde(serialize_with = "serde_path::serialize_vec")]
1490    pub conflicting_paths: Vec<PathBuf>,
1491    /// 1-based line number (file-level finding, always 1).
1492    pub line: u32,
1493    /// 0-based byte column offset (file-level finding, always 0).
1494    pub col: u32,
1495}
1496
1497/// Two or more sibling dynamic route segments at the SAME App Router tree
1498/// position using different param spellings (`[id]` vs `[slug]`, or `[...x]`
1499/// vs `[[...x]]`). Next.js throws "You cannot use different slug names for the
1500/// same dynamic path" at dev / production RUNTIME when the position is hit;
1501/// `next build` does NOT catch it, so fallow's static catch surfaces a route
1502/// that would otherwise pass CI and crash at request time. One finding is
1503/// emitted per involved file.
1504#[derive(Debug, Clone, Serialize)]
1505#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1506pub struct DynamicSegmentNameConflict {
1507    /// This route file living under one of the conflicting dynamic segments.
1508    #[serde(serialize_with = "serde_path::serialize")]
1509    pub path: PathBuf,
1510    /// The tree position (parent URL after group/slot normalization) where the
1511    /// dynamic segments conflict, e.g. `/shop` for `/shop/[id]` vs
1512    /// `/shop/[slug]`. The app-root prefix is stripped.
1513    pub position: String,
1514    /// The distinct conflicting dynamic-segment spellings at this position, as
1515    /// written (e.g. `["[id]", "[slug]"]`). Sorted for stable output.
1516    pub conflicting_segments: Vec<String>,
1517    /// The other route files at the same position under a conflicting dynamic
1518    /// segment. Path-sorted for stable output / fingerprints.
1519    #[serde(serialize_with = "serde_path::serialize_vec")]
1520    pub conflicting_paths: Vec<PathBuf>,
1521    /// 1-based line number (file-level finding, always 1).
1522    pub line: u32,
1523    /// 0-based byte column offset (file-level finding, always 0).
1524    pub col: u32,
1525}
1526
1527/// A dependency that is listed in package.json but never imported.
1528#[derive(Debug, Clone, Serialize)]
1529#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1530pub struct UnusedDependency {
1531    /// Package name, including internal workspace package names.
1532    pub package_name: String,
1533    /// Whether this is in `dependencies`, `devDependencies`, or `optionalDependencies`.
1534    pub location: DependencyLocation,
1535    /// Path to the package.json where this dependency is listed.
1536    /// For root deps this is `<root>/package.json`, for workspace deps it is `<ws>/package.json`.
1537    #[serde(serialize_with = "serde_path::serialize")]
1538    pub path: PathBuf,
1539    /// 1-based line number of the dependency entry in package.json.
1540    pub line: u32,
1541    /// Workspace roots that import this package even though the declaring workspace does not.
1542    #[serde(
1543        serialize_with = "serde_path::serialize_vec",
1544        skip_serializing_if = "Vec::is_empty"
1545    )]
1546    #[cfg_attr(feature = "schema", schemars(default))]
1547    pub used_in_workspaces: Vec<PathBuf>,
1548}
1549
1550/// Where in package.json a dependency is listed.
1551///
1552/// # Examples
1553///
1554/// ```
1555/// use fallow_types::results::DependencyLocation;
1556///
1557/// // All three variants are constructible
1558/// let loc = DependencyLocation::Dependencies;
1559/// let dev = DependencyLocation::DevDependencies;
1560/// let opt = DependencyLocation::OptionalDependencies;
1561/// // Debug output includes the variant name
1562/// assert!(format!("{loc:?}").contains("Dependencies"));
1563/// assert!(format!("{dev:?}").contains("DevDependencies"));
1564/// assert!(format!("{opt:?}").contains("OptionalDependencies"));
1565/// ```
1566#[derive(Debug, Clone, Serialize)]
1567#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1568#[serde(rename_all = "camelCase")]
1569pub enum DependencyLocation {
1570    /// Listed in `dependencies`.
1571    Dependencies,
1572    /// Listed in `devDependencies`.
1573    DevDependencies,
1574    /// Listed in `optionalDependencies`.
1575    OptionalDependencies,
1576}
1577
1578/// An unused enum or class member.
1579#[derive(Debug, Clone, Serialize)]
1580#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1581pub struct UnusedMember {
1582    /// File containing the unused member.
1583    #[serde(serialize_with = "serde_path::serialize")]
1584    pub path: PathBuf,
1585    /// Name of the parent enum or class.
1586    pub parent_name: String,
1587    /// Name of the unused member.
1588    pub member_name: String,
1589    /// Whether this is an enum member, class method, or class property.
1590    pub kind: MemberKind,
1591    /// 1-based line number.
1592    pub line: u32,
1593    /// 0-based byte column offset.
1594    pub col: u32,
1595}
1596
1597/// An import that could not be resolved.
1598#[derive(Debug, Clone, Serialize)]
1599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1600pub struct UnresolvedImport {
1601    /// File containing the unresolved import.
1602    #[serde(serialize_with = "serde_path::serialize")]
1603    pub path: PathBuf,
1604    /// The import specifier that could not be resolved.
1605    pub specifier: String,
1606    /// 1-based line number.
1607    pub line: u32,
1608    /// 0-based byte column offset of the import statement.
1609    pub col: u32,
1610    /// 0-based byte column offset of the source string literal (the specifier in quotes).
1611    /// Used by the LSP to underline just the specifier, not the entire import line.
1612    pub specifier_col: u32,
1613}
1614
1615/// A dependency used in code but not listed in package.json.
1616#[derive(Debug, Clone, Serialize)]
1617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1618pub struct UnlistedDependency {
1619    /// Package name, including internal workspace package names, that is
1620    /// imported but not listed in package.json.
1621    pub package_name: String,
1622    /// Import sites where this unlisted dependency is used (file path, line, column).
1623    pub imported_from: Vec<ImportSite>,
1624}
1625
1626/// A location where an import occurs.
1627#[derive(Debug, Clone, Serialize)]
1628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1629pub struct ImportSite {
1630    /// File containing the import.
1631    #[serde(serialize_with = "serde_path::serialize")]
1632    pub path: PathBuf,
1633    /// 1-based line number.
1634    pub line: u32,
1635    /// 0-based byte column offset.
1636    pub col: u32,
1637}
1638
1639/// An export that appears multiple times across the project.
1640#[derive(Debug, Clone, Serialize)]
1641#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1642pub struct DuplicateExport {
1643    /// The duplicated export name.
1644    pub export_name: String,
1645    /// Locations where this export name appears.
1646    pub locations: Vec<DuplicateLocation>,
1647}
1648
1649/// A location where a duplicate export appears.
1650#[derive(Debug, Clone, Serialize)]
1651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1652pub struct DuplicateLocation {
1653    /// File containing the duplicate export.
1654    #[serde(serialize_with = "serde_path::serialize")]
1655    pub path: PathBuf,
1656    /// 1-based line number.
1657    pub line: u32,
1658    /// 0-based byte column offset.
1659    pub col: u32,
1660}
1661
1662/// A production dependency that is only used via type-only imports.
1663/// In production builds, type imports are erased, so this dependency
1664/// is not needed at runtime and could be moved to devDependencies.
1665#[derive(Debug, Clone, Serialize)]
1666#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1667pub struct TypeOnlyDependency {
1668    /// Production dependency that is only used via type-only imports.
1669    pub package_name: String,
1670    /// Path to the package.json where the dependency is listed.
1671    #[serde(serialize_with = "serde_path::serialize")]
1672    pub path: PathBuf,
1673    /// 1-based line number of the dependency entry in package.json.
1674    pub line: u32,
1675}
1676
1677/// The kind of security candidate. Findings are CANDIDATES for downstream agent
1678/// verification, NOT verified vulnerabilities.
1679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1681#[serde(rename_all = "kebab-case")]
1682pub enum SecurityFindingKind {
1683    /// A `"use client"` file transitively imports a module that reads a
1684    /// non-public `process.env` secret (graph-structural; bespoke, not catalogue).
1685    ClientServerLeak,
1686    /// A syntactic sink site matched against the data-driven catalogue
1687    /// (`security_matchers.toml`). Serializes `"tainted-sink"`; the CWE class is
1688    /// carried in `category` + `cwe`. ONE variant covers all catalogue categories.
1689    TaintedSink,
1690}
1691
1692/// The role a hop plays in a security finding's structural import trace.
1693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1695#[serde(rename_all = "kebab-case")]
1696pub enum TraceHopRole {
1697    /// The `"use client"` boundary file the finding is anchored on.
1698    ClientBoundary,
1699    /// A module that reads an untrusted input source such as request data,
1700    /// where the candidate's sink argument actually traces back to that read in
1701    /// the same statement (arg-level, the strong intra-module association).
1702    UntrustedSource,
1703    /// A module that merely CONTAINS an untrusted-input source somewhere and is
1704    /// import-reachable to the sink module (module-level, issue #885). This is a
1705    /// reachability signal, NOT a proven value path: the specific source value
1706    /// is not shown to reach the sink argument. Labeled distinctly from
1707    /// `UntrustedSource` so a consumer never reads a module-level hop as a
1708    /// value-flow proof.
1709    ModuleSource,
1710    /// An intermediate module on the transitive import path.
1711    Intermediate,
1712    /// The module that reads the secret.
1713    SecretSource,
1714    /// The syntactic sink site of a catalogue-driven `tainted-sink` candidate
1715    /// (the single hop the `tainted_sink` detector emits). Distinct from
1716    /// `SecretSource`, which is specific to the `client-server-leak` rule.
1717    Sink,
1718}
1719
1720/// One hop in a security finding's structural trace. Stored as an absolute path
1721/// internally; JSON serialization strips the project root via
1722/// `serde_path::serialize`.
1723#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1725pub struct TraceHop {
1726    /// File on this hop of the import chain.
1727    #[serde(serialize_with = "serde_path::serialize")]
1728    pub path: PathBuf,
1729    /// 1-based line number. Import-chain hops point at the import site; the
1730    /// terminal secret-source hop points at the source module when extraction
1731    /// does not carry a more precise member-access span.
1732    pub line: u32,
1733    /// 0-based byte column offset.
1734    pub col: u32,
1735    /// Role of this hop in the chain.
1736    pub role: TraceHopRole,
1737}
1738
1739/// How strongly the untrusted-source signal is associated with the sink, a
1740/// structured discriminator so a consumer can tier candidates without parsing
1741/// the human `evidence` prose. Present only when
1742/// [`SecurityReachability::reachable_from_untrusted_source`] is true. Neither
1743/// value proves exploitability; both are ranking signals (issue #885 doctrine:
1744/// rank, never gate).
1745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1747#[serde(rename_all = "kebab-case")]
1748pub enum TaintConfidence {
1749    /// The sink's argument traces back to a known untrusted-source read in the
1750    /// SAME statement / module (the intra-module back-trace, issue #859). The
1751    /// strong, high-value candidate: a specific source expression is implicated.
1752    ArgLevel,
1753    /// The sink merely lives in a module that is import-reachable from a module
1754    /// containing an untrusted source (issue #885). The weak candidate: only the
1755    /// module is implicated, not a specific value path to the sink argument.
1756    ModuleLevel,
1757}
1758
1759/// Graph-derived reachability ranking signal for a security candidate. Computed
1760/// from the existing module graph after detection, never proven exploitable.
1761/// Used to surface candidates that sit on a request/runtime-reachable surface,
1762/// receive same-module source evidence, or are import-reachable from an
1763/// untrusted-source module above isolated helpers or scripts.
1764///
1765/// This is a relative-ordering signal, NOT a `confidence` or `signal_strength`
1766/// score: fallow does not prove the path is exploitable.
1767#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1768#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1769pub struct SecurityReachability {
1770    /// Whether the anchor module is reachable from a runtime/application entry
1771    /// point (route handlers, server entry, framework runtime roots), the
1772    /// closest graph proxy for an external/request input surface. Code reachable
1773    /// only from test entry points does not count.
1774    pub reachable_from_entry: bool,
1775    /// Whether the anchor module is reachable over value imports from a module
1776    /// that reads a known untrusted input source. Module-level only: this does
1777    /// not prove a specific source value reaches the sink argument.
1778    #[serde(default)]
1779    pub reachable_from_untrusted_source: bool,
1780    /// Structured tier of the untrusted-source association: `arg-level` when the
1781    /// sink argument traces to a same-module source read (strong), `module-level`
1782    /// when only the module is import-reachable from a source (weak). Present
1783    /// exactly when `reachable_from_untrusted_source` is true, so a consumer can
1784    /// separate strong from weak candidates from this field alone without parsing
1785    /// the `evidence` string. Not an exploitability proof.
1786    #[serde(default, skip_serializing_if = "Option::is_none")]
1787    pub taint_confidence: Option<TaintConfidence>,
1788    /// Number of value-import hops from the untrusted-source module to the sink
1789    /// module when `reachable_from_untrusted_source` is true.
1790    #[serde(default, skip_serializing_if = "Option::is_none")]
1791    pub untrusted_source_hop_count: Option<u32>,
1792    /// Module-level import path from the untrusted-source module to the sink
1793    /// anchor. Empty when no source module reaches this candidate. The path is a
1794    /// ranking explanation, not a value-flow proof.
1795    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1796    pub untrusted_source_trace: Vec<TraceHop>,
1797    /// Number of distinct modules that transitively depend on the anchor module
1798    /// (fan-in via the graph's reverse-dependency index). A higher value means a
1799    /// wider surface: more call sites could route untrusted input into the sink.
1800    pub blast_radius: u32,
1801    /// Whether the anchor module participates in an architecture-boundary
1802    /// violation found in the same run (as the importing or imported file).
1803    /// Optional pairing: a candidate that also crosses a declared boundary is a
1804    /// stronger review target.
1805    pub crosses_boundary: bool,
1806}
1807
1808/// Dead-code cross-link attached to a security candidate when fallow's dead-code
1809/// pass reports the same anchor as removable code.
1810#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1811#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1812pub struct SecurityDeadCodeContext {
1813    /// Dead-code issue kind that matched the security candidate.
1814    pub kind: SecurityDeadCodeKind,
1815    /// Unused export name when `kind` is `unused-export`.
1816    #[serde(default, skip_serializing_if = "Option::is_none")]
1817    pub export_name: Option<String>,
1818    /// Dead-code finding line when available.
1819    #[serde(default, skip_serializing_if = "Option::is_none")]
1820    pub line: Option<u32>,
1821    /// Agent-facing guidance for deciding between deletion and hardening.
1822    pub guidance: String,
1823}
1824
1825/// Dead-code issue kind linked to a security candidate.
1826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1827#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1828#[serde(rename_all = "kebab-case")]
1829pub enum SecurityDeadCodeKind {
1830    /// The candidate's anchor file is also reported as an unused file.
1831    UnusedFile,
1832    /// The candidate's anchor sits on an unused export declaration.
1833    UnusedExport,
1834}
1835
1836/// Internal row for a security sink-shaped callee that extraction could not
1837/// flatten to a static catalogue path.
1838#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1839#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1840pub struct SecurityUnresolvedCalleeDiagnostic {
1841    /// File containing the skipped callee. Absolute internally.
1842    #[serde(serialize_with = "serde_path::serialize")]
1843    pub path: PathBuf,
1844    /// 1-based line of the skipped callee.
1845    pub line: u32,
1846    /// 0-based byte column of the skipped callee.
1847    pub col: u32,
1848    /// Why the callee could not be flattened.
1849    pub reason: SkippedSecurityCalleeReason,
1850    /// Compact syntax shape of the skipped callee.
1851    pub expression_kind: SkippedSecurityCalleeExpressionKind,
1852}
1853
1854/// The sink slot of a [`SecurityCandidate`]: a self-contained description of the
1855/// matched sink site. Echoes the finding's own span (`path`/`line`/`col`) plus
1856/// the catalogue `category`/`cwe` and the captured `callee`, so an agent can act
1857/// on `candidate.sink` in isolation (e.g. after fanning a finding out to a
1858/// sub-agent) without reading the parent finding.
1859#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1860#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1861pub struct SecurityCandidateSink {
1862    /// File of the sink site. Absolute internally; JSON strips the project root
1863    /// via `serde_path::serialize`.
1864    #[serde(serialize_with = "serde_path::serialize")]
1865    pub path: PathBuf,
1866    /// 1-based line of the sink site.
1867    pub line: u32,
1868    /// 0-based byte column of the sink site.
1869    pub col: u32,
1870    /// Catalogue category id of the sink (e.g. `"dangerous-html"`). For
1871    /// `client-server-leak` this is `None` for the secret-leak finding, and
1872    /// `Some("server-only-import")` when a `"use client"` cone reaches
1873    /// server-only code.
1874    #[serde(default, skip_serializing_if = "Option::is_none")]
1875    pub category: Option<String>,
1876    /// CWE number declared by the catalogue entry. `None` for
1877    /// `client-server-leak`; never fabricated beyond the catalogue's value.
1878    #[serde(default, skip_serializing_if = "Option::is_none")]
1879    pub cwe: Option<u32>,
1880    /// The sink callee (the dangerous function or member path, e.g.
1881    /// `"el.innerHTML"`, `"child_process.exec"`) captured by the catalogue match.
1882    /// `None` for `client-server-leak` and matches that name no callee.
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub callee: Option<String>,
1885    /// URL construction shape for SSRF and open-redirect style candidates when
1886    /// fallow can classify whether the origin is fixed or dynamic. Absent for
1887    /// non-URL sinks and unclassified URL expressions.
1888    #[serde(default, skip_serializing_if = "Option::is_none")]
1889    pub url_shape: Option<SecurityUrlShape>,
1890}
1891
1892/// A declared architecture-zone crossing, recovered by correlating a finding's
1893/// anchor against the run's architecture-boundary violations.
1894#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1895#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1896pub struct SecurityZoneCrossing {
1897    /// Zone the importing side belongs to.
1898    pub from: String,
1899    /// Zone the imported side belongs to.
1900    pub to: String,
1901}
1902
1903/// The boundary slot of a [`SecurityCandidate`]: which structural boundaries the
1904/// candidate's flow crosses. A flow that crosses a client/server or module
1905/// boundary is a stronger review target than a self-contained one; the boundary
1906/// is fallow's structural signal over a pure source-sink match.
1907///
1908/// Two further boundary kinds are RESERVED for a follow-up and are deliberately
1909/// absent here rather than emitted as always-false: `export_visibility` (is the
1910/// sink on a publicly-exported symbol?) and a package boundary (does the flow
1911/// cross an npm-package edge?). Both need new graph derivation that does not
1912/// exist today; emitting them as `false` would misreport "we checked and it does
1913/// not cross" when fallow has not checked at all.
1914#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1915#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1916pub struct SecurityCandidateBoundary {
1917    /// Whether the finding crosses a client/server boundary (a `"use client"`
1918    /// file appears in the trace). True only for `client-server-leak` today;
1919    /// `tainted-sink` candidates carry no client/server marker.
1920    pub client_server: bool,
1921    /// Whether an untrusted source reaches the sink across one or more
1922    /// value-import (module) hops. Derived from the reachability hop count.
1923    pub cross_module: bool,
1924    /// The architecture-zone crossing when the anchor participates in a declared
1925    /// boundary-rule violation in the same run. `None` when it crosses no
1926    /// declared zone boundary.
1927    #[serde(default, skip_serializing_if = "Option::is_none")]
1928    pub architecture_zone: Option<SecurityZoneCrossing>,
1929}
1930
1931/// Network-destination context for a `secret-to-network` candidate (#890): where
1932/// the secret-bearing network call sends its data. Present only on
1933/// network-category candidates. A consuming agent uses it to triage exfil
1934/// (dynamic / untrusted destination) from intended auth (a literal provider
1935/// host) without re-reading source.
1936#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1937#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1938pub struct SecurityNetworkContext {
1939    /// The network call's destination as a static URL string literal, or absent
1940    /// when the destination is DYNAMIC (not a literal). A dynamic destination is
1941    /// the higher-signal exfil case; a literal provider host is usually intended
1942    /// auth.
1943    #[serde(default, skip_serializing_if = "Option::is_none")]
1944    pub destination: Option<String>,
1945}
1946
1947/// An agent-actionable candidate record on a [`SecurityFinding`]. fallow fills
1948/// `source_kind`, `sink`, and `boundary`. The exploitability IMPACT is
1949/// deliberately NOT a field: `severity` on the parent finding is only a
1950/// review-priority tier, while deciding exploitability remains the consuming
1951/// agent's job. A perpetually-null `impact` key would only train consumers to
1952/// ignore it. The agent reads this record, then writes its own impact verdict
1953/// downstream.
1954#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1955#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1956pub struct SecurityCandidate {
1957    /// The kind of untrusted input that reaches the sink, as a stable catalogue
1958    /// source id (`"http-request-input"`, `"process-env"`, `"process-argv"`,
1959    /// `"message-event-data"`, `"location-input"`, ...). `None`/absent when no
1960    /// untrusted source was matched (always `None` for `client-server-leak`).
1961    /// This is an OPEN string set, driven by the data-driven source catalogue; a
1962    /// consumer should treat an unknown id as "untrusted source of unknown kind"
1963    /// and never drop the candidate on that basis.
1964    #[serde(default, skip_serializing_if = "Option::is_none")]
1965    pub source_kind: Option<String>,
1966    /// The sink the candidate fires on, self-contained so the record is
1967    /// actionable without reading the parent finding.
1968    pub sink: SecurityCandidateSink,
1969    /// The structural boundary the flow crosses.
1970    pub boundary: SecurityCandidateBoundary,
1971    /// Network-destination context, present only on `secret-to-network` (#890)
1972    /// candidates: the host the secret-bearing call targets, so an agent can
1973    /// triage exfil from intended auth. Absent for every other category.
1974    #[serde(default, skip_serializing_if = "Option::is_none")]
1975    pub network: Option<SecurityNetworkContext>,
1976}
1977
1978/// One endpoint (source or sink node) of a [`SecurityTaintFlow`].
1979#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1980#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1981pub struct TaintEndpoint {
1982    /// File of the endpoint. Absolute internally; JSON strips the project root.
1983    #[serde(serialize_with = "serde_path::serialize")]
1984    pub path: PathBuf,
1985    /// 1-based line of the endpoint.
1986    pub line: u32,
1987    /// 0-based byte column of the endpoint.
1988    pub col: u32,
1989}
1990
1991/// Compact taint-flow path shape. The ordered per-hop trace is NOT duplicated
1992/// here: it lives on [`SecurityReachability::untrusted_source_trace`]. This
1993/// carries only the flow's structural summary (intra-module flow plus the
1994/// cross-module hop count) so consumers do not parse two copies of the hops.
1995#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1996#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1997pub struct TaintPath {
1998    /// Whether the source and sink sit in the same module (no import hop between
1999    /// them); the source-to-sink association is intra-module.
2000    pub intra_module: bool,
2001    /// Number of value-import hops from the untrusted-source module to the sink
2002    /// module. Zero for an intra-module flow.
2003    pub cross_module_hops: u32,
2004}
2005
2006/// A source-to-sink taint-flow triple, emitted only when an untrusted source is
2007/// import-reachable to the sink (`reachability.reachable_from_untrusted_source`).
2008/// The `{ source, sink, path }` shape matches the model agent SAST tooling
2009/// expects (cf. Semgrep `taint_source` / `taint_sink`, SARIF `threadFlows`).
2010#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2011#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2012pub struct SecurityTaintFlow {
2013    /// The untrusted-source endpoint (first hop of the reachability trace).
2014    pub source: TaintEndpoint,
2015    /// The sink endpoint (terminal hop of the reachability trace / the anchor).
2016    pub sink: TaintEndpoint,
2017    /// Compact flow shape: same-module flag plus module hop count. The full
2018    /// ordered path is `reachability.untrusted_source_trace`.
2019    pub path: TaintPath,
2020}
2021
2022/// Runtime coverage state for the function enclosing a security sink.
2023/// This is production-observation evidence, not an exploitability verdict.
2024#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2025#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2026#[serde(rename_all = "kebab-case")]
2027pub enum SecurityRuntimeState {
2028    /// The sink sits inside a runtime hot path.
2029    RuntimeHot,
2030    /// The sink sits inside a tracked function with zero production invocations.
2031    RuntimeCold,
2032    /// The sink sits inside a tracked function the runtime layer marked as safe
2033    /// to delete because it was never executed.
2034    NeverExecuted,
2035    /// The sink sits inside a function that executed, but below the low-traffic
2036    /// threshold.
2037    LowTraffic,
2038    /// Runtime coverage could not classify the enclosing function.
2039    CoverageUnavailable,
2040    /// A static enclosing function was found, but the runtime report carried no
2041    /// matching evidence for it.
2042    RuntimeUnknown,
2043}
2044
2045/// Runtime coverage context attached to a security candidate when
2046/// `fallow security --runtime-coverage` is supplied.
2047#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2048#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2049pub struct SecurityRuntimeContext {
2050    /// Runtime state for the enclosing function.
2051    pub state: SecurityRuntimeState,
2052    /// Enclosing function name from static extraction.
2053    pub function: String,
2054    /// 1-based line where the enclosing function starts.
2055    pub line: u32,
2056    /// Observed invocation count when the runtime report provides it.
2057    #[serde(default, skip_serializing_if = "Option::is_none")]
2058    pub invocations: Option<u64>,
2059    /// Runtime coverage stable function id, when available.
2060    #[serde(default, skip_serializing_if = "Option::is_none")]
2061    pub stable_id: Option<String>,
2062    /// Short candidate-framed explanation of the runtime evidence.
2063    #[serde(default, skip_serializing_if = "Option::is_none")]
2064    pub evidence: Option<String>,
2065}
2066
2067/// Verification-priority tier for a security candidate. This is ranking, not an
2068/// exploitability verdict.
2069#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
2070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2071#[serde(rename_all = "lowercase")]
2072pub enum SecuritySeverity {
2073    /// Highest-priority candidate based on reachability, boundary, or runtime-hot signals.
2074    High,
2075    /// Candidate has source-reachability evidence but no high-priority signal.
2076    Medium,
2077    /// Candidate has no source-reachability or boundary signal.
2078    Low,
2079}
2080
2081/// Defensive control found on an attack-surface path.
2082#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2083#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2084pub struct SecurityDefensiveControl {
2085    /// Control family.
2086    pub kind: SecurityControlKind,
2087    /// File of the control site. Absolute internally; JSON strips the project root.
2088    #[serde(serialize_with = "serde_path::serialize")]
2089    pub path: PathBuf,
2090    /// 1-based line of the control site.
2091    pub line: u32,
2092    /// 0-based byte column of the control site.
2093    pub col: u32,
2094    /// Flattened callee path or a stable synthetic guard name.
2095    pub callee: String,
2096}
2097
2098/// Agent-facing defensive-boundary verification context for one surface path.
2099#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2101pub struct SecurityDefensiveBoundary {
2102    /// Known controls detected along this path.
2103    pub controls: Vec<SecurityDefensiveControl>,
2104    /// Verification question for the consuming agent. It is a prompt, not a
2105    /// missing-guard verdict.
2106    pub verification_prompt: String,
2107}
2108
2109/// One untrusted entry to reachable sink path for `fallow security --surface`.
2110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2112pub struct SecurityAttackSurfaceEntry {
2113    /// The untrusted-source endpoint.
2114    pub source: TaintEndpoint,
2115    /// The reachable sink endpoint and catalogue metadata.
2116    pub sink: SecurityCandidateSink,
2117    /// Ordered source to sink path. Same shape as the reachability trace so
2118    /// consumers can reuse existing path handling.
2119    pub path: Vec<TraceHop>,
2120    /// Defensive-boundary context detected on this path.
2121    pub defensive_boundary: SecurityDefensiveBoundary,
2122}
2123
2124/// A local security CANDIDATE for downstream agent verification, NOT a verified
2125/// vulnerability. Emitted only by `fallow security`, never under bare `fallow`
2126/// or the `audit` gate. There is deliberately no `confidence` or
2127/// `signal_strength` field: fallow does not prove exploitability, so the trace
2128/// (its hops and length) is the only honest signal.
2129#[derive(Debug, Clone, Serialize)]
2130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2131pub struct SecurityFinding {
2132    /// Stable per-finding correlation id, identical across runs for the same
2133    /// rule + anchor path + line. An autonomous agent that triaged this
2134    /// candidate on a prior run uses it to correlate the candidate after a
2135    /// rebase. Equal to the SARIF `partialFingerprints` value for the same
2136    /// finding (one shared helper computes both).
2137    pub finding_id: String,
2138    /// The rule that produced this candidate.
2139    pub kind: SecurityFindingKind,
2140    /// The catalogue category id (e.g. `"dangerous-html"`). `Some` for
2141    /// `TaintedSink`. For `ClientServerLeak` this is `None` for the secret-leak
2142    /// finding, and `Some("server-only-import")` when a `"use client"` cone
2143    /// reaches server-only code.
2144    #[serde(default, skip_serializing_if = "Option::is_none")]
2145    pub category: Option<String>,
2146    /// The CWE number declared by the matched catalogue entry. `None` for
2147    /// `ClientServerLeak`; never fabricated beyond the catalogue's value.
2148    #[serde(default, skip_serializing_if = "Option::is_none")]
2149    pub cwe: Option<u32>,
2150    /// File the finding is anchored on (the client boundary). Absolute
2151    /// internally; JSON strips the project root via `serde_path::serialize`.
2152    #[serde(serialize_with = "serde_path::serialize")]
2153    pub path: PathBuf,
2154    /// 1-based line number of the anchor.
2155    pub line: u32,
2156    /// 0-based byte column offset of the anchor.
2157    pub col: u32,
2158    /// Agent/human-readable evidence (e.g. the named env var the chain reaches).
2159    pub evidence: String,
2160    /// Whether the sink argument was associated with a known untrusted source by
2161    /// the intra-module source-to-sink back-trace (issue #859): a local binding
2162    /// referenced in the argument was sourced from a catalogue source path
2163    /// (`req.query`, `process.argv`, message-event `data`, etc.). `true` ranks
2164    /// the candidate higher and annotates the evidence; `false` does NOT
2165    /// suppress the finding (the association is conservative, never a proof, and
2166    /// fallow prefers false-negatives over false-positives). Always `false` for
2167    /// `ClientServerLeak`. Skipped from JSON when `false` for output stability.
2168    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2169    pub source_backed: bool,
2170    /// Internal cross-pass carrier (NEVER serialized): the (1-based line, 0-based
2171    /// col) of the arg-level source read, resolved by the detector when
2172    /// `source_backed` is true and a concrete read span was captured. The ranking
2173    /// pass uses it to anchor the taint trace's source node at the real read
2174    /// instead of the module import line. `None` for module-level findings and
2175    /// for arg-level findings with no concrete read span (synthetic
2176    /// framework-param / helper-return sources), where the trace falls back to
2177    /// the sink site.
2178    #[serde(skip)]
2179    pub source_read: Option<(u32, u32)>,
2180    /// Verification-priority tier derived from existing reachability, boundary,
2181    /// source-backed, and runtime signals. Candidate-only: this does not prove
2182    /// exploitability and does not change gates.
2183    pub severity: SecuritySeverity,
2184    /// Structural import-hop trace from the client boundary to the secret source.
2185    /// The hop count is the uncalibrated signal; fallow does not prove the path
2186    /// is exploitable.
2187    pub trace: Vec<TraceHop>,
2188    /// Machine-actionable next steps. Always emitted (possibly empty for
2189    /// forward-compat). For security candidates this is a single file-level
2190    /// suppress hint (`auto_fixable: false`); there is no auto-fix because
2191    /// verification is the agent's job, not fallow's.
2192    pub actions: Vec<IssueAction>,
2193    /// Dead-code cross-link when the same sink candidate sits in code fallow also
2194    /// reports as removable. Agents should verify the dead-code finding and delete
2195    /// the code instead of hardening the sink when deletion is safe.
2196    #[serde(default, skip_serializing_if = "Option::is_none")]
2197    pub dead_code: Option<SecurityDeadCodeContext>,
2198    /// Graph-derived reachability ranking signal (issues #860 and #885). `None`
2199    /// until the post-detection ranking pass fills it; additive on the wire
2200    /// (skipped when absent). Drives the order findings are emitted in:
2201    /// runtime-reachable candidates sort first, followed by source-backed and
2202    /// source-reachable candidates, then wider blast radius.
2203    #[serde(default, skip_serializing_if = "Option::is_none")]
2204    pub reachability: Option<SecurityReachability>,
2205    /// Agent-actionable candidate record: the untrusted input kind, the sink,
2206    /// and the boundary the flow crosses. fallow fills these three slots; the
2207    /// exploitability verdict is the agent's job and is not a field here. Always
2208    /// present.
2209    pub candidate: SecurityCandidate,
2210    /// Source-to-sink taint-flow triple, present only when an untrusted source
2211    /// is import-reachable to this sink. Absent (skipped) otherwise.
2212    #[serde(default, skip_serializing_if = "Option::is_none")]
2213    pub taint_flow: Option<SecurityTaintFlow>,
2214    /// Production runtime coverage context for the function enclosing this
2215    /// security sink. Present only when `fallow security --runtime-coverage`
2216    /// runs and the candidate is a `tainted-sink`.
2217    #[serde(default, skip_serializing_if = "Option::is_none")]
2218    pub runtime: Option<SecurityRuntimeContext>,
2219    /// Internal projection used by `fallow security --surface`. The CLI strips
2220    /// this from per-finding JSON and promotes it to the top-level
2221    /// `attack_surface` field only when requested.
2222    #[serde(default, skip_serializing_if = "Option::is_none")]
2223    pub attack_surface: Option<SecurityAttackSurfaceEntry>,
2224}
2225
2226/// A pnpm catalog entry declared in pnpm-workspace.yaml that no workspace package
2227/// references via the `catalog:` protocol.
2228///
2229/// The default catalog (top-level `catalog:` key) uses `catalog_name: "default"`.
2230/// Named catalogs (under `catalogs.<name>:`) use their declared name.
2231#[derive(Debug, Clone, Serialize)]
2232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2233pub struct UnusedCatalogEntry {
2234    /// Package name declared in the catalog (e.g. `"react"`, `"@scope/lib"`).
2235    pub entry_name: String,
2236    /// Catalog group: `"default"` for the top-level `catalog:` map, or the
2237    /// named catalog key for entries declared under `catalogs.<name>:`.
2238    pub catalog_name: String,
2239    /// Path to `pnpm-workspace.yaml`, relative to the analyzed root.
2240    #[serde(serialize_with = "serde_path::serialize")]
2241    pub path: PathBuf,
2242    /// 1-based line number of the catalog entry within `pnpm-workspace.yaml`.
2243    pub line: u32,
2244    /// Workspace `package.json` files that declare the same package with a
2245    /// hardcoded version range instead of `catalog:`. Empty when no consumer
2246    /// uses a hardcoded version. Sorted lexicographically for deterministic
2247    /// output.
2248    #[serde(
2249        default,
2250        serialize_with = "serde_path::serialize_vec",
2251        skip_serializing_if = "Vec::is_empty"
2252    )]
2253    pub hardcoded_consumers: Vec<PathBuf>,
2254}
2255
2256/// A named `catalogs.<name>:` group in `pnpm-workspace.yaml` with no package entries.
2257#[derive(Debug, Clone, Serialize)]
2258#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2259pub struct EmptyCatalogGroup {
2260    /// Catalog group name declared under the top-level `catalogs:` map.
2261    pub catalog_name: String,
2262    /// Path to `pnpm-workspace.yaml`, relative to the analyzed root.
2263    #[serde(serialize_with = "serde_path::serialize")]
2264    pub path: PathBuf,
2265    /// 1-based line number of the empty group header within `pnpm-workspace.yaml`.
2266    pub line: u32,
2267}
2268
2269/// A workspace package.json reference (`catalog:` or `catalog:<name>`) that points
2270/// at a catalog which does not declare the consumed package.
2271///
2272/// `pnpm install` errors at install time with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_CATALOG_PROTOCOL`
2273/// when this happens. fallow surfaces it statically so the failure is caught at
2274/// `fallow dead-code` time, before any install.
2275///
2276/// The default catalog (bare `catalog:` references the top-level `catalog:` map)
2277/// uses `catalog_name: "default"`. Named catalogs (`catalog:react17`) use the
2278/// declared catalog name.
2279#[derive(Debug, Clone, Serialize)]
2280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2281pub struct UnresolvedCatalogReference {
2282    /// Package name being referenced via the catalog protocol (e.g. `"react"`).
2283    pub entry_name: String,
2284    /// Catalog group the reference points at: `"default"` for bare `catalog:` references,
2285    /// or the named catalog key for `catalog:<name>` references.
2286    pub catalog_name: String,
2287    /// Absolute path to the consumer `package.json`. Matches the storage
2288    /// convention used by every path-anchored finding type (`UnusedFile`,
2289    /// `UnresolvedImport`, `UnusedExport`, etc.) so the shared filtering
2290    /// pipelines (`filter_results_by_changed_files`, per-file overrides,
2291    /// audit attribution) work without a separate root-join pass. JSON
2292    /// output strips the project-root prefix via `serde_path::serialize`.
2293    #[serde(serialize_with = "serde_path::serialize")]
2294    pub path: PathBuf,
2295    /// 1-based line number of the dependency entry in the consumer `package.json`.
2296    pub line: u32,
2297    /// Other catalogs (in the same `pnpm-workspace.yaml`) that DO declare this
2298    /// package. Empty when no catalog has the package. Sorted lexicographically.
2299    /// Lets agents and humans decide whether to switch the reference to a
2300    /// different catalog or to add the entry to the named catalog.
2301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2302    pub available_in_catalogs: Vec<String>,
2303}
2304
2305/// Where an override entry was declared. Serialized as the filename label
2306/// (`"pnpm-workspace.yaml"` or `"package.json"`) so the value in JSON output
2307/// matches the value users write in `ignoreDependencyOverrides[].source`.
2308#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2310pub enum DependencyOverrideSource {
2311    /// Top-level `overrides:` key in `pnpm-workspace.yaml`.
2312    #[serde(rename = "pnpm-workspace.yaml")]
2313    PnpmWorkspaceYaml,
2314    /// `pnpm.overrides` in a root `package.json`.
2315    #[serde(rename = "package.json")]
2316    PnpmPackageJson,
2317}
2318
2319impl DependencyOverrideSource {
2320    /// Stable string label matching the serde rename. Used in baseline keys,
2321    /// audit keys, jq comparisons, and `ignoreDependencyOverrides[].source`.
2322    #[must_use]
2323    pub const fn as_label(&self) -> &'static str {
2324        match self {
2325            Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
2326            Self::PnpmPackageJson => "package.json",
2327        }
2328    }
2329}
2330
2331impl std::fmt::Display for DependencyOverrideSource {
2332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2333        f.write_str(self.as_label())
2334    }
2335}
2336
2337/// An entry in pnpm's `overrides:` map (or the legacy `pnpm.overrides` in
2338/// `package.json`) whose target package is not declared in any workspace
2339/// `package.json` and is not present in `pnpm-lock.yaml`. Projects without a
2340/// readable lockfile fall back to package manifest checks; the `hint` field
2341/// flags that conservative mode.
2342#[derive(Debug, Clone, Serialize)]
2343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2344pub struct UnusedDependencyOverride {
2345    /// The full original override key as written in the source (e.g.
2346    /// `"react>react-dom"`, `"@types/react@<18"`). Preserved for round-trip
2347    /// reporting so agents see the unmodified spelling.
2348    pub raw_key: String,
2349    /// The target package the override rewrites (e.g. `"react-dom"` for
2350    /// `"react>react-dom"`, `"@types/react"` for `"@types/react@<18"`).
2351    pub target_package: String,
2352    /// Optional parent package (left side of `>`). `None` for bare-target keys.
2353    #[serde(default, skip_serializing_if = "Option::is_none")]
2354    pub parent_package: Option<String>,
2355    /// Optional version selector on the target (e.g. `Some("<18")` for
2356    /// `"@types/react@<18"`).
2357    #[serde(default, skip_serializing_if = "Option::is_none")]
2358    pub version_constraint: Option<String>,
2359    /// The right-hand side of the entry: the version pnpm should force.
2360    pub version_range: String,
2361    /// File the override was declared in. Matches the value users write in
2362    /// `ignoreDependencyOverrides[].source`.
2363    pub source: DependencyOverrideSource,
2364    /// Path to the source file. `pnpm-workspace.yaml` or a `package.json`,
2365    /// stored as an absolute filesystem path so `--changed-since` and
2366    /// per-file `overrides.rules` can compare directly against the analyzer's
2367    /// changed-set / per-path rule lookups. JSON serialization strips the
2368    /// project root via `serde_path::serialize`, matching the
2369    /// `UnresolvedCatalogReference` convention.
2370    #[serde(serialize_with = "serde_path::serialize")]
2371    pub path: PathBuf,
2372    /// 1-based line number of the entry within the source file.
2373    pub line: u32,
2374    /// Soft hint reminding consumers to verify the override before removal.
2375    /// Emitted on every unused-override finding (both bare-target and
2376    /// parent-chain shapes) because projects without a readable lockfile still
2377    /// use the conservative package-manifest fallback.
2378    #[serde(default, skip_serializing_if = "Option::is_none")]
2379    pub hint: Option<String>,
2380}
2381
2382/// Why a dependency-override entry is misconfigured. `pnpm install` would
2383/// either fail at install time or silently no-op on these entries; surfacing
2384/// them statically catches the issue before pnpm does.
2385#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2387#[serde(rename_all = "kebab-case")]
2388pub enum DependencyOverrideMisconfigReason {
2389    /// The override key could not be parsed into a recognised pnpm shape
2390    /// (e.g. dangling `>`, missing target, garbage characters).
2391    UnparsableKey,
2392    /// The override value is missing, empty, or contains line breaks.
2393    EmptyValue,
2394}
2395
2396impl DependencyOverrideMisconfigReason {
2397    /// Human-readable summary of the reason.
2398    #[must_use]
2399    pub const fn describe(self) -> &'static str {
2400        match self {
2401            Self::UnparsableKey => "override key cannot be parsed",
2402            Self::EmptyValue => "override value is missing or empty",
2403        }
2404    }
2405}
2406
2407/// An override entry whose key or value is malformed. Default severity is
2408/// `error` because pnpm refuses to install (or silently produces a no-op
2409/// override) when it encounters these shapes.
2410#[derive(Debug, Clone, Serialize)]
2411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2412pub struct MisconfiguredDependencyOverride {
2413    /// The full original override key as written in the source.
2414    pub raw_key: String,
2415    /// Parsed target package name when the key was syntactically valid (the
2416    /// `EmptyValue` reason path). `None` for `UnparsableKey` findings whose
2417    /// key could not be parsed at all. Used by JSON `add-to-config` actions to
2418    /// emit a paste-ready `ignoreDependencyOverrides` value that matches the
2419    /// suppression matcher (which also keys on `target_package`); avoids the
2420    /// pitfall where `raw_key` like `"react@<18"` would not match the rule
2421    /// that targets package `"react"`.
2422    #[serde(default, skip_serializing_if = "Option::is_none")]
2423    pub target_package: Option<String>,
2424    /// The right-hand side of the entry, exactly as written. Empty when the
2425    /// value was missing.
2426    pub raw_value: String,
2427    /// Classifier for the misconfiguration. 'unparsable-key' = the key is not a
2428    /// valid pnpm shape; 'empty-value' = the value is missing, empty, or
2429    /// contains line breaks.
2430    pub reason: DependencyOverrideMisconfigReason,
2431    /// Where the override entry was declared.
2432    pub source: DependencyOverrideSource,
2433    /// Path to the source file. Stored as an absolute filesystem path so
2434    /// `--changed-since` and per-file `overrides.rules` can compare directly.
2435    /// JSON serialization strips the project root via `serde_path::serialize`.
2436    #[serde(serialize_with = "serde_path::serialize")]
2437    pub path: PathBuf,
2438    /// 1-based line number of the entry within the source file.
2439    pub line: u32,
2440}
2441
2442/// A production dependency that is only imported by test files.
2443/// Since it is never used in production code, it could be moved to devDependencies.
2444#[derive(Debug, Clone, Serialize)]
2445#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2446pub struct TestOnlyDependency {
2447    /// Production dependency that is only imported by test files — consider
2448    /// moving to devDependencies.
2449    pub package_name: String,
2450    /// Path to the package.json where the dependency is listed.
2451    #[serde(serialize_with = "serde_path::serialize")]
2452    pub path: PathBuf,
2453    /// 1-based line number of the dependency entry in package.json.
2454    pub line: u32,
2455}
2456
2457/// One import hop in a circular dependency: the file containing the import
2458/// and where that import statement sits.
2459///
2460/// `edges[i]` is the import IN `path` (the hop SOURCE, equal to the cycle's
2461/// `files[i]`) that points to the NEXT file in the cycle
2462/// (`files[(i + 1) % files.len()]`); the target is not repeated here to keep
2463/// the wire compact. Enables a per-file diagnostic squiggly anchored under
2464/// the offending import rather than a single squiggly on the first file.
2465///
2466/// `col` is a 0-based BYTE column, matching the cycle's top-level `col`;
2467/// converting it to a UTF-16 code-unit column for LSP clients is a tracked
2468/// follow-up shared with the existing field.
2469#[derive(Debug, Clone, Serialize, Deserialize)]
2470#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2471pub struct CircularDependencyEdge {
2472    /// The file containing the import (the hop SOURCE; equal to `files[i]`).
2473    #[serde(serialize_with = "serde_path::serialize")]
2474    pub path: PathBuf,
2475    /// 1-based line number of the import statement pointing to the next file.
2476    pub line: u32,
2477    /// 0-based byte column offset of the import statement.
2478    pub col: u32,
2479}
2480
2481/// A circular dependency chain detected in the module graph.
2482///
2483/// The `line` and `col` fields carry `#[serde(default)]` so callers reading
2484/// historical baseline JSON without these fields can still deserialize the
2485/// struct, but the JSON output layer always emits them (u32 always
2486/// serializes, never via `skip_serializing_if`). The schemars derive sees
2487/// the serde defaults and marks both fields optional in the generated
2488/// schema; the explicit `extend("required" = ...)` override here keeps the
2489/// schema's `required` array honest about what the JSON output actually
2490/// contains.
2491///
2492/// `edges` is deliberately kept OUT of the `required` extend: it is
2493/// `#[serde(default)]` (so historical baseline JSON without it still
2494/// deserializes) and the output layer always emits it, but listing it in
2495/// `required` would make pre-upgrade JSON fail validation against the new
2496/// schema. It is a normal additive field: always present in current output,
2497/// optional for backward compatibility.
2498#[derive(Debug, Clone, Serialize, Deserialize)]
2499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2500#[cfg_attr(feature = "schema", schemars(extend("required" = ["files", "length", "line", "col"])))]
2501pub struct CircularDependency {
2502    /// Files forming the cycle, in import order.
2503    #[serde(serialize_with = "serde_path::serialize_vec")]
2504    pub files: Vec<PathBuf>,
2505    /// Number of files in the cycle.
2506    pub length: usize,
2507    /// 1-based line number of the import that starts the cycle (in the first file).
2508    #[serde(default)]
2509    pub line: u32,
2510    /// 0-based byte column offset of the import that starts the cycle.
2511    #[serde(default)]
2512    pub col: u32,
2513    /// Per-file import anchors, one entry per hop in cycle order: `edges[i]`
2514    /// is the import in `files[i]` pointing to `files[(i + 1) % len]`. Always
2515    /// the same length as `files`. Drives the per-file LSP diagnostic
2516    /// squiggly. `#[serde(default)]` so pre-`edges` baselines deserialize;
2517    /// always emitted on output but intentionally not in the schema's
2518    /// `required` set (see the struct doc).
2519    #[serde(default)]
2520    pub edges: Vec<CircularDependencyEdge>,
2521    /// Whether this cycle crosses workspace package boundaries.
2522    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2523    pub is_cross_package: bool,
2524}
2525
2526/// A cycle or self-loop in the re-export edge subgraph.
2527///
2528/// Detected by Tarjan SCC over `(barrel, source)` re-export edges in
2529/// `crates/graph/src/graph/re_exports/`. A multi-node cycle is a strongly
2530/// connected component of size >= 2; a self-loop is a barrel that re-exports
2531/// from itself (often a rename leftover or accidental `export * from './'`).
2532/// Both are structural bugs because chain propagation through the loop is a
2533/// no-op: any symbol consumers think they are re-exporting through the cycle
2534/// silently fails to resolve.
2535#[derive(Debug, Clone, Serialize, Deserialize)]
2536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2537pub struct ReExportCycle {
2538    /// Files participating in the cycle, sorted lexicographically. For a
2539    /// self-loop, exactly one entry.
2540    #[serde(serialize_with = "serde_path::serialize_vec")]
2541    pub files: Vec<PathBuf>,
2542    /// Which structural shape this finding describes.
2543    pub kind: ReExportCycleKind,
2544}
2545
2546/// Discriminator for [`ReExportCycle`]: which structural shape was detected.
2547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2548#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2549#[serde(rename_all = "kebab-case")]
2550pub enum ReExportCycleKind {
2551    /// Two or more barrel files re-export from each other in a loop
2552    /// (SCC of size >= 2).
2553    MultiNode,
2554    /// A single barrel file re-exports from itself.
2555    SelfLoop,
2556}
2557
2558/// An import that crosses an architecture boundary rule.
2559#[derive(Debug, Clone, Serialize)]
2560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2561pub struct BoundaryViolation {
2562    /// The file making the disallowed import.
2563    #[serde(serialize_with = "serde_path::serialize")]
2564    pub from_path: PathBuf,
2565    /// The file being imported that violates the boundary.
2566    #[serde(serialize_with = "serde_path::serialize")]
2567    pub to_path: PathBuf,
2568    /// The zone the importing file belongs to.
2569    pub from_zone: String,
2570    /// The zone the imported file belongs to.
2571    pub to_zone: String,
2572    /// The raw import specifier from the source file.
2573    pub import_specifier: String,
2574    /// 1-based line number of the import statement in the source file.
2575    pub line: u32,
2576    /// 0-based byte column offset of the import statement.
2577    pub col: u32,
2578}
2579
2580/// A source file that does not match any configured architecture boundary zone.
2581#[derive(Debug, Clone, Serialize)]
2582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2583pub struct BoundaryCoverageViolation {
2584    /// The unmatched source file.
2585    #[serde(serialize_with = "serde_path::serialize")]
2586    pub path: PathBuf,
2587    /// 1-based line number used for diagnostics.
2588    pub line: u32,
2589    /// 0-based byte column offset used for diagnostics.
2590    pub col: u32,
2591}
2592
2593/// A call from a zoned file to a callee forbidden for that zone via
2594/// `boundaries.calls.forbidden`. One finding is reported per unique callee
2595/// path per file (first occurrence wins).
2596#[derive(Debug, Clone, Serialize)]
2597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2598pub struct BoundaryCallViolation {
2599    /// The zoned source file making the forbidden call.
2600    #[serde(serialize_with = "serde_path::serialize")]
2601    pub path: PathBuf,
2602    /// 1-based line number of the call site.
2603    pub line: u32,
2604    /// 0-based byte column offset of the call site.
2605    pub col: u32,
2606    /// The zone the calling file is classified into.
2607    pub zone: String,
2608    /// The callee path as written at the call site (e.g. `cp.exec`).
2609    pub callee: String,
2610    /// The configured pattern that matched (e.g. `child_process.*`), so
2611    /// consumers can see both the written path and the rule that fired.
2612    pub pattern: String,
2613}
2614
2615/// Which rule-pack rule kind produced a [`PolicyViolation`].
2616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2618#[serde(rename_all = "kebab-case")]
2619pub enum PolicyRuleKind {
2620    /// A call site matched a `banned-call` rule's callee patterns.
2621    BannedCall,
2622    /// An import or re-export specifier matched a `banned-import` rule.
2623    BannedImport,
2624}
2625
2626/// Effective severity of a single [`PolicyViolation`]. Per-rule `severity`
2627/// overrides the `rules."policy-violation"` master; `off` rules emit nothing,
2628/// so only `error` and `warn` appear on the wire. The exit-code gate inspects
2629/// this per-finding value, not the master severity.
2630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2632#[serde(rename_all = "lowercase")]
2633pub enum PolicyViolationSeverity {
2634    /// Fails CI (non-zero exit code).
2635    Error,
2636    /// Reported without failing CI.
2637    Warn,
2638}
2639
2640/// A banned call or banned import matched by a declarative rule pack
2641/// (`rulePacks` config). Banned-call findings report one entry per unique
2642/// callee path per file (first occurrence wins, matching
2643/// `boundary_call_violations`); banned-import findings anchor at each
2644/// matching import or re-export declaration.
2645#[derive(Debug, Clone, Serialize)]
2646#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2647pub struct PolicyViolation {
2648    /// The source file containing the banned call or import.
2649    #[serde(serialize_with = "serde_path::serialize")]
2650    pub path: PathBuf,
2651    /// 1-based line number of the call site or import declaration.
2652    pub line: u32,
2653    /// 0-based byte column offset of the call site or import declaration.
2654    pub col: u32,
2655    /// Name of the rule pack that declared the matching rule.
2656    pub pack: String,
2657    /// Id of the matching rule inside the pack. `pack` plus `rule_id` is the
2658    /// finding's policy identity.
2659    pub rule_id: String,
2660    /// Which rule kind matched.
2661    pub kind: PolicyRuleKind,
2662    /// What matched: the written callee path for `banned-call` (e.g.
2663    /// `cp.exec`), or the raw import specifier for `banned-import` (e.g.
2664    /// `moment/locale/nl`).
2665    pub matched: String,
2666    /// Effective severity for this finding (per-rule `severity`, else the
2667    /// `rules."policy-violation"` master).
2668    pub severity: PolicyViolationSeverity,
2669    /// The rule's author-provided message, when set.
2670    #[serde(default, skip_serializing_if = "Option::is_none")]
2671    pub message: Option<String>,
2672}
2673
2674/// The origin of a stale suppression: inline comment or JSDoc tag.
2675#[derive(Debug, Clone, Serialize)]
2676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2677#[serde(rename_all = "snake_case", tag = "type")]
2678pub enum SuppressionOrigin {
2679    /// A `// fallow-ignore-next-line` or `// fallow-ignore-file` comment.
2680    Comment {
2681        /// The issue kind token from the comment (e.g., "unused-exports"), or None for blanket.
2682        #[serde(default, skip_serializing_if = "Option::is_none")]
2683        issue_kind: Option<String>,
2684        /// Whether this was a file-level suppression.
2685        is_file_level: bool,
2686        /// Whether `issue_kind` parses to a known `IssueKind`. False when the
2687        /// token is a typo or refers to a kind that was renamed or removed in
2688        /// a newer fallow release. JSON consumers (CI annotations, MCP agents,
2689        /// VS Code) branch on this to choose the right next-step text.
2690        /// Omitted from the wire when `true` so producers that have not yet
2691        /// adopted the field stay byte-compatible. See issue #449.
2692        #[serde(default = "default_true", skip_serializing_if = "is_true")]
2693        kind_known: bool,
2694    },
2695    /// An `@expected-unused` JSDoc tag on an export.
2696    JsdocTag {
2697        /// The name of the export that was tagged.
2698        export_name: String,
2699    },
2700}
2701
2702#[expect(
2703    clippy::trivially_copy_pass_by_ref,
2704    reason = "serde skip_serializing_if takes a reference by contract"
2705)]
2706const fn is_true(b: &bool) -> bool {
2707    *b
2708}
2709
2710/// Default for `SuppressionOrigin::Comment.kind_known` when the field is
2711/// absent from a deserialized payload, paired with `skip_serializing_if = is_true`
2712/// so schemars marks the field non-required in the generated JSON Schema AND
2713/// the absent case round-trips to the recognized-kind interpretation.
2714/// Referenced by the always-emitted `#[serde(default = "default_true")]`
2715/// attribute. Today `SuppressionOrigin` derives only `Serialize`, so serde
2716/// itself never calls this; schemars (under the `schema` feature) reads the
2717/// attribute textually to mark `kind_known` non-required. The `cfg_attr`
2718/// applies `#[expect(dead_code)]` only on builds WITHOUT the `schema` feature
2719/// (where the function is genuinely dead): under the feature schemars
2720/// references it, the lint does not fire, and an unconditional `#[expect]`
2721/// would be unfulfilled. The function stays un-gated so a future
2722/// `Deserialize` derive on `SuppressionOrigin` does not produce a missing-
2723/// function compile error on non-`schema` builds.
2724#[cfg_attr(
2725    not(feature = "schema"),
2726    expect(
2727        dead_code,
2728        reason = "referenced via #[serde(default = ...)]; only consumed by schemars under the `schema` feature, dead on default builds today"
2729    )
2730)]
2731const fn default_true() -> bool {
2732    true
2733}
2734
2735/// A suppression comment or JSDoc tag that no longer matches any issue.
2736#[derive(Debug, Clone, Serialize)]
2737#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2738pub struct StaleSuppression {
2739    /// File containing the stale suppression.
2740    #[serde(serialize_with = "serde_path::serialize")]
2741    pub path: PathBuf,
2742    /// 1-based line number of the suppression comment or tag.
2743    pub line: u32,
2744    /// 0-based byte column offset.
2745    pub col: u32,
2746    /// The origin and details of the stale suppression.
2747    pub origin: SuppressionOrigin,
2748}
2749
2750impl StaleSuppression {
2751    /// Produce a human-readable description of this stale suppression.
2752    #[must_use]
2753    pub fn description(&self) -> String {
2754        match &self.origin {
2755            SuppressionOrigin::Comment {
2756                issue_kind,
2757                is_file_level,
2758                ..
2759            } => {
2760                let directive = if *is_file_level {
2761                    "fallow-ignore-file"
2762                } else {
2763                    "fallow-ignore-next-line"
2764                };
2765                match issue_kind {
2766                    Some(kind) => format!("// {directive} {kind}"),
2767                    None => format!("// {directive}"),
2768                }
2769            }
2770            SuppressionOrigin::JsdocTag { export_name } => {
2771                format!("@expected-unused on {export_name}")
2772            }
2773        }
2774    }
2775
2776    /// Produce an explanation of why this suppression is stale.
2777    ///
2778    /// For comment suppressions where `kind_known == false`, surfaces the
2779    /// unknown token plus a Levenshtein "did you mean?" hint when one is
2780    /// within edit distance 2. Other tokens on the same comment line still
2781    /// apply normally (see issue #449).
2782    #[must_use]
2783    pub fn explanation(&self) -> String {
2784        match &self.origin {
2785            SuppressionOrigin::Comment {
2786                issue_kind,
2787                is_file_level,
2788                kind_known,
2789            } => {
2790                let scope = if *is_file_level {
2791                    "in this file"
2792                } else {
2793                    "on the next line"
2794                };
2795                match issue_kind {
2796                    Some(kind) if !*kind_known => match closest_known_kind_name(kind) {
2797                        Some(suggestion) => format!(
2798                            "'{kind}' is not a recognized fallow issue kind. Did you mean '{suggestion}'? Other tokens on this line still apply."
2799                        ),
2800                        None => format!(
2801                            "'{kind}' is not a recognized fallow issue kind. Other tokens on this line still apply."
2802                        ),
2803                    },
2804                    Some(kind) => format!("no {kind} issue found {scope}"),
2805                    None => format!("no issues found {scope}"),
2806                }
2807            }
2808            SuppressionOrigin::JsdocTag { export_name } => {
2809                format!("{export_name} is now used")
2810            }
2811        }
2812    }
2813
2814    /// The suppressed `IssueKind`, if this was a comment suppression with a specific known kind.
2815    ///
2816    /// Returns `None` for unknown-kind comments (`kind_known == false`) and
2817    /// for JSDoc tags.
2818    #[must_use]
2819    pub fn suppressed_kind(&self) -> Option<IssueKind> {
2820        match &self.origin {
2821            SuppressionOrigin::Comment {
2822                issue_kind,
2823                kind_known: true,
2824                ..
2825            } => issue_kind.as_deref().and_then(IssueKind::parse),
2826            SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => None,
2827        }
2828    }
2829
2830    /// Per-format display message combining `description()` and `explanation()`
2831    /// for the unknown-kind case so SARIF, CodeClimate, and compact consumers
2832    /// surface the typo-fix copy and Levenshtein hint without needing to
2833    /// branch on `origin.kind_known` themselves. Stale-but-known and JSDoc
2834    /// origins keep the bare `description()` so existing wire bytes stay
2835    /// unchanged. See issue #449.
2836    #[must_use]
2837    pub fn display_message(&self) -> String {
2838        match &self.origin {
2839            SuppressionOrigin::Comment {
2840                kind_known: false, ..
2841            } => format!("{} ({})", self.description(), self.explanation()),
2842            SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => {
2843                self.description()
2844            }
2845        }
2846    }
2847}
2848
2849/// A suppression comment present in an analyzed file this run.
2850///
2851/// This is the "active-suppression state" the Fallow Impact value report needs
2852/// to tell a genuinely resolved finding (the code was fixed) from one merely
2853/// silenced by a newly-added `fallow-ignore`. It captures every PRESENT marker,
2854/// not only the ones a detector consumed: complexity and code-duplication
2855/// suppressions are consumed in the CLI layer rather than the core suppression
2856/// context, so presence is the single uniform signal that covers all impact
2857/// categories. A present-but-stale marker is harmless because impact keys on a
2858/// suppression that newly appeared between two recorded runs. It is internal:
2859/// never serialized into the public JSON output schema (the field on
2860/// [`AnalysisResults`] is `#[serde(skip)]`), only read in-process by
2861/// `fallow impact`.
2862#[derive(Debug, Clone)]
2863pub struct ActiveSuppression {
2864    /// Absolute path to the file carrying the suppression comment.
2865    pub path: PathBuf,
2866    /// The suppressed issue kind in kebab-case (e.g. `"unused-export"`), or
2867    /// `None` for a blanket marker that suppresses every kind on its target.
2868    pub kind: Option<String>,
2869    /// Whether this is a `fallow-ignore-file` (file-level) marker rather than a
2870    /// `fallow-ignore-next-line` marker.
2871    pub is_file_level: bool,
2872}
2873
2874/// The detection method used to identify a feature flag.
2875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2876#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2877#[serde(rename_all = "snake_case")]
2878pub enum FlagKind {
2879    /// Environment variable check (e.g., `process.env.FEATURE_X`).
2880    EnvironmentVariable,
2881    /// Feature flag SDK call (e.g., `useFlag('name')`, `variation('name', false)`).
2882    SdkCall,
2883    /// Config object property access (e.g., `config.features.newCheckout`).
2884    ConfigObject,
2885}
2886
2887/// Detection confidence for a feature flag finding.
2888#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
2889#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2890#[serde(rename_all = "snake_case")]
2891pub enum FlagConfidence {
2892    /// Low confidence: heuristic match (config object patterns).
2893    Low,
2894    /// Medium confidence: pattern match with some ambiguity.
2895    Medium,
2896    /// High confidence: unambiguous pattern (env vars, direct SDK calls).
2897    High,
2898}
2899
2900/// A detected feature flag use site.
2901#[derive(Debug, Clone, Serialize)]
2902#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2903pub struct FeatureFlag {
2904    /// File containing the feature flag usage.
2905    #[serde(serialize_with = "serde_path::serialize")]
2906    pub path: PathBuf,
2907    /// Name or identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
2908    pub flag_name: String,
2909    /// How the flag was detected.
2910    pub kind: FlagKind,
2911    /// Detection confidence level.
2912    pub confidence: FlagConfidence,
2913    /// 1-based line number.
2914    pub line: u32,
2915    /// 0-based byte column offset.
2916    pub col: u32,
2917    /// Start byte offset of the guarded code block (if-branch span), if detected.
2918    #[serde(skip)]
2919    pub guard_span_start: Option<u32>,
2920    /// End byte offset of the guarded code block (if-branch span), if detected.
2921    #[serde(skip)]
2922    pub guard_span_end: Option<u32>,
2923    /// SDK or provider name (e.g., "LaunchDarkly", "Statsig"), if detected from SDK call.
2924    #[serde(default, skip_serializing_if = "Option::is_none")]
2925    pub sdk_name: Option<String>,
2926    /// Line range of the guarded code block (derived from guard_span + line_offsets).
2927    /// Used for cross-reference with dead code findings.
2928    #[serde(skip)]
2929    pub guard_line_start: Option<u32>,
2930    /// End line of the guarded code block.
2931    #[serde(skip)]
2932    pub guard_line_end: Option<u32>,
2933    /// Unused exports found within the guarded code block.
2934    /// Populated by cross-reference with dead code analysis.
2935    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2936    pub guarded_dead_exports: Vec<String>,
2937}
2938
2939// Size assertion: FeatureFlag is stored in a Vec per analysis run.
2940const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
2941
2942/// Usage count for an export symbol. Used by the LSP Code Lens to show
2943/// reference counts above each export declaration.
2944#[derive(Debug, Clone, Serialize)]
2945#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2946pub struct ExportUsage {
2947    /// File containing the export.
2948    #[serde(serialize_with = "serde_path::serialize")]
2949    pub path: PathBuf,
2950    /// Name of the exported symbol.
2951    pub export_name: String,
2952    /// 1-based line number.
2953    pub line: u32,
2954    /// 0-based byte column offset.
2955    pub col: u32,
2956    /// Number of files that reference this export.
2957    pub reference_count: usize,
2958    /// Locations where this export is referenced. Used by the LSP Code Lens
2959    /// to enable click-to-navigate via `editor.action.showReferences`.
2960    pub reference_locations: Vec<ReferenceLocation>,
2961}
2962
2963/// A location where an export is referenced (import site in another file).
2964#[derive(Debug, Clone, Serialize)]
2965#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2966pub struct ReferenceLocation {
2967    /// File containing the import that references the export.
2968    #[serde(serialize_with = "serde_path::serialize")]
2969    pub path: PathBuf,
2970    /// 1-based line number.
2971    pub line: u32,
2972    /// 0-based byte column offset.
2973    pub col: u32,
2974}
2975
2976#[cfg(test)]
2977mod tests {
2978    use super::*;
2979    use crate::output_dead_code::{
2980        BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
2981        UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
2982        UnusedTypeFinding,
2983    };
2984
2985    #[test]
2986    fn empty_results_no_issues() {
2987        let results = AnalysisResults::default();
2988        assert_eq!(results.total_issues(), 0);
2989        assert!(!results.has_issues());
2990    }
2991
2992    #[test]
2993    fn results_with_unused_file() {
2994        let mut results = AnalysisResults::default();
2995        results
2996            .unused_files
2997            .push(UnusedFileFinding::with_actions(UnusedFile {
2998                path: PathBuf::from("test.ts"),
2999            }));
3000        assert_eq!(results.total_issues(), 1);
3001        assert!(results.has_issues());
3002    }
3003
3004    #[test]
3005    fn results_with_unused_export() {
3006        let mut results = AnalysisResults::default();
3007        results
3008            .unused_exports
3009            .push(UnusedExportFinding::with_actions(UnusedExport {
3010                path: PathBuf::from("test.ts"),
3011                export_name: "foo".to_string(),
3012                is_type_only: false,
3013                line: 1,
3014                col: 0,
3015                span_start: 0,
3016                is_re_export: false,
3017            }));
3018        assert_eq!(results.total_issues(), 1);
3019        assert!(results.has_issues());
3020    }
3021
3022    fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
3023        UnusedExport {
3024            path: PathBuf::from(path),
3025            export_name: export_name.to_string(),
3026            is_type_only,
3027            line: 1,
3028            col: 0,
3029            span_start: 0,
3030            is_re_export: false,
3031        }
3032    }
3033
3034    fn test_unused_dependency(
3035        package_name: &str,
3036        location: DependencyLocation,
3037    ) -> UnusedDependency {
3038        UnusedDependency {
3039            package_name: package_name.to_string(),
3040            location,
3041            path: PathBuf::from("package.json"),
3042            line: 5,
3043            used_in_workspaces: Vec::new(),
3044        }
3045    }
3046
3047    fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
3048        UnusedMember {
3049            path: PathBuf::from("members.ts"),
3050            parent_name: "Parent".to_string(),
3051            member_name: member_name.to_string(),
3052            kind,
3053            line: 1,
3054            col: 0,
3055        }
3056    }
3057
3058    #[test]
3059    fn results_total_counts_all_types() {
3060        let results = AnalysisResults {
3061            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
3062                path: PathBuf::from("a.ts"),
3063            })],
3064            unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
3065                "b.ts", "x", false,
3066            ))],
3067            unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
3068                "c.ts", "T", true,
3069            ))],
3070            unused_dependencies: vec![UnusedDependencyFinding::with_actions(
3071                test_unused_dependency("dep", DependencyLocation::Dependencies),
3072            )],
3073            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
3074                test_unused_dependency("dev", DependencyLocation::DevDependencies),
3075            )],
3076            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
3077                "A",
3078                MemberKind::EnumMember,
3079            ))],
3080            unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
3081                "m",
3082                MemberKind::ClassMethod,
3083            ))],
3084            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
3085                path: PathBuf::from("f.ts"),
3086                specifier: "./missing".to_string(),
3087                line: 1,
3088                col: 0,
3089                specifier_col: 0,
3090            })],
3091            unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
3092                UnlistedDependency {
3093                    package_name: "unlisted".to_string(),
3094                    imported_from: vec![ImportSite {
3095                        path: PathBuf::from("g.ts"),
3096                        line: 1,
3097                        col: 0,
3098                    }],
3099                },
3100            )],
3101            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
3102                export_name: "dup".to_string(),
3103                locations: vec![
3104                    DuplicateLocation {
3105                        path: PathBuf::from("h.ts"),
3106                        line: 15,
3107                        col: 0,
3108                    },
3109                    DuplicateLocation {
3110                        path: PathBuf::from("i.ts"),
3111                        line: 30,
3112                        col: 0,
3113                    },
3114                ],
3115            })],
3116            unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
3117                test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
3118            )],
3119            type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
3120                TypeOnlyDependency {
3121                    package_name: "type-only".to_string(),
3122                    path: PathBuf::from("package.json"),
3123                    line: 8,
3124                },
3125            )],
3126            test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
3127                TestOnlyDependency {
3128                    package_name: "test-only".to_string(),
3129                    path: PathBuf::from("package.json"),
3130                    line: 9,
3131                },
3132            )],
3133            circular_dependencies: vec![CircularDependencyFinding::with_actions(
3134                CircularDependency {
3135                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
3136                    length: 2,
3137                    line: 3,
3138                    col: 0,
3139                    edges: Vec::new(),
3140                    is_cross_package: false,
3141                },
3142            )],
3143            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
3144                from_path: PathBuf::from("src/ui/Button.tsx"),
3145                to_path: PathBuf::from("src/db/queries.ts"),
3146                from_zone: "ui".to_string(),
3147                to_zone: "database".to_string(),
3148                import_specifier: "../db/queries".to_string(),
3149                line: 3,
3150                col: 0,
3151            })],
3152            ..Default::default()
3153        };
3154
3155        // 15 categories, one of each
3156        assert_eq!(results.total_issues(), 15);
3157        assert!(results.has_issues());
3158    }
3159
3160    // ── total_issues / has_issues consistency ──────────────────
3161
3162    #[test]
3163    fn total_issues_and_has_issues_are_consistent() {
3164        let results = AnalysisResults::default();
3165        assert_eq!(results.total_issues(), 0);
3166        assert!(!results.has_issues());
3167        assert_eq!(results.total_issues() > 0, results.has_issues());
3168    }
3169
3170    // ── total_issues counts each category independently ─────────
3171
3172    #[test]
3173    fn total_issues_sums_all_categories_independently() {
3174        let mut results = AnalysisResults::default();
3175        results
3176            .unused_files
3177            .push(UnusedFileFinding::with_actions(UnusedFile {
3178                path: PathBuf::from("a.ts"),
3179            }));
3180        assert_eq!(results.total_issues(), 1);
3181
3182        results
3183            .unused_files
3184            .push(UnusedFileFinding::with_actions(UnusedFile {
3185                path: PathBuf::from("b.ts"),
3186            }));
3187        assert_eq!(results.total_issues(), 2);
3188
3189        results
3190            .unresolved_imports
3191            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3192                path: PathBuf::from("c.ts"),
3193                specifier: "./missing".to_string(),
3194                line: 1,
3195                col: 0,
3196                specifier_col: 0,
3197            }));
3198        assert_eq!(results.total_issues(), 3);
3199    }
3200
3201    // ── default is truly empty ──────────────────────────────────
3202
3203    #[test]
3204    fn default_results_all_fields_empty() {
3205        let r = AnalysisResults::default();
3206        assert!(r.unused_files.is_empty());
3207        assert!(r.unused_exports.is_empty());
3208        assert!(r.unused_types.is_empty());
3209        assert!(r.unused_dependencies.is_empty());
3210        assert!(r.unused_dev_dependencies.is_empty());
3211        assert!(r.unused_optional_dependencies.is_empty());
3212        assert!(r.unused_enum_members.is_empty());
3213        assert!(r.unused_class_members.is_empty());
3214        assert!(r.unresolved_imports.is_empty());
3215        assert!(r.unlisted_dependencies.is_empty());
3216        assert!(r.duplicate_exports.is_empty());
3217        assert!(r.type_only_dependencies.is_empty());
3218        assert!(r.test_only_dependencies.is_empty());
3219        assert!(r.circular_dependencies.is_empty());
3220        assert!(r.boundary_violations.is_empty());
3221        assert!(r.unused_catalog_entries.is_empty());
3222        assert!(r.unresolved_catalog_references.is_empty());
3223        assert!(r.export_usages.is_empty());
3224    }
3225
3226    // ── EntryPointSummary ────────────────────────────────────────
3227
3228    #[test]
3229    fn entry_point_summary_default() {
3230        let summary = EntryPointSummary::default();
3231        assert_eq!(summary.total, 0);
3232        assert!(summary.by_source.is_empty());
3233    }
3234
3235    #[test]
3236    fn entry_point_summary_not_in_default_results() {
3237        let r = AnalysisResults::default();
3238        assert!(r.entry_point_summary.is_none());
3239    }
3240
3241    #[test]
3242    fn entry_point_summary_some_preserves_data() {
3243        let r = AnalysisResults {
3244            entry_point_summary: Some(EntryPointSummary {
3245                total: 5,
3246                by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
3247            }),
3248            ..AnalysisResults::default()
3249        };
3250        let summary = r.entry_point_summary.as_ref().unwrap();
3251        assert_eq!(summary.total, 5);
3252        assert_eq!(summary.by_source.len(), 2);
3253        assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
3254    }
3255
3256    // ── sort: unused_files by path ──────────────────────────────
3257
3258    #[test]
3259    fn sort_unused_files_by_path() {
3260        let mut r = AnalysisResults::default();
3261        r.unused_files
3262            .push(UnusedFileFinding::with_actions(UnusedFile {
3263                path: PathBuf::from("z.ts"),
3264            }));
3265        r.unused_files
3266            .push(UnusedFileFinding::with_actions(UnusedFile {
3267                path: PathBuf::from("a.ts"),
3268            }));
3269        r.unused_files
3270            .push(UnusedFileFinding::with_actions(UnusedFile {
3271                path: PathBuf::from("m.ts"),
3272            }));
3273        r.sort();
3274        let paths: Vec<_> = r
3275            .unused_files
3276            .iter()
3277            .map(|f| f.file.path.to_string_lossy().to_string())
3278            .collect();
3279        assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
3280    }
3281
3282    // ── sort: unused_exports by path, line, name ────────────────
3283
3284    #[test]
3285    fn sort_unused_exports_by_path_line_name() {
3286        let mut r = AnalysisResults::default();
3287        let mk = |path: &str, line: u32, name: &str| {
3288            UnusedExportFinding::with_actions(UnusedExport {
3289                path: PathBuf::from(path),
3290                export_name: name.to_string(),
3291                is_type_only: false,
3292                line,
3293                col: 0,
3294                span_start: 0,
3295                is_re_export: false,
3296            })
3297        };
3298        r.unused_exports.push(mk("b.ts", 5, "beta"));
3299        r.unused_exports.push(mk("a.ts", 10, "zeta"));
3300        r.unused_exports.push(mk("a.ts", 10, "alpha"));
3301        r.unused_exports.push(mk("a.ts", 1, "gamma"));
3302        r.sort();
3303        let keys: Vec<_> = r
3304            .unused_exports
3305            .iter()
3306            .map(|e| {
3307                format!(
3308                    "{}:{}:{}",
3309                    e.export.path.to_string_lossy(),
3310                    e.export.line,
3311                    e.export.export_name
3312                )
3313            })
3314            .collect();
3315        assert_eq!(
3316            keys,
3317            vec![
3318                "a.ts:1:gamma",
3319                "a.ts:10:alpha",
3320                "a.ts:10:zeta",
3321                "b.ts:5:beta"
3322            ]
3323        );
3324    }
3325
3326    // ── sort: unused_types (same sort as unused_exports) ────────
3327
3328    #[test]
3329    fn sort_unused_types_by_path_line_name() {
3330        let mut r = AnalysisResults::default();
3331        let mk = |path: &str, line: u32, name: &str| {
3332            UnusedTypeFinding::with_actions(UnusedExport {
3333                path: PathBuf::from(path),
3334                export_name: name.to_string(),
3335                is_type_only: true,
3336                line,
3337                col: 0,
3338                span_start: 0,
3339                is_re_export: false,
3340            })
3341        };
3342        r.unused_types.push(mk("z.ts", 1, "Z"));
3343        r.unused_types.push(mk("a.ts", 1, "A"));
3344        r.sort();
3345        assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
3346        assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
3347    }
3348
3349    // ── sort: unused_dependencies by path, line, name ───────────
3350
3351    #[test]
3352    fn sort_unused_dependencies_by_path_line_name() {
3353        let mut r = AnalysisResults::default();
3354        let mk = |path: &str, line: u32, name: &str| {
3355            UnusedDependencyFinding::with_actions(UnusedDependency {
3356                package_name: name.to_string(),
3357                location: DependencyLocation::Dependencies,
3358                path: PathBuf::from(path),
3359                line,
3360                used_in_workspaces: Vec::new(),
3361            })
3362        };
3363        r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
3364        r.unused_dependencies.push(mk("a/package.json", 5, "react"));
3365        r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
3366        r.sort();
3367        let names: Vec<_> = r
3368            .unused_dependencies
3369            .iter()
3370            .map(|d| d.dep.package_name.as_str())
3371            .collect();
3372        assert_eq!(names, vec!["axios", "react", "zlib"]);
3373    }
3374
3375    // ── sort: unused_dev_dependencies ───────────────────────────
3376
3377    #[test]
3378    fn sort_unused_dev_dependencies() {
3379        let mut r = AnalysisResults::default();
3380        r.unused_dev_dependencies
3381            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3382                package_name: "vitest".to_string(),
3383                location: DependencyLocation::DevDependencies,
3384                path: PathBuf::from("package.json"),
3385                line: 10,
3386                used_in_workspaces: Vec::new(),
3387            }));
3388        r.unused_dev_dependencies
3389            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3390                package_name: "jest".to_string(),
3391                location: DependencyLocation::DevDependencies,
3392                path: PathBuf::from("package.json"),
3393                line: 5,
3394                used_in_workspaces: Vec::new(),
3395            }));
3396        r.sort();
3397        assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
3398        assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
3399    }
3400
3401    // ── sort: unused_optional_dependencies ──────────────────────
3402
3403    #[test]
3404    fn sort_unused_optional_dependencies() {
3405        let mut r = AnalysisResults::default();
3406        r.unused_optional_dependencies
3407            .push(UnusedOptionalDependencyFinding::with_actions(
3408                UnusedDependency {
3409                    package_name: "zod".to_string(),
3410                    location: DependencyLocation::OptionalDependencies,
3411                    path: PathBuf::from("package.json"),
3412                    line: 3,
3413                    used_in_workspaces: Vec::new(),
3414                },
3415            ));
3416        r.unused_optional_dependencies
3417            .push(UnusedOptionalDependencyFinding::with_actions(
3418                UnusedDependency {
3419                    package_name: "ajv".to_string(),
3420                    location: DependencyLocation::OptionalDependencies,
3421                    path: PathBuf::from("package.json"),
3422                    line: 2,
3423                    used_in_workspaces: Vec::new(),
3424                },
3425            ));
3426        r.sort();
3427        assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
3428        assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
3429    }
3430
3431    // ── sort: unused_enum_members by path, line, parent, member ─
3432
3433    #[test]
3434    fn sort_unused_enum_members_by_path_line_parent_member() {
3435        let mut r = AnalysisResults::default();
3436        let mk = |path: &str, line: u32, parent: &str, member: &str| {
3437            UnusedEnumMemberFinding::with_actions(UnusedMember {
3438                path: PathBuf::from(path),
3439                parent_name: parent.to_string(),
3440                member_name: member.to_string(),
3441                kind: MemberKind::EnumMember,
3442                line,
3443                col: 0,
3444            })
3445        };
3446        r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
3447        r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
3448        r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
3449        r.sort();
3450        let keys: Vec<_> = r
3451            .unused_enum_members
3452            .iter()
3453            .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
3454            .collect();
3455        assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
3456    }
3457
3458    // ── sort: unused_class_members by path, line, parent, member
3459
3460    #[test]
3461    fn sort_unused_class_members() {
3462        let mut r = AnalysisResults::default();
3463        let mk = |path: &str, line: u32, parent: &str, member: &str| {
3464            UnusedClassMemberFinding::with_actions(UnusedMember {
3465                path: PathBuf::from(path),
3466                parent_name: parent.to_string(),
3467                member_name: member.to_string(),
3468                kind: MemberKind::ClassMethod,
3469                line,
3470                col: 0,
3471            })
3472        };
3473        r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
3474        r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
3475        r.sort();
3476        assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
3477        assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
3478    }
3479
3480    // ── sort: unresolved_imports by path, line, col, specifier ──
3481
3482    #[test]
3483    fn sort_unresolved_imports_by_path_line_col_specifier() {
3484        let mut r = AnalysisResults::default();
3485        let mk = |path: &str, line: u32, col: u32, spec: &str| {
3486            UnresolvedImportFinding::with_actions(UnresolvedImport {
3487                path: PathBuf::from(path),
3488                specifier: spec.to_string(),
3489                line,
3490                col,
3491                specifier_col: 0,
3492            })
3493        };
3494        r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
3495        r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
3496        r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
3497        r.sort();
3498        let specs: Vec<_> = r
3499            .unresolved_imports
3500            .iter()
3501            .map(|i| i.import.specifier.as_str())
3502            .collect();
3503        assert_eq!(specs, vec!["./m", "./a", "./z"]);
3504    }
3505
3506    // ── sort: unlisted_dependencies + inner imported_from ───────
3507
3508    #[test]
3509    fn sort_unlisted_dependencies_by_name_and_inner_sites() {
3510        let mut r = AnalysisResults::default();
3511        r.unlisted_dependencies
3512            .push(UnlistedDependencyFinding::with_actions(
3513                UnlistedDependency {
3514                    package_name: "zod".to_string(),
3515                    imported_from: vec![
3516                        ImportSite {
3517                            path: PathBuf::from("b.ts"),
3518                            line: 10,
3519                            col: 0,
3520                        },
3521                        ImportSite {
3522                            path: PathBuf::from("a.ts"),
3523                            line: 1,
3524                            col: 0,
3525                        },
3526                    ],
3527                },
3528            ));
3529        r.unlisted_dependencies
3530            .push(UnlistedDependencyFinding::with_actions(
3531                UnlistedDependency {
3532                    package_name: "axios".to_string(),
3533                    imported_from: vec![ImportSite {
3534                        path: PathBuf::from("c.ts"),
3535                        line: 1,
3536                        col: 0,
3537                    }],
3538                },
3539            ));
3540        r.sort();
3541
3542        // Outer sort: by package_name
3543        assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
3544        assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
3545
3546        // Inner sort: imported_from sorted by path, then line
3547        let zod_sites: Vec<_> = r.unlisted_dependencies[1]
3548            .dep
3549            .imported_from
3550            .iter()
3551            .map(|s| s.path.to_string_lossy().to_string())
3552            .collect();
3553        assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
3554    }
3555
3556    // ── sort: duplicate_exports + inner locations ───────────────
3557
3558    #[test]
3559    fn sort_duplicate_exports_by_name_and_inner_locations() {
3560        let mut r = AnalysisResults::default();
3561        r.duplicate_exports
3562            .push(DuplicateExportFinding::with_actions(DuplicateExport {
3563                export_name: "z".to_string(),
3564                locations: vec![
3565                    DuplicateLocation {
3566                        path: PathBuf::from("c.ts"),
3567                        line: 1,
3568                        col: 0,
3569                    },
3570                    DuplicateLocation {
3571                        path: PathBuf::from("a.ts"),
3572                        line: 5,
3573                        col: 0,
3574                    },
3575                ],
3576            }));
3577        r.duplicate_exports
3578            .push(DuplicateExportFinding::with_actions(DuplicateExport {
3579                export_name: "a".to_string(),
3580                locations: vec![DuplicateLocation {
3581                    path: PathBuf::from("b.ts"),
3582                    line: 1,
3583                    col: 0,
3584                }],
3585            }));
3586        r.sort();
3587
3588        // Outer sort: by export_name
3589        assert_eq!(r.duplicate_exports[0].export.export_name, "a");
3590        assert_eq!(r.duplicate_exports[1].export.export_name, "z");
3591
3592        // Inner sort: locations sorted by path, then line
3593        let z_locs: Vec<_> = r.duplicate_exports[1]
3594            .export
3595            .locations
3596            .iter()
3597            .map(|l| l.path.to_string_lossy().to_string())
3598            .collect();
3599        assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
3600    }
3601
3602    // ── sort: type_only_dependencies ────────────────────────────
3603
3604    #[test]
3605    fn sort_type_only_dependencies() {
3606        let mut r = AnalysisResults::default();
3607        r.type_only_dependencies
3608            .push(TypeOnlyDependencyFinding::with_actions(
3609                TypeOnlyDependency {
3610                    package_name: "zod".to_string(),
3611                    path: PathBuf::from("package.json"),
3612                    line: 10,
3613                },
3614            ));
3615        r.type_only_dependencies
3616            .push(TypeOnlyDependencyFinding::with_actions(
3617                TypeOnlyDependency {
3618                    package_name: "ajv".to_string(),
3619                    path: PathBuf::from("package.json"),
3620                    line: 5,
3621                },
3622            ));
3623        r.sort();
3624        assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
3625        assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
3626    }
3627
3628    // ── sort: test_only_dependencies ────────────────────────────
3629
3630    #[test]
3631    fn sort_test_only_dependencies() {
3632        let mut r = AnalysisResults::default();
3633        r.test_only_dependencies
3634            .push(TestOnlyDependencyFinding::with_actions(
3635                TestOnlyDependency {
3636                    package_name: "vitest".to_string(),
3637                    path: PathBuf::from("package.json"),
3638                    line: 15,
3639                },
3640            ));
3641        r.test_only_dependencies
3642            .push(TestOnlyDependencyFinding::with_actions(
3643                TestOnlyDependency {
3644                    package_name: "jest".to_string(),
3645                    path: PathBuf::from("package.json"),
3646                    line: 10,
3647                },
3648            ));
3649        r.sort();
3650        assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
3651        assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
3652    }
3653
3654    // ── sort: circular_dependencies by files, then length ───────
3655
3656    #[test]
3657    fn sort_circular_dependencies_by_files_then_length() {
3658        let mut r = AnalysisResults::default();
3659        r.circular_dependencies
3660            .push(CircularDependencyFinding::with_actions(
3661                CircularDependency {
3662                    files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
3663                    length: 2,
3664                    line: 1,
3665                    col: 0,
3666                    edges: Vec::new(),
3667                    is_cross_package: false,
3668                },
3669            ));
3670        r.circular_dependencies
3671            .push(CircularDependencyFinding::with_actions(
3672                CircularDependency {
3673                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
3674                    length: 2,
3675                    line: 1,
3676                    col: 0,
3677                    edges: Vec::new(),
3678                    is_cross_package: true,
3679                },
3680            ));
3681        r.sort();
3682        assert_eq!(
3683            r.circular_dependencies[0].cycle.files[0],
3684            PathBuf::from("a.ts")
3685        );
3686        assert_eq!(
3687            r.circular_dependencies[1].cycle.files[0],
3688            PathBuf::from("b.ts")
3689        );
3690    }
3691
3692    // ── sort: boundary_violations by from_path, line, col, to_path
3693
3694    #[test]
3695    fn sort_boundary_violations() {
3696        let mut r = AnalysisResults::default();
3697        let mk = |from: &str, line: u32, col: u32, to: &str| {
3698            BoundaryViolationFinding::with_actions(BoundaryViolation {
3699                from_path: PathBuf::from(from),
3700                to_path: PathBuf::from(to),
3701                from_zone: "a".to_string(),
3702                to_zone: "b".to_string(),
3703                import_specifier: to.to_string(),
3704                line,
3705                col,
3706            })
3707        };
3708        r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
3709        r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
3710        r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
3711        r.sort();
3712        let from_paths: Vec<_> = r
3713            .boundary_violations
3714            .iter()
3715            .map(|v| {
3716                format!(
3717                    "{}:{}",
3718                    v.violation.from_path.to_string_lossy(),
3719                    v.violation.line
3720                )
3721            })
3722            .collect();
3723        assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
3724    }
3725
3726    // ── sort: export_usages + inner reference_locations ─────────
3727
3728    #[test]
3729    fn sort_export_usages_and_inner_reference_locations() {
3730        let mut r = AnalysisResults::default();
3731        r.export_usages.push(ExportUsage {
3732            path: PathBuf::from("z.ts"),
3733            export_name: "foo".to_string(),
3734            line: 1,
3735            col: 0,
3736            reference_count: 2,
3737            reference_locations: vec![
3738                ReferenceLocation {
3739                    path: PathBuf::from("c.ts"),
3740                    line: 10,
3741                    col: 0,
3742                },
3743                ReferenceLocation {
3744                    path: PathBuf::from("a.ts"),
3745                    line: 5,
3746                    col: 0,
3747                },
3748            ],
3749        });
3750        r.export_usages.push(ExportUsage {
3751            path: PathBuf::from("a.ts"),
3752            export_name: "bar".to_string(),
3753            line: 1,
3754            col: 0,
3755            reference_count: 1,
3756            reference_locations: vec![ReferenceLocation {
3757                path: PathBuf::from("b.ts"),
3758                line: 1,
3759                col: 0,
3760            }],
3761        });
3762        r.sort();
3763
3764        // Outer sort: by path, then line, then export_name
3765        assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
3766        assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
3767
3768        // Inner sort: reference_locations sorted by path, line, col
3769        let refs: Vec<_> = r.export_usages[1]
3770            .reference_locations
3771            .iter()
3772            .map(|l| l.path.to_string_lossy().to_string())
3773            .collect();
3774        assert_eq!(refs, vec!["a.ts", "c.ts"]);
3775    }
3776
3777    // ── sort: empty results does not panic ──────────────────────
3778
3779    #[test]
3780    fn sort_empty_results_is_noop() {
3781        let mut r = AnalysisResults::default();
3782        r.sort(); // should not panic
3783        assert_eq!(r.total_issues(), 0);
3784    }
3785
3786    // ── sort: single-element lists remain stable ────────────────
3787
3788    #[test]
3789    fn sort_single_element_lists_stable() {
3790        let mut r = AnalysisResults::default();
3791        r.unused_files
3792            .push(UnusedFileFinding::with_actions(UnusedFile {
3793                path: PathBuf::from("only.ts"),
3794            }));
3795        r.sort();
3796        assert_eq!(r.unused_files[0].file.path, PathBuf::from("only.ts"));
3797    }
3798
3799    // ── serialization ──────────────────────────────────────────
3800
3801    #[test]
3802    fn serialize_empty_results() {
3803        let r = AnalysisResults::default();
3804        let json = serde_json::to_value(&r).unwrap();
3805
3806        // All arrays should be present and empty
3807        assert!(json["unused_files"].as_array().unwrap().is_empty());
3808        assert!(json["unused_exports"].as_array().unwrap().is_empty());
3809        assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
3810
3811        // Skipped fields should be absent
3812        assert!(json.get("export_usages").is_none());
3813        assert!(json.get("entry_point_summary").is_none());
3814    }
3815
3816    #[test]
3817    fn serialize_unused_file_path() {
3818        let r = UnusedFile {
3819            path: PathBuf::from("src/utils/index.ts"),
3820        };
3821        let json = serde_json::to_value(&r).unwrap();
3822        assert_eq!(json["path"], "src/utils/index.ts");
3823    }
3824
3825    #[test]
3826    fn serialize_dependency_location_camel_case() {
3827        let dep = UnusedDependency {
3828            package_name: "react".to_string(),
3829            location: DependencyLocation::DevDependencies,
3830            path: PathBuf::from("package.json"),
3831            line: 5,
3832            used_in_workspaces: Vec::new(),
3833        };
3834        let json = serde_json::to_value(&dep).unwrap();
3835        assert_eq!(json["location"], "devDependencies");
3836
3837        let dep2 = UnusedDependency {
3838            package_name: "react".to_string(),
3839            location: DependencyLocation::Dependencies,
3840            path: PathBuf::from("package.json"),
3841            line: 3,
3842            used_in_workspaces: Vec::new(),
3843        };
3844        let json2 = serde_json::to_value(&dep2).unwrap();
3845        assert_eq!(json2["location"], "dependencies");
3846
3847        let dep3 = UnusedDependency {
3848            package_name: "fsevents".to_string(),
3849            location: DependencyLocation::OptionalDependencies,
3850            path: PathBuf::from("package.json"),
3851            line: 7,
3852            used_in_workspaces: Vec::new(),
3853        };
3854        let json3 = serde_json::to_value(&dep3).unwrap();
3855        assert_eq!(json3["location"], "optionalDependencies");
3856    }
3857
3858    #[test]
3859    fn serialize_circular_dependency_skips_false_cross_package() {
3860        let cd = CircularDependency {
3861            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
3862            length: 2,
3863            line: 1,
3864            col: 0,
3865            edges: Vec::new(),
3866            is_cross_package: false,
3867        };
3868        let json = serde_json::to_value(&cd).unwrap();
3869        // skip_serializing_if = "std::ops::Not::not" means false is skipped
3870        assert!(json.get("is_cross_package").is_none());
3871    }
3872
3873    #[test]
3874    fn serialize_circular_dependency_includes_true_cross_package() {
3875        let cd = CircularDependency {
3876            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
3877            length: 2,
3878            line: 1,
3879            col: 0,
3880            edges: Vec::new(),
3881            is_cross_package: true,
3882        };
3883        let json = serde_json::to_value(&cd).unwrap();
3884        assert_eq!(json["is_cross_package"], true);
3885    }
3886
3887    #[test]
3888    fn serialize_unused_export_fields() {
3889        let e = UnusedExport {
3890            path: PathBuf::from("src/mod.ts"),
3891            export_name: "helper".to_string(),
3892            is_type_only: true,
3893            line: 42,
3894            col: 7,
3895            span_start: 100,
3896            is_re_export: true,
3897        };
3898        let json = serde_json::to_value(&e).unwrap();
3899        assert_eq!(json["path"], "src/mod.ts");
3900        assert_eq!(json["export_name"], "helper");
3901        assert_eq!(json["is_type_only"], true);
3902        assert_eq!(json["line"], 42);
3903        assert_eq!(json["col"], 7);
3904        assert_eq!(json["span_start"], 100);
3905        assert_eq!(json["is_re_export"], true);
3906    }
3907
3908    #[test]
3909    fn serialize_boundary_violation_fields() {
3910        let v = BoundaryViolation {
3911            from_path: PathBuf::from("src/ui/button.tsx"),
3912            to_path: PathBuf::from("src/db/queries.ts"),
3913            from_zone: "ui".to_string(),
3914            to_zone: "db".to_string(),
3915            import_specifier: "../db/queries".to_string(),
3916            line: 3,
3917            col: 0,
3918        };
3919        let json = serde_json::to_value(&v).unwrap();
3920        assert_eq!(json["from_path"], "src/ui/button.tsx");
3921        assert_eq!(json["to_path"], "src/db/queries.ts");
3922        assert_eq!(json["from_zone"], "ui");
3923        assert_eq!(json["to_zone"], "db");
3924        assert_eq!(json["import_specifier"], "../db/queries");
3925    }
3926
3927    #[test]
3928    fn serialize_unlisted_dependency_with_import_sites() {
3929        let d = UnlistedDependency {
3930            package_name: "chalk".to_string(),
3931            imported_from: vec![
3932                ImportSite {
3933                    path: PathBuf::from("a.ts"),
3934                    line: 1,
3935                    col: 0,
3936                },
3937                ImportSite {
3938                    path: PathBuf::from("b.ts"),
3939                    line: 5,
3940                    col: 3,
3941                },
3942            ],
3943        };
3944        let json = serde_json::to_value(&d).unwrap();
3945        assert_eq!(json["package_name"], "chalk");
3946        let sites = json["imported_from"].as_array().unwrap();
3947        assert_eq!(sites.len(), 2);
3948        assert_eq!(sites[0]["path"], "a.ts");
3949        assert_eq!(sites[1]["line"], 5);
3950    }
3951
3952    #[test]
3953    fn serialize_duplicate_export_with_locations() {
3954        let d = DuplicateExport {
3955            export_name: "Button".to_string(),
3956            locations: vec![
3957                DuplicateLocation {
3958                    path: PathBuf::from("src/a.ts"),
3959                    line: 10,
3960                    col: 0,
3961                },
3962                DuplicateLocation {
3963                    path: PathBuf::from("src/b.ts"),
3964                    line: 20,
3965                    col: 5,
3966                },
3967            ],
3968        };
3969        let json = serde_json::to_value(&d).unwrap();
3970        assert_eq!(json["export_name"], "Button");
3971        let locs = json["locations"].as_array().unwrap();
3972        assert_eq!(locs.len(), 2);
3973        assert_eq!(locs[0]["line"], 10);
3974        assert_eq!(locs[1]["col"], 5);
3975    }
3976
3977    #[test]
3978    fn serialize_type_only_dependency() {
3979        let d = TypeOnlyDependency {
3980            package_name: "@types/react".to_string(),
3981            path: PathBuf::from("package.json"),
3982            line: 12,
3983        };
3984        let json = serde_json::to_value(&d).unwrap();
3985        assert_eq!(json["package_name"], "@types/react");
3986        assert_eq!(json["line"], 12);
3987    }
3988
3989    #[test]
3990    fn serialize_test_only_dependency() {
3991        let d = TestOnlyDependency {
3992            package_name: "vitest".to_string(),
3993            path: PathBuf::from("package.json"),
3994            line: 8,
3995        };
3996        let json = serde_json::to_value(&d).unwrap();
3997        assert_eq!(json["package_name"], "vitest");
3998        assert_eq!(json["line"], 8);
3999    }
4000
4001    #[test]
4002    fn serialize_unused_member() {
4003        let m = UnusedMember {
4004            path: PathBuf::from("enums.ts"),
4005            parent_name: "Status".to_string(),
4006            member_name: "Pending".to_string(),
4007            kind: MemberKind::EnumMember,
4008            line: 3,
4009            col: 4,
4010        };
4011        let json = serde_json::to_value(&m).unwrap();
4012        assert_eq!(json["parent_name"], "Status");
4013        assert_eq!(json["member_name"], "Pending");
4014        assert_eq!(json["line"], 3);
4015    }
4016
4017    #[test]
4018    fn serialize_unresolved_import() {
4019        let i = UnresolvedImport {
4020            path: PathBuf::from("app.ts"),
4021            specifier: "./missing-module".to_string(),
4022            line: 7,
4023            col: 0,
4024            specifier_col: 21,
4025        };
4026        let json = serde_json::to_value(&i).unwrap();
4027        assert_eq!(json["specifier"], "./missing-module");
4028        assert_eq!(json["specifier_col"], 21);
4029    }
4030
4031    // ── deserialize: CircularDependency serde(default) fields ──
4032
4033    #[test]
4034    fn deserialize_circular_dependency_with_defaults() {
4035        // CircularDependency derives Deserialize; line/col/is_cross_package have #[serde(default)]
4036        let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
4037        let cd: CircularDependency = serde_json::from_str(json).unwrap();
4038        assert_eq!(cd.files.len(), 2);
4039        assert_eq!(cd.length, 2);
4040        assert_eq!(cd.line, 0);
4041        assert_eq!(cd.col, 0);
4042        assert!(!cd.is_cross_package);
4043    }
4044
4045    #[test]
4046    fn deserialize_circular_dependency_with_all_fields() {
4047        let json =
4048            r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
4049        let cd: CircularDependency = serde_json::from_str(json).unwrap();
4050        assert_eq!(cd.line, 5);
4051        assert_eq!(cd.col, 10);
4052        assert!(cd.is_cross_package);
4053    }
4054
4055    // ── clone produces independent copies ───────────────────────
4056
4057    #[test]
4058    fn clone_results_are_independent() {
4059        let mut r = AnalysisResults::default();
4060        r.unused_files
4061            .push(UnusedFileFinding::with_actions(UnusedFile {
4062                path: PathBuf::from("a.ts"),
4063            }));
4064        let mut cloned = r.clone();
4065        cloned
4066            .unused_files
4067            .push(UnusedFileFinding::with_actions(UnusedFile {
4068                path: PathBuf::from("b.ts"),
4069            }));
4070        assert_eq!(r.total_issues(), 1);
4071        assert_eq!(cloned.total_issues(), 2);
4072    }
4073
4074    // ── export_usages not counted in total_issues ───────────────
4075
4076    #[test]
4077    fn export_usages_not_counted_in_total_issues() {
4078        let mut r = AnalysisResults::default();
4079        r.export_usages.push(ExportUsage {
4080            path: PathBuf::from("mod.ts"),
4081            export_name: "foo".to_string(),
4082            line: 1,
4083            col: 0,
4084            reference_count: 3,
4085            reference_locations: vec![],
4086        });
4087        // export_usages is metadata, not an issue type
4088        assert_eq!(r.total_issues(), 0);
4089        assert!(!r.has_issues());
4090    }
4091
4092    // ── entry_point_summary not counted in total_issues ─────────
4093
4094    #[test]
4095    fn entry_point_summary_not_counted_in_total_issues() {
4096        let r = AnalysisResults {
4097            entry_point_summary: Some(EntryPointSummary {
4098                total: 10,
4099                by_source: vec![("config".to_string(), 10)],
4100            }),
4101            ..AnalysisResults::default()
4102        };
4103        assert_eq!(r.total_issues(), 0);
4104        assert!(!r.has_issues());
4105    }
4106}