Skip to main content

fallow_types/
results.rs

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