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