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