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