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: pattern match with some ambiguity.
3911    Medium,
3912    /// High confidence: unambiguous pattern (env vars, direct SDK calls).
3913    High,
3914}
3915
3916/// A detected feature flag use site.
3917#[derive(Debug, Clone, Serialize, Deserialize)]
3918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3919pub struct FeatureFlag {
3920    /// File containing the feature flag usage.
3921    #[serde(serialize_with = "serde_path::serialize")]
3922    pub path: PathBuf,
3923    /// Name or identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
3924    pub flag_name: String,
3925    /// How the flag was detected.
3926    pub kind: FlagKind,
3927    /// Detection confidence level.
3928    pub confidence: FlagConfidence,
3929    /// 1-based line number.
3930    pub line: u32,
3931    /// 0-based byte column offset.
3932    pub col: u32,
3933    /// Start byte offset of the guarded code block (if-branch span), if detected.
3934    #[serde(skip)]
3935    pub guard_span_start: Option<u32>,
3936    /// End byte offset of the guarded code block (if-branch span), if detected.
3937    #[serde(skip)]
3938    pub guard_span_end: Option<u32>,
3939    /// SDK or provider name (e.g., "LaunchDarkly", "Statsig"), if detected from SDK call.
3940    #[serde(default, skip_serializing_if = "Option::is_none")]
3941    pub sdk_name: Option<String>,
3942    /// Line range of the guarded code block (derived from guard_span + line_offsets).
3943    /// Used for cross-reference with dead code findings.
3944    #[serde(skip)]
3945    pub guard_line_start: Option<u32>,
3946    /// End line of the guarded code block.
3947    #[serde(skip)]
3948    pub guard_line_end: Option<u32>,
3949    /// Unused exports found within the guarded code block.
3950    /// Populated by cross-reference with dead code analysis.
3951    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3952    pub guarded_dead_exports: Vec<String>,
3953}
3954
3955// Size assertion: FeatureFlag is stored in a Vec per analysis run.
3956const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
3957
3958/// Usage count for an export symbol. Used by the LSP Code Lens to show
3959/// reference counts above each export declaration.
3960#[derive(Debug, Clone, Serialize, Deserialize)]
3961#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3962pub struct ExportUsage {
3963    /// File containing the export.
3964    #[serde(serialize_with = "serde_path::serialize")]
3965    pub path: PathBuf,
3966    /// Name of the exported symbol.
3967    pub export_name: String,
3968    /// 1-based line number.
3969    pub line: u32,
3970    /// 0-based byte column offset.
3971    pub col: u32,
3972    /// Number of files that reference this export.
3973    pub reference_count: usize,
3974    /// Locations where this export is referenced. Used by the LSP Code Lens
3975    /// to enable click-to-navigate via `editor.action.showReferences`.
3976    pub reference_locations: Vec<ReferenceLocation>,
3977}
3978
3979/// A location where an export is referenced (import site in another file).
3980#[derive(Debug, Clone, Serialize, Deserialize)]
3981#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3982pub struct ReferenceLocation {
3983    /// File containing the import that references the export.
3984    #[serde(serialize_with = "serde_path::serialize")]
3985    pub path: PathBuf,
3986    /// 1-based line number.
3987    pub line: u32,
3988    /// 0-based byte column offset.
3989    pub col: u32,
3990}
3991
3992#[cfg(test)]
3993mod tests {
3994    use super::*;
3995    use crate::output_dead_code::{
3996        BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
3997        UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
3998        UnusedTypeFinding,
3999    };
4000
4001    #[test]
4002    fn empty_results_no_issues() {
4003        let results = AnalysisResults::default();
4004        assert_eq!(results.total_issues(), 0);
4005        assert!(!results.has_issues());
4006    }
4007
4008    #[test]
4009    fn results_with_unused_file() {
4010        let mut results = AnalysisResults::default();
4011        results
4012            .unused_files
4013            .push(UnusedFileFinding::with_actions(UnusedFile {
4014                path: PathBuf::from("test.ts"),
4015            }));
4016        assert_eq!(results.total_issues(), 1);
4017        assert!(results.has_issues());
4018    }
4019
4020    #[test]
4021    fn results_with_unused_export() {
4022        let mut results = AnalysisResults::default();
4023        results
4024            .unused_exports
4025            .push(UnusedExportFinding::with_actions(UnusedExport {
4026                path: PathBuf::from("test.ts"),
4027                export_name: "foo".to_string(),
4028                is_type_only: false,
4029                line: 1,
4030                col: 0,
4031                span_start: 0,
4032                is_re_export: false,
4033                deprecated: false,
4034                deprecated_reason: None,
4035            }));
4036        assert_eq!(results.total_issues(), 1);
4037        assert!(results.has_issues());
4038    }
4039
4040    #[test]
4041    fn merge_into_appends_counts_and_preserves_existing_optional_metadata() {
4042        let framework_contract = crate::semantic::SemanticFrameworkContract {
4043            framework: "lit".to_string(),
4044            package: "lit".to_string(),
4045            heritage_symbol: "LitElement".to_string(),
4046            heritage_names: vec!["LitElement".to_string()],
4047            relation: crate::semantic::SemanticFrameworkRelation::Extends,
4048            members: vec!["render".to_string()],
4049        };
4050        let mut target = AnalysisResults {
4051            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4052                path: PathBuf::from("a.ts"),
4053            })],
4054            suppression_count: 2,
4055            security_unresolved_edge_files: 1,
4056            security_unresolved_callee_sites: 3,
4057            entry_point_summary: Some(EntryPointSummary {
4058                total: 1,
4059                by_source: vec![("existing".to_string(), 1)],
4060            }),
4061            semantic_framework_contracts: vec![framework_contract.clone()],
4062            ..AnalysisResults::default()
4063        };
4064        let source = AnalysisResults {
4065            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4066                path: PathBuf::from("b.ts"),
4067            })],
4068            suppression_count: 4,
4069            security_unresolved_edge_files: 5,
4070            security_unresolved_callee_sites: 6,
4071            unused_load_data_keys_global_abstain: true,
4072            entry_point_summary: Some(EntryPointSummary {
4073                total: 1,
4074                by_source: vec![("incoming".to_string(), 1)],
4075            }),
4076            render_fan_in: Some(RenderFanInMetric::default()),
4077            semantic_framework_contracts: vec![framework_contract],
4078            ..AnalysisResults::default()
4079        };
4080
4081        target.merge_into(source);
4082
4083        assert_eq!(target.unused_files.len(), 2);
4084        assert_eq!(target.suppression_count, 6);
4085        assert_eq!(target.security_unresolved_edge_files, 6);
4086        assert_eq!(target.security_unresolved_callee_sites, 9);
4087        assert!(target.unused_load_data_keys_global_abstain);
4088        assert_eq!(
4089            target
4090                .entry_point_summary
4091                .as_ref()
4092                .map(|summary| summary.total),
4093            Some(1)
4094        );
4095        assert_eq!(
4096            target
4097                .entry_point_summary
4098                .as_ref()
4099                .and_then(|summary| summary.by_source.first())
4100                .map(|(name, _)| name.as_str()),
4101            Some("existing")
4102        );
4103        assert!(target.render_fan_in.is_some());
4104        assert_eq!(target.semantic_framework_contracts.len(), 1);
4105    }
4106
4107    fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
4108        UnusedExport {
4109            path: PathBuf::from(path),
4110            export_name: export_name.to_string(),
4111            is_type_only,
4112            line: 1,
4113            col: 0,
4114            span_start: 0,
4115            is_re_export: false,
4116            deprecated: false,
4117            deprecated_reason: None,
4118        }
4119    }
4120
4121    fn test_unused_dependency(
4122        package_name: &str,
4123        location: DependencyLocation,
4124    ) -> UnusedDependency {
4125        UnusedDependency {
4126            package_name: package_name.to_string(),
4127            location,
4128            path: PathBuf::from("package.json"),
4129            line: 5,
4130            used_in_workspaces: Vec::new(),
4131        }
4132    }
4133
4134    fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
4135        UnusedMember {
4136            path: PathBuf::from("members.ts"),
4137            parent_name: "Parent".to_string(),
4138            member_name: member_name.to_string(),
4139            kind,
4140            line: 1,
4141            col: 0,
4142        }
4143    }
4144
4145    #[test]
4146    fn results_total_counts_all_types() {
4147        let results = AnalysisResults {
4148            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4149                path: PathBuf::from("a.ts"),
4150            })],
4151            unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
4152                "b.ts", "x", false,
4153            ))],
4154            unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
4155                "c.ts", "T", true,
4156            ))],
4157            unused_dependencies: vec![UnusedDependencyFinding::with_actions(
4158                test_unused_dependency("dep", DependencyLocation::Dependencies),
4159            )],
4160            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
4161                test_unused_dependency("dev", DependencyLocation::DevDependencies),
4162            )],
4163            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
4164                "A",
4165                MemberKind::EnumMember,
4166            ))],
4167            unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
4168                "m",
4169                MemberKind::ClassMethod,
4170            ))],
4171            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
4172                path: PathBuf::from("f.ts"),
4173                specifier: "./missing".to_string(),
4174                line: 1,
4175                col: 0,
4176                specifier_col: 0,
4177            })],
4178            unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
4179                UnlistedDependency {
4180                    package_name: "unlisted".to_string(),
4181                    imported_from: vec![ImportSite {
4182                        path: PathBuf::from("g.ts"),
4183                        line: 1,
4184                        col: 0,
4185                    }],
4186                },
4187            )],
4188            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
4189                export_name: "dup".to_string(),
4190                locations: vec![
4191                    DuplicateLocation {
4192                        path: PathBuf::from("h.ts"),
4193                        line: 15,
4194                        col: 0,
4195                    },
4196                    DuplicateLocation {
4197                        path: PathBuf::from("i.ts"),
4198                        line: 30,
4199                        col: 0,
4200                    },
4201                ],
4202            })],
4203            unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
4204                test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
4205            )],
4206            type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
4207                TypeOnlyDependency {
4208                    package_name: "type-only".to_string(),
4209                    path: PathBuf::from("package.json"),
4210                    line: 8,
4211                },
4212            )],
4213            test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
4214                TestOnlyDependency {
4215                    package_name: "test-only".to_string(),
4216                    path: PathBuf::from("package.json"),
4217                    line: 9,
4218                },
4219            )],
4220            circular_dependencies: vec![CircularDependencyFinding::with_actions(
4221                CircularDependency {
4222                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4223                    length: 2,
4224                    line: 3,
4225                    col: 0,
4226                    edges: Vec::new(),
4227                    is_cross_package: false,
4228                },
4229            )],
4230            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
4231                from_path: PathBuf::from("src/ui/Button.tsx"),
4232                to_path: PathBuf::from("src/db/queries.ts"),
4233                from_zone: "ui".to_string(),
4234                to_zone: "database".to_string(),
4235                import_specifier: "../db/queries".to_string(),
4236                line: 3,
4237                col: 0,
4238            })],
4239            ..Default::default()
4240        };
4241
4242        // 15 categories, one of each
4243        assert_eq!(results.total_issues(), 15);
4244        assert!(results.has_issues());
4245    }
4246
4247    // ── total_issues counts each category independently ─────────
4248
4249    #[test]
4250    fn total_issues_sums_all_categories_independently() {
4251        let mut results = AnalysisResults::default();
4252        results
4253            .unused_files
4254            .push(UnusedFileFinding::with_actions(UnusedFile {
4255                path: PathBuf::from("a.ts"),
4256            }));
4257        assert_eq!(results.total_issues(), 1);
4258
4259        results
4260            .unused_files
4261            .push(UnusedFileFinding::with_actions(UnusedFile {
4262                path: PathBuf::from("b.ts"),
4263            }));
4264        assert_eq!(results.total_issues(), 2);
4265
4266        results
4267            .unresolved_imports
4268            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
4269                path: PathBuf::from("c.ts"),
4270                specifier: "./missing".to_string(),
4271                line: 1,
4272                col: 0,
4273                specifier_col: 0,
4274            }));
4275        assert_eq!(results.total_issues(), 3);
4276    }
4277
4278    // ── sort: unused_files by path ──────────────────────────────
4279
4280    #[test]
4281    fn sort_unused_files_by_path() {
4282        let mut r = AnalysisResults::default();
4283        r.unused_files
4284            .push(UnusedFileFinding::with_actions(UnusedFile {
4285                path: PathBuf::from("z.ts"),
4286            }));
4287        r.unused_files
4288            .push(UnusedFileFinding::with_actions(UnusedFile {
4289                path: PathBuf::from("a.ts"),
4290            }));
4291        r.unused_files
4292            .push(UnusedFileFinding::with_actions(UnusedFile {
4293                path: PathBuf::from("m.ts"),
4294            }));
4295        r.sort();
4296        let paths: Vec<_> = r
4297            .unused_files
4298            .iter()
4299            .map(|f| f.file.path.to_string_lossy().to_string())
4300            .collect();
4301        assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
4302    }
4303
4304    // ── sort: unused_exports by path, line, name ────────────────
4305
4306    #[test]
4307    fn sort_unused_exports_by_path_line_name() {
4308        let mut r = AnalysisResults::default();
4309        let mk = |path: &str, line: u32, name: &str| {
4310            UnusedExportFinding::with_actions(UnusedExport {
4311                path: PathBuf::from(path),
4312                export_name: name.to_string(),
4313                is_type_only: false,
4314                line,
4315                col: 0,
4316                span_start: 0,
4317                is_re_export: false,
4318                deprecated: false,
4319                deprecated_reason: None,
4320            })
4321        };
4322        r.unused_exports.push(mk("b.ts", 5, "beta"));
4323        r.unused_exports.push(mk("a.ts", 10, "zeta"));
4324        r.unused_exports.push(mk("a.ts", 10, "alpha"));
4325        r.unused_exports.push(mk("a.ts", 1, "gamma"));
4326        r.sort();
4327        let keys: Vec<_> = r
4328            .unused_exports
4329            .iter()
4330            .map(|e| {
4331                format!(
4332                    "{}:{}:{}",
4333                    e.export.path.to_string_lossy(),
4334                    e.export.line,
4335                    e.export.export_name
4336                )
4337            })
4338            .collect();
4339        assert_eq!(
4340            keys,
4341            vec![
4342                "a.ts:1:gamma",
4343                "a.ts:10:alpha",
4344                "a.ts:10:zeta",
4345                "b.ts:5:beta"
4346            ]
4347        );
4348    }
4349
4350    // ── sort: unused_types (same sort as unused_exports) ────────
4351
4352    #[test]
4353    fn sort_unused_types_by_path_line_name() {
4354        let mut r = AnalysisResults::default();
4355        let mk = |path: &str, line: u32, name: &str| {
4356            UnusedTypeFinding::with_actions(UnusedExport {
4357                path: PathBuf::from(path),
4358                export_name: name.to_string(),
4359                is_type_only: true,
4360                line,
4361                col: 0,
4362                span_start: 0,
4363                is_re_export: false,
4364                deprecated: false,
4365                deprecated_reason: None,
4366            })
4367        };
4368        r.unused_types.push(mk("z.ts", 1, "Z"));
4369        r.unused_types.push(mk("a.ts", 1, "A"));
4370        r.sort();
4371        assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
4372        assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
4373    }
4374
4375    // ── sort: unused_dependencies by path, line, name ───────────
4376
4377    #[test]
4378    fn sort_unused_dependencies_by_path_line_name() {
4379        let mut r = AnalysisResults::default();
4380        let mk = |path: &str, line: u32, name: &str| {
4381            UnusedDependencyFinding::with_actions(UnusedDependency {
4382                package_name: name.to_string(),
4383                location: DependencyLocation::Dependencies,
4384                path: PathBuf::from(path),
4385                line,
4386                used_in_workspaces: Vec::new(),
4387            })
4388        };
4389        r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
4390        r.unused_dependencies.push(mk("a/package.json", 5, "react"));
4391        r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
4392        r.sort();
4393        let names: Vec<_> = r
4394            .unused_dependencies
4395            .iter()
4396            .map(|d| d.dep.package_name.as_str())
4397            .collect();
4398        assert_eq!(names, vec!["axios", "react", "zlib"]);
4399    }
4400
4401    // ── sort: unused_dev_dependencies ───────────────────────────
4402
4403    #[test]
4404    fn sort_unused_dev_dependencies() {
4405        let mut r = AnalysisResults::default();
4406        r.unused_dev_dependencies
4407            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
4408                package_name: "vitest".to_string(),
4409                location: DependencyLocation::DevDependencies,
4410                path: PathBuf::from("package.json"),
4411                line: 10,
4412                used_in_workspaces: Vec::new(),
4413            }));
4414        r.unused_dev_dependencies
4415            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
4416                package_name: "jest".to_string(),
4417                location: DependencyLocation::DevDependencies,
4418                path: PathBuf::from("package.json"),
4419                line: 5,
4420                used_in_workspaces: Vec::new(),
4421            }));
4422        r.sort();
4423        assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
4424        assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
4425    }
4426
4427    // ── sort: unused_optional_dependencies ──────────────────────
4428
4429    #[test]
4430    fn sort_unused_optional_dependencies() {
4431        let mut r = AnalysisResults::default();
4432        r.unused_optional_dependencies
4433            .push(UnusedOptionalDependencyFinding::with_actions(
4434                UnusedDependency {
4435                    package_name: "zod".to_string(),
4436                    location: DependencyLocation::OptionalDependencies,
4437                    path: PathBuf::from("package.json"),
4438                    line: 3,
4439                    used_in_workspaces: Vec::new(),
4440                },
4441            ));
4442        r.unused_optional_dependencies
4443            .push(UnusedOptionalDependencyFinding::with_actions(
4444                UnusedDependency {
4445                    package_name: "ajv".to_string(),
4446                    location: DependencyLocation::OptionalDependencies,
4447                    path: PathBuf::from("package.json"),
4448                    line: 2,
4449                    used_in_workspaces: Vec::new(),
4450                },
4451            ));
4452        r.sort();
4453        assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
4454        assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
4455    }
4456
4457    // ── sort: unused_enum_members by path, line, parent, member ─
4458
4459    #[test]
4460    fn sort_unused_enum_members_by_path_line_parent_member() {
4461        let mut r = AnalysisResults::default();
4462        let mk = |path: &str, line: u32, parent: &str, member: &str| {
4463            UnusedEnumMemberFinding::with_actions(UnusedMember {
4464                path: PathBuf::from(path),
4465                parent_name: parent.to_string(),
4466                member_name: member.to_string(),
4467                kind: MemberKind::EnumMember,
4468                line,
4469                col: 0,
4470            })
4471        };
4472        r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
4473        r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
4474        r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
4475        r.sort();
4476        let keys: Vec<_> = r
4477            .unused_enum_members
4478            .iter()
4479            .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
4480            .collect();
4481        assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
4482    }
4483
4484    // ── sort: unused_class_members by path, line, parent, member
4485
4486    #[test]
4487    fn sort_unused_class_members() {
4488        let mut r = AnalysisResults::default();
4489        let mk = |path: &str, line: u32, parent: &str, member: &str| {
4490            UnusedClassMemberFinding::with_actions(UnusedMember {
4491                path: PathBuf::from(path),
4492                parent_name: parent.to_string(),
4493                member_name: member.to_string(),
4494                kind: MemberKind::ClassMethod,
4495                line,
4496                col: 0,
4497            })
4498        };
4499        r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
4500        r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
4501        r.sort();
4502        assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
4503        assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
4504    }
4505
4506    // ── sort: unresolved_imports by path, line, col, specifier ──
4507
4508    #[test]
4509    fn sort_unresolved_imports_by_path_line_col_specifier() {
4510        let mut r = AnalysisResults::default();
4511        let mk = |path: &str, line: u32, col: u32, spec: &str| {
4512            UnresolvedImportFinding::with_actions(UnresolvedImport {
4513                path: PathBuf::from(path),
4514                specifier: spec.to_string(),
4515                line,
4516                col,
4517                specifier_col: 0,
4518            })
4519        };
4520        r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
4521        r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
4522        r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
4523        r.sort();
4524        let specs: Vec<_> = r
4525            .unresolved_imports
4526            .iter()
4527            .map(|i| i.import.specifier.as_str())
4528            .collect();
4529        assert_eq!(specs, vec!["./m", "./a", "./z"]);
4530    }
4531
4532    // ── sort: unlisted_dependencies + inner imported_from ───────
4533
4534    #[test]
4535    fn sort_unlisted_dependencies_by_name_and_inner_sites() {
4536        let mut r = AnalysisResults::default();
4537        r.unlisted_dependencies
4538            .push(UnlistedDependencyFinding::with_actions(
4539                UnlistedDependency {
4540                    package_name: "zod".to_string(),
4541                    imported_from: vec![
4542                        ImportSite {
4543                            path: PathBuf::from("b.ts"),
4544                            line: 10,
4545                            col: 0,
4546                        },
4547                        ImportSite {
4548                            path: PathBuf::from("a.ts"),
4549                            line: 1,
4550                            col: 0,
4551                        },
4552                    ],
4553                },
4554            ));
4555        r.unlisted_dependencies
4556            .push(UnlistedDependencyFinding::with_actions(
4557                UnlistedDependency {
4558                    package_name: "axios".to_string(),
4559                    imported_from: vec![ImportSite {
4560                        path: PathBuf::from("c.ts"),
4561                        line: 1,
4562                        col: 0,
4563                    }],
4564                },
4565            ));
4566        r.sort();
4567
4568        // Outer sort: by package_name
4569        assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
4570        assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
4571
4572        // Inner sort: imported_from sorted by path, then line
4573        let zod_sites: Vec<_> = r.unlisted_dependencies[1]
4574            .dep
4575            .imported_from
4576            .iter()
4577            .map(|s| s.path.to_string_lossy().to_string())
4578            .collect();
4579        assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
4580    }
4581
4582    // ── sort: duplicate_exports + inner locations ───────────────
4583
4584    #[test]
4585    fn sort_duplicate_exports_by_name_and_inner_locations() {
4586        let mut r = AnalysisResults::default();
4587        r.duplicate_exports
4588            .push(DuplicateExportFinding::with_actions(DuplicateExport {
4589                export_name: "z".to_string(),
4590                locations: vec![
4591                    DuplicateLocation {
4592                        path: PathBuf::from("c.ts"),
4593                        line: 1,
4594                        col: 0,
4595                    },
4596                    DuplicateLocation {
4597                        path: PathBuf::from("a.ts"),
4598                        line: 5,
4599                        col: 0,
4600                    },
4601                ],
4602            }));
4603        r.duplicate_exports
4604            .push(DuplicateExportFinding::with_actions(DuplicateExport {
4605                export_name: "a".to_string(),
4606                locations: vec![DuplicateLocation {
4607                    path: PathBuf::from("b.ts"),
4608                    line: 1,
4609                    col: 0,
4610                }],
4611            }));
4612        r.sort();
4613
4614        // Outer sort: by export_name
4615        assert_eq!(r.duplicate_exports[0].export.export_name, "a");
4616        assert_eq!(r.duplicate_exports[1].export.export_name, "z");
4617
4618        // Inner sort: locations sorted by path, then line
4619        let z_locs: Vec<_> = r.duplicate_exports[1]
4620            .export
4621            .locations
4622            .iter()
4623            .map(|l| l.path.to_string_lossy().to_string())
4624            .collect();
4625        assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
4626    }
4627
4628    // ── sort: type_only_dependencies ────────────────────────────
4629
4630    #[test]
4631    fn sort_type_only_dependencies() {
4632        let mut r = AnalysisResults::default();
4633        r.type_only_dependencies
4634            .push(TypeOnlyDependencyFinding::with_actions(
4635                TypeOnlyDependency {
4636                    package_name: "zod".to_string(),
4637                    path: PathBuf::from("package.json"),
4638                    line: 10,
4639                },
4640            ));
4641        r.type_only_dependencies
4642            .push(TypeOnlyDependencyFinding::with_actions(
4643                TypeOnlyDependency {
4644                    package_name: "ajv".to_string(),
4645                    path: PathBuf::from("package.json"),
4646                    line: 5,
4647                },
4648            ));
4649        r.sort();
4650        assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
4651        assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
4652    }
4653
4654    // ── sort: test_only_dependencies ────────────────────────────
4655
4656    #[test]
4657    fn sort_test_only_dependencies() {
4658        let mut r = AnalysisResults::default();
4659        r.test_only_dependencies
4660            .push(TestOnlyDependencyFinding::with_actions(
4661                TestOnlyDependency {
4662                    package_name: "vitest".to_string(),
4663                    path: PathBuf::from("package.json"),
4664                    line: 15,
4665                },
4666            ));
4667        r.test_only_dependencies
4668            .push(TestOnlyDependencyFinding::with_actions(
4669                TestOnlyDependency {
4670                    package_name: "jest".to_string(),
4671                    path: PathBuf::from("package.json"),
4672                    line: 10,
4673                },
4674            ));
4675        r.sort();
4676        assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
4677        assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
4678    }
4679
4680    // ── sort: circular_dependencies by files, then length ───────
4681
4682    #[test]
4683    fn sort_circular_dependencies_by_files_then_length() {
4684        let mut r = AnalysisResults::default();
4685        r.circular_dependencies
4686            .push(CircularDependencyFinding::with_actions(
4687                CircularDependency {
4688                    files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
4689                    length: 2,
4690                    line: 1,
4691                    col: 0,
4692                    edges: Vec::new(),
4693                    is_cross_package: false,
4694                },
4695            ));
4696        r.circular_dependencies
4697            .push(CircularDependencyFinding::with_actions(
4698                CircularDependency {
4699                    files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4700                    length: 2,
4701                    line: 1,
4702                    col: 0,
4703                    edges: Vec::new(),
4704                    is_cross_package: true,
4705                },
4706            ));
4707        r.sort();
4708        assert_eq!(
4709            r.circular_dependencies[0].cycle.files[0],
4710            PathBuf::from("a.ts")
4711        );
4712        assert_eq!(
4713            r.circular_dependencies[1].cycle.files[0],
4714            PathBuf::from("b.ts")
4715        );
4716    }
4717
4718    // ── sort: boundary_violations by from_path, line, col, to_path
4719
4720    #[test]
4721    fn sort_boundary_violations() {
4722        let mut r = AnalysisResults::default();
4723        let mk = |from: &str, line: u32, col: u32, to: &str| {
4724            BoundaryViolationFinding::with_actions(BoundaryViolation {
4725                from_path: PathBuf::from(from),
4726                to_path: PathBuf::from(to),
4727                from_zone: "a".to_string(),
4728                to_zone: "b".to_string(),
4729                import_specifier: to.to_string(),
4730                line,
4731                col,
4732            })
4733        };
4734        r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
4735        r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
4736        r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
4737        r.sort();
4738        let from_paths: Vec<_> = r
4739            .boundary_violations
4740            .iter()
4741            .map(|v| {
4742                format!(
4743                    "{}:{}",
4744                    v.violation.from_path.to_string_lossy(),
4745                    v.violation.line
4746                )
4747            })
4748            .collect();
4749        assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
4750    }
4751
4752    // ── sort: export_usages + inner reference_locations ─────────
4753
4754    #[test]
4755    fn sort_export_usages_and_inner_reference_locations() {
4756        let mut r = AnalysisResults::default();
4757        r.export_usages.push(ExportUsage {
4758            path: PathBuf::from("z.ts"),
4759            export_name: "foo".to_string(),
4760            line: 1,
4761            col: 0,
4762            reference_count: 2,
4763            reference_locations: vec![
4764                ReferenceLocation {
4765                    path: PathBuf::from("c.ts"),
4766                    line: 10,
4767                    col: 0,
4768                },
4769                ReferenceLocation {
4770                    path: PathBuf::from("a.ts"),
4771                    line: 5,
4772                    col: 0,
4773                },
4774            ],
4775        });
4776        r.export_usages.push(ExportUsage {
4777            path: PathBuf::from("a.ts"),
4778            export_name: "bar".to_string(),
4779            line: 1,
4780            col: 0,
4781            reference_count: 1,
4782            reference_locations: vec![ReferenceLocation {
4783                path: PathBuf::from("b.ts"),
4784                line: 1,
4785                col: 0,
4786            }],
4787        });
4788        r.sort();
4789
4790        // Outer sort: by path, then line, then export_name
4791        assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
4792        assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
4793
4794        // Inner sort: reference_locations sorted by path, line, col
4795        let refs: Vec<_> = r.export_usages[1]
4796            .reference_locations
4797            .iter()
4798            .map(|l| l.path.to_string_lossy().to_string())
4799            .collect();
4800        assert_eq!(refs, vec!["a.ts", "c.ts"]);
4801    }
4802
4803    // ── serialization ──────────────────────────────────────────
4804
4805    #[test]
4806    fn serialize_empty_results() {
4807        let r = AnalysisResults::default();
4808        let json = serde_json::to_value(&r).unwrap();
4809
4810        // All arrays should be present and empty
4811        assert!(json["unused_files"].as_array().unwrap().is_empty());
4812        assert!(json["unused_exports"].as_array().unwrap().is_empty());
4813        assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
4814
4815        // Skipped fields should be absent
4816        assert!(json.get("export_usages").is_none());
4817        assert!(json.get("entry_point_summary").is_none());
4818    }
4819
4820    #[test]
4821    fn serialize_unused_file_path() {
4822        let r = UnusedFile {
4823            path: PathBuf::from("src/utils/index.ts"),
4824        };
4825        let json = serde_json::to_value(&r).unwrap();
4826        assert_eq!(json["path"], "src/utils/index.ts");
4827    }
4828
4829    #[test]
4830    fn serialize_dependency_location_camel_case() {
4831        let dep = UnusedDependency {
4832            package_name: "react".to_string(),
4833            location: DependencyLocation::DevDependencies,
4834            path: PathBuf::from("package.json"),
4835            line: 5,
4836            used_in_workspaces: Vec::new(),
4837        };
4838        let json = serde_json::to_value(&dep).unwrap();
4839        assert_eq!(json["location"], "devDependencies");
4840
4841        let dep2 = UnusedDependency {
4842            package_name: "react".to_string(),
4843            location: DependencyLocation::Dependencies,
4844            path: PathBuf::from("package.json"),
4845            line: 3,
4846            used_in_workspaces: Vec::new(),
4847        };
4848        let json2 = serde_json::to_value(&dep2).unwrap();
4849        assert_eq!(json2["location"], "dependencies");
4850
4851        let dep3 = UnusedDependency {
4852            package_name: "fsevents".to_string(),
4853            location: DependencyLocation::OptionalDependencies,
4854            path: PathBuf::from("package.json"),
4855            line: 7,
4856            used_in_workspaces: Vec::new(),
4857        };
4858        let json3 = serde_json::to_value(&dep3).unwrap();
4859        assert_eq!(json3["location"], "optionalDependencies");
4860    }
4861
4862    #[test]
4863    fn serialize_circular_dependency_skips_false_cross_package() {
4864        let cd = CircularDependency {
4865            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4866            length: 2,
4867            line: 1,
4868            col: 0,
4869            edges: Vec::new(),
4870            is_cross_package: false,
4871        };
4872        let json = serde_json::to_value(&cd).unwrap();
4873        // skip_serializing_if = "std::ops::Not::not" means false is skipped
4874        assert!(json.get("is_cross_package").is_none());
4875    }
4876
4877    #[test]
4878    fn serialize_circular_dependency_includes_true_cross_package() {
4879        let cd = CircularDependency {
4880            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4881            length: 2,
4882            line: 1,
4883            col: 0,
4884            edges: Vec::new(),
4885            is_cross_package: true,
4886        };
4887        let json = serde_json::to_value(&cd).unwrap();
4888        assert_eq!(json["is_cross_package"], true);
4889    }
4890
4891    #[test]
4892    fn serialize_unused_export_fields() {
4893        let e = UnusedExport {
4894            path: PathBuf::from("src/mod.ts"),
4895            export_name: "helper".to_string(),
4896            is_type_only: true,
4897            line: 42,
4898            col: 7,
4899            span_start: 100,
4900            is_re_export: true,
4901            deprecated: false,
4902            deprecated_reason: None,
4903        };
4904        let json = serde_json::to_value(&e).unwrap();
4905        assert_eq!(json["path"], "src/mod.ts");
4906        assert_eq!(json["export_name"], "helper");
4907        assert_eq!(json["is_type_only"], true);
4908        assert_eq!(json["line"], 42);
4909        assert_eq!(json["col"], 7);
4910        assert_eq!(json["span_start"], 100);
4911        assert_eq!(json["is_re_export"], true);
4912    }
4913
4914    #[test]
4915    fn serialize_boundary_violation_fields() {
4916        let v = BoundaryViolation {
4917            from_path: PathBuf::from("src/ui/button.tsx"),
4918            to_path: PathBuf::from("src/db/queries.ts"),
4919            from_zone: "ui".to_string(),
4920            to_zone: "db".to_string(),
4921            import_specifier: "../db/queries".to_string(),
4922            line: 3,
4923            col: 0,
4924        };
4925        let json = serde_json::to_value(&v).unwrap();
4926        assert_eq!(json["from_path"], "src/ui/button.tsx");
4927        assert_eq!(json["to_path"], "src/db/queries.ts");
4928        assert_eq!(json["from_zone"], "ui");
4929        assert_eq!(json["to_zone"], "db");
4930        assert_eq!(json["import_specifier"], "../db/queries");
4931    }
4932
4933    #[test]
4934    fn serialize_unlisted_dependency_with_import_sites() {
4935        let d = UnlistedDependency {
4936            package_name: "chalk".to_string(),
4937            imported_from: vec![
4938                ImportSite {
4939                    path: PathBuf::from("a.ts"),
4940                    line: 1,
4941                    col: 0,
4942                },
4943                ImportSite {
4944                    path: PathBuf::from("b.ts"),
4945                    line: 5,
4946                    col: 3,
4947                },
4948            ],
4949        };
4950        let json = serde_json::to_value(&d).unwrap();
4951        assert_eq!(json["package_name"], "chalk");
4952        let sites = json["imported_from"].as_array().unwrap();
4953        assert_eq!(sites.len(), 2);
4954        assert_eq!(sites[0]["path"], "a.ts");
4955        assert_eq!(sites[1]["line"], 5);
4956    }
4957
4958    #[test]
4959    fn serialize_duplicate_export_with_locations() {
4960        let d = DuplicateExport {
4961            export_name: "Button".to_string(),
4962            locations: vec![
4963                DuplicateLocation {
4964                    path: PathBuf::from("src/a.ts"),
4965                    line: 10,
4966                    col: 0,
4967                },
4968                DuplicateLocation {
4969                    path: PathBuf::from("src/b.ts"),
4970                    line: 20,
4971                    col: 5,
4972                },
4973            ],
4974        };
4975        let json = serde_json::to_value(&d).unwrap();
4976        assert_eq!(json["export_name"], "Button");
4977        let locs = json["locations"].as_array().unwrap();
4978        assert_eq!(locs.len(), 2);
4979        assert_eq!(locs[0]["line"], 10);
4980        assert_eq!(locs[1]["col"], 5);
4981    }
4982
4983    #[test]
4984    fn serialize_type_only_dependency() {
4985        let d = TypeOnlyDependency {
4986            package_name: "@types/react".to_string(),
4987            path: PathBuf::from("package.json"),
4988            line: 12,
4989        };
4990        let json = serde_json::to_value(&d).unwrap();
4991        assert_eq!(json["package_name"], "@types/react");
4992        assert_eq!(json["line"], 12);
4993    }
4994
4995    #[test]
4996    fn serialize_test_only_dependency() {
4997        let d = TestOnlyDependency {
4998            package_name: "vitest".to_string(),
4999            path: PathBuf::from("package.json"),
5000            line: 8,
5001        };
5002        let json = serde_json::to_value(&d).unwrap();
5003        assert_eq!(json["package_name"], "vitest");
5004        assert_eq!(json["line"], 8);
5005    }
5006
5007    #[test]
5008    fn serialize_unused_member() {
5009        let m = UnusedMember {
5010            path: PathBuf::from("enums.ts"),
5011            parent_name: "Status".to_string(),
5012            member_name: "Pending".to_string(),
5013            kind: MemberKind::EnumMember,
5014            line: 3,
5015            col: 4,
5016        };
5017        let json = serde_json::to_value(&m).unwrap();
5018        assert_eq!(json["parent_name"], "Status");
5019        assert_eq!(json["member_name"], "Pending");
5020        assert_eq!(json["line"], 3);
5021    }
5022
5023    #[test]
5024    fn serialize_unresolved_import() {
5025        let i = UnresolvedImport {
5026            path: PathBuf::from("app.ts"),
5027            specifier: "./missing-module".to_string(),
5028            line: 7,
5029            col: 0,
5030            specifier_col: 21,
5031        };
5032        let json = serde_json::to_value(&i).unwrap();
5033        assert_eq!(json["specifier"], "./missing-module");
5034        assert_eq!(json["specifier_col"], 21);
5035    }
5036
5037    // ── deserialize: CircularDependency serde(default) fields ──
5038
5039    #[test]
5040    fn deserialize_circular_dependency_with_defaults() {
5041        // CircularDependency derives Deserialize; line/col/is_cross_package have #[serde(default)]
5042        let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
5043        let cd: CircularDependency = serde_json::from_str(json).unwrap();
5044        assert_eq!(cd.files.len(), 2);
5045        assert_eq!(cd.length, 2);
5046        assert_eq!(cd.line, 0);
5047        assert_eq!(cd.col, 0);
5048        assert!(!cd.is_cross_package);
5049    }
5050
5051    #[test]
5052    fn deserialize_circular_dependency_with_all_fields() {
5053        let json =
5054            r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
5055        let cd: CircularDependency = serde_json::from_str(json).unwrap();
5056        assert_eq!(cd.line, 5);
5057        assert_eq!(cd.col, 10);
5058        assert!(cd.is_cross_package);
5059    }
5060
5061    // ── clone produces independent copies ───────────────────────
5062
5063    fn protected_architecture_findings(path: &Path) -> AnalysisResults {
5064        AnalysisResults {
5065            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
5066                from_path: path.to_path_buf(),
5067                to_path: PathBuf::from("src/target.ts"),
5068                from_zone: "ui".to_string(),
5069                to_zone: "data".to_string(),
5070                import_specifier: "../target".to_string(),
5071                line: 1,
5072                col: 0,
5073            })],
5074            boundary_coverage_violations: vec![BoundaryCoverageViolationFinding::with_actions(
5075                BoundaryCoverageViolation {
5076                    path: path.to_path_buf(),
5077                    line: 1,
5078                    col: 0,
5079                },
5080            )],
5081            boundary_call_violations: vec![BoundaryCallViolationFinding::with_actions(
5082                BoundaryCallViolation {
5083                    path: path.to_path_buf(),
5084                    line: 1,
5085                    col: 0,
5086                    zone: "ui".to_string(),
5087                    callee: "cp.exec".to_string(),
5088                    pattern: "child_process.*".to_string(),
5089                },
5090            )],
5091            policy_violations: vec![PolicyViolationFinding::with_actions(PolicyViolation {
5092                path: path.to_path_buf(),
5093                line: 1,
5094                col: 0,
5095                pack: "security".to_string(),
5096                rule_id: "no-eval".to_string(),
5097                kind: PolicyRuleKind::BannedCall,
5098                matched: "eval".to_string(),
5099                severity: PolicyViolationSeverity::Error,
5100                message: None,
5101            })],
5102            stale_suppressions: vec![StaleSuppression {
5103                path: path.to_path_buf(),
5104                line: 1,
5105                col: 0,
5106                origin: SuppressionOrigin::Comment {
5107                    issue_kind: Some("unused-file".to_string()),
5108                    reason: None,
5109                    is_file_level: false,
5110                    kind_known: true,
5111                },
5112                missing_reason: false,
5113                actions: StaleSuppression::actions_for(false),
5114                effective_severity: None,
5115            }],
5116            ..AnalysisResults::default()
5117        }
5118    }
5119
5120    fn protected_framework_findings() -> AnalysisResults {
5121        AnalysisResults {
5122            invalid_client_exports: vec![InvalidClientExportFinding::with_actions(
5123                InvalidClientExport {
5124                    path: PathBuf::from("ignored/client.ts"),
5125                    export_name: "metadata".to_string(),
5126                    directive: "use client".to_string(),
5127                    line: 1,
5128                    col: 0,
5129                },
5130            )],
5131            mixed_client_server_barrels: vec![MixedClientServerBarrelFinding::with_actions(
5132                MixedClientServerBarrel {
5133                    path: PathBuf::from("ignored/barrel.ts"),
5134                    client_origin: "./client".to_string(),
5135                    server_origin: "./server".to_string(),
5136                    line: 1,
5137                    col: 0,
5138                },
5139            )],
5140            misplaced_directives: vec![MisplacedDirectiveFinding::with_actions(
5141                MisplacedDirective {
5142                    path: PathBuf::from("ignored/directive.ts"),
5143                    directive: "use client".to_string(),
5144                    line: 2,
5145                    col: 0,
5146                },
5147            )],
5148            route_collisions: vec![RouteCollisionFinding::with_actions(RouteCollision {
5149                path: PathBuf::from("ignored/app/about/page.tsx"),
5150                url: "/about".to_string(),
5151                conflicting_paths: vec![PathBuf::from("src/app/about/page.tsx")],
5152                line: 1,
5153                col: 0,
5154            })],
5155            dynamic_segment_name_conflicts: vec![DynamicSegmentNameConflictFinding::with_actions(
5156                DynamicSegmentNameConflict {
5157                    path: PathBuf::from("ignored/app/shop/[id]/page.tsx"),
5158                    position: "/shop".to_string(),
5159                    conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
5160                    conflicting_paths: vec![PathBuf::from("src/app/shop/[slug]/page.tsx")],
5161                    line: 1,
5162                    col: 0,
5163                },
5164            )],
5165            ..AnalysisResults::default()
5166        }
5167    }
5168
5169    #[test]
5170    fn finding_ignore_hides_dead_code_but_retains_protected_findings() {
5171        let ignored_path = PathBuf::from("ignored/dead.ts");
5172        let mut results = protected_architecture_findings(&ignored_path);
5173        results.merge_into(protected_framework_findings());
5174        results.unused_files = vec![
5175            UnusedFileFinding::with_actions(UnusedFile { path: ignored_path }),
5176            UnusedFileFinding::with_actions(UnusedFile {
5177                path: PathBuf::from("src/visible.ts"),
5178            }),
5179        ];
5180
5181        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5182
5183        assert_eq!(results.unused_files.len(), 1);
5184        assert_eq!(
5185            results.unused_files[0].file.path,
5186            PathBuf::from("src/visible.ts")
5187        );
5188        assert_eq!(results.boundary_violations.len(), 1);
5189        assert_eq!(results.boundary_coverage_violations.len(), 1);
5190        assert_eq!(results.boundary_call_violations.len(), 1);
5191        assert_eq!(results.policy_violations.len(), 1);
5192        assert_eq!(results.stale_suppressions.len(), 1);
5193        assert_eq!(results.invalid_client_exports.len(), 1);
5194        assert_eq!(results.mixed_client_server_barrels.len(), 1);
5195        assert_eq!(results.misplaced_directives.len(), 1);
5196        assert_eq!(results.route_collisions.len(), 1);
5197        assert_eq!(results.dynamic_segment_name_conflicts.len(), 1);
5198    }
5199
5200    #[test]
5201    fn finding_ignore_requires_every_source_owner_to_match() {
5202        let duplicate = |paths: &[&str]| {
5203            DuplicateExportFinding::with_actions(DuplicateExport {
5204                export_name: "shared".to_string(),
5205                locations: paths
5206                    .iter()
5207                    .map(|path| DuplicateLocation {
5208                        path: PathBuf::from(path),
5209                        line: 1,
5210                        col: 0,
5211                    })
5212                    .collect(),
5213            })
5214        };
5215        let mut results = AnalysisResults {
5216            duplicate_exports: vec![
5217                duplicate(&["ignored/a.ts", "ignored/b.ts"]),
5218                duplicate(&["ignored/a.ts", "src/b.ts"]),
5219                duplicate(&[]),
5220            ],
5221            ..AnalysisResults::default()
5222        };
5223
5224        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5225
5226        assert_eq!(results.duplicate_exports.len(), 2);
5227        assert_eq!(results.duplicate_exports[0].export.locations.len(), 2);
5228        assert!(results.duplicate_exports[1].export.locations.is_empty());
5229    }
5230
5231    #[test]
5232    fn finding_ignore_retains_unowned_package_issues() {
5233        let mut results = AnalysisResults {
5234            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
5235                package_name: "unused-package".to_string(),
5236                location: DependencyLocation::Dependencies,
5237                path: PathBuf::from("ignored/package.json"),
5238                line: 3,
5239                used_in_workspaces: vec![],
5240            })],
5241            ..AnalysisResults::default()
5242        };
5243
5244        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5245
5246        assert_eq!(results.unused_dependencies.len(), 1);
5247    }
5248
5249    fn thin_wrapper_finding(path: &str) -> ThinWrapperFinding {
5250        ThinWrapperFinding::with_actions(ThinWrapper {
5251            file: PathBuf::from(path),
5252            line: 1,
5253            component: "Wrapper".to_string(),
5254            child_component: "Child".to_string(),
5255        })
5256    }
5257
5258    fn duplicate_prop_shape_finding(path: &str) -> DuplicatePropShapeFinding {
5259        DuplicatePropShapeFinding::with_actions(DuplicatePropShape {
5260            file: PathBuf::from(path),
5261            line: 1,
5262            component: "Card".to_string(),
5263            shape: vec!["title".to_string(), "subtitle".to_string()],
5264            group_size: 3,
5265            sharing_components: vec![],
5266        })
5267    }
5268
5269    fn prop_drilling_chain_finding(paths: &[&str]) -> PropDrillingChainFinding {
5270        PropDrillingChainFinding::with_actions(PropDrillingChain {
5271            prop: "user".to_string(),
5272            depth: paths.len() as u32,
5273            hops: paths
5274                .iter()
5275                .map(|path| PropDrillHop {
5276                    file: PathBuf::from(path),
5277                    line: 1,
5278                    component: "Hop".to_string(),
5279                })
5280                .collect(),
5281        })
5282    }
5283
5284    #[test]
5285    fn finding_ignore_hides_thin_wrappers_by_wrapper_file() {
5286        let mut results = AnalysisResults {
5287            thin_wrappers: vec![
5288                thin_wrapper_finding("ignored/Wrapper.tsx"),
5289                thin_wrapper_finding("src/Wrapper.tsx"),
5290            ],
5291            ..AnalysisResults::default()
5292        };
5293
5294        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5295
5296        assert_eq!(results.thin_wrappers.len(), 1);
5297        assert_eq!(
5298            results.thin_wrappers[0].wrapper.file,
5299            PathBuf::from("src/Wrapper.tsx")
5300        );
5301    }
5302
5303    #[test]
5304    fn finding_ignore_hides_duplicate_prop_shapes_by_component_file() {
5305        let mut results = AnalysisResults {
5306            duplicate_prop_shapes: vec![
5307                duplicate_prop_shape_finding("ignored/Card.tsx"),
5308                duplicate_prop_shape_finding("src/Card.tsx"),
5309            ],
5310            ..AnalysisResults::default()
5311        };
5312
5313        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5314
5315        assert_eq!(results.duplicate_prop_shapes.len(), 1);
5316        assert_eq!(
5317            results.duplicate_prop_shapes[0].shape.file,
5318            PathBuf::from("src/Card.tsx")
5319        );
5320    }
5321
5322    #[test]
5323    fn finding_ignore_hides_prop_drilling_chains_only_when_every_hop_matches() {
5324        let mut results = AnalysisResults {
5325            prop_drilling_chains: vec![
5326                prop_drilling_chain_finding(&["ignored/a.tsx", "ignored/b.tsx"]),
5327                prop_drilling_chain_finding(&["ignored/a.tsx", "src/b.tsx"]),
5328                prop_drilling_chain_finding(&[]),
5329            ],
5330            ..AnalysisResults::default()
5331        };
5332
5333        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5334
5335        assert_eq!(results.prop_drilling_chains.len(), 2);
5336        assert_eq!(results.prop_drilling_chains[0].chain.hops.len(), 2);
5337        assert!(results.prop_drilling_chains[1].chain.hops.is_empty());
5338    }
5339
5340    #[test]
5341    fn finding_ignore_retains_security_findings_and_blind_spot_diagnostics() {
5342        let path = PathBuf::from("ignored/leak.ts");
5343        let mut results = AnalysisResults {
5344            security_findings: vec![SecurityFinding {
5345                finding_id: "id".to_string(),
5346                kind: SecurityFindingKind::TaintedSink,
5347                category: Some("dangerous-html".to_string()),
5348                cwe: Some(79),
5349                path: path.clone(),
5350                line: 1,
5351                col: 0,
5352                evidence: "candidate".to_string(),
5353                source_backed: false,
5354                source_read: None,
5355                severity: SecuritySeverity::Low,
5356                trace: vec![TraceHop {
5357                    path: path.clone(),
5358                    line: 1,
5359                    col: 0,
5360                    role: TraceHopRole::Sink,
5361                }],
5362                actions: vec![],
5363                dead_code: None,
5364                reachability: None,
5365                candidate: SecurityCandidate {
5366                    source_kind: None,
5367                    sink: SecurityCandidateSink {
5368                        path: path.clone(),
5369                        line: 1,
5370                        col: 0,
5371                        category: Some("dangerous-html".to_string()),
5372                        cwe: Some(79),
5373                        callee: None,
5374                        url_shape: None,
5375                    },
5376                    boundary: SecurityCandidateBoundary::default(),
5377                    network: None,
5378                },
5379                taint_flow: None,
5380                runtime: None,
5381                attack_surface: None,
5382            }],
5383            security_unresolved_callee_diagnostics: vec![SecurityUnresolvedCalleeDiagnostic {
5384                path,
5385                line: 1,
5386                col: 0,
5387                reason: SkippedSecurityCalleeReason::DynamicDispatch,
5388                expression_kind: SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
5389            }],
5390            ..AnalysisResults::default()
5391        };
5392
5393        results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5394
5395        assert_eq!(results.security_findings.len(), 1);
5396        assert_eq!(results.security_unresolved_callee_diagnostics.len(), 1);
5397    }
5398
5399    // ── export_usages not counted in total_issues ───────────────
5400
5401    #[test]
5402    fn export_usages_not_counted_in_total_issues() {
5403        let mut r = AnalysisResults::default();
5404        r.export_usages.push(ExportUsage {
5405            path: PathBuf::from("mod.ts"),
5406            export_name: "foo".to_string(),
5407            line: 1,
5408            col: 0,
5409            reference_count: 3,
5410            reference_locations: vec![],
5411        });
5412        // export_usages is metadata, not an issue type
5413        assert_eq!(r.total_issues(), 0);
5414        assert!(!r.has_issues());
5415    }
5416
5417    // ── entry_point_summary not counted in total_issues ─────────
5418
5419    #[test]
5420    fn entry_point_summary_not_counted_in_total_issues() {
5421        let r = AnalysisResults {
5422            entry_point_summary: Some(EntryPointSummary {
5423                total: 10,
5424                by_source: vec![("config".to_string(), 10)],
5425            }),
5426            ..AnalysisResults::default()
5427        };
5428        assert_eq!(r.total_issues(), 0);
5429        assert!(!r.has_issues());
5430    }
5431}