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