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