Skip to main content

fallow_types/
results.rs

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