Skip to main content

fallow_types/
results.rs

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