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