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