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