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