Skip to main content

fallow_types/
results.rs

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