Skip to main content

fallow_types/
results.rs

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