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