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