Skip to main content

fallow_types/
results.rs

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