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