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: pattern match with some ambiguity.
3911 Medium,
3912 /// High confidence: unambiguous pattern (env vars, direct SDK calls).
3913 High,
3914}
3915
3916/// A detected feature flag use site.
3917#[derive(Debug, Clone, Serialize, Deserialize)]
3918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3919pub struct FeatureFlag {
3920 /// File containing the feature flag usage.
3921 #[serde(serialize_with = "serde_path::serialize")]
3922 pub path: PathBuf,
3923 /// Name or identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
3924 pub flag_name: String,
3925 /// How the flag was detected.
3926 pub kind: FlagKind,
3927 /// Detection confidence level.
3928 pub confidence: FlagConfidence,
3929 /// 1-based line number.
3930 pub line: u32,
3931 /// 0-based byte column offset.
3932 pub col: u32,
3933 /// Start byte offset of the guarded code block (if-branch span), if detected.
3934 #[serde(skip)]
3935 pub guard_span_start: Option<u32>,
3936 /// End byte offset of the guarded code block (if-branch span), if detected.
3937 #[serde(skip)]
3938 pub guard_span_end: Option<u32>,
3939 /// SDK or provider name (e.g., "LaunchDarkly", "Statsig"), if detected from SDK call.
3940 #[serde(default, skip_serializing_if = "Option::is_none")]
3941 pub sdk_name: Option<String>,
3942 /// Line range of the guarded code block (derived from guard_span + line_offsets).
3943 /// Used for cross-reference with dead code findings.
3944 #[serde(skip)]
3945 pub guard_line_start: Option<u32>,
3946 /// End line of the guarded code block.
3947 #[serde(skip)]
3948 pub guard_line_end: Option<u32>,
3949 /// Unused exports found within the guarded code block.
3950 /// Populated by cross-reference with dead code analysis.
3951 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3952 pub guarded_dead_exports: Vec<String>,
3953}
3954
3955// Size assertion: FeatureFlag is stored in a Vec per analysis run.
3956const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
3957
3958/// Usage count for an export symbol. Used by the LSP Code Lens to show
3959/// reference counts above each export declaration.
3960#[derive(Debug, Clone, Serialize, Deserialize)]
3961#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3962pub struct ExportUsage {
3963 /// File containing the export.
3964 #[serde(serialize_with = "serde_path::serialize")]
3965 pub path: PathBuf,
3966 /// Name of the exported symbol.
3967 pub export_name: String,
3968 /// 1-based line number.
3969 pub line: u32,
3970 /// 0-based byte column offset.
3971 pub col: u32,
3972 /// Number of files that reference this export.
3973 pub reference_count: usize,
3974 /// Locations where this export is referenced. Used by the LSP Code Lens
3975 /// to enable click-to-navigate via `editor.action.showReferences`.
3976 pub reference_locations: Vec<ReferenceLocation>,
3977}
3978
3979/// A location where an export is referenced (import site in another file).
3980#[derive(Debug, Clone, Serialize, Deserialize)]
3981#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3982pub struct ReferenceLocation {
3983 /// File containing the import that references the export.
3984 #[serde(serialize_with = "serde_path::serialize")]
3985 pub path: PathBuf,
3986 /// 1-based line number.
3987 pub line: u32,
3988 /// 0-based byte column offset.
3989 pub col: u32,
3990}
3991
3992#[cfg(test)]
3993mod tests {
3994 use super::*;
3995 use crate::output_dead_code::{
3996 BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
3997 UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
3998 UnusedTypeFinding,
3999 };
4000
4001 #[test]
4002 fn empty_results_no_issues() {
4003 let results = AnalysisResults::default();
4004 assert_eq!(results.total_issues(), 0);
4005 assert!(!results.has_issues());
4006 }
4007
4008 #[test]
4009 fn results_with_unused_file() {
4010 let mut results = AnalysisResults::default();
4011 results
4012 .unused_files
4013 .push(UnusedFileFinding::with_actions(UnusedFile {
4014 path: PathBuf::from("test.ts"),
4015 }));
4016 assert_eq!(results.total_issues(), 1);
4017 assert!(results.has_issues());
4018 }
4019
4020 #[test]
4021 fn results_with_unused_export() {
4022 let mut results = AnalysisResults::default();
4023 results
4024 .unused_exports
4025 .push(UnusedExportFinding::with_actions(UnusedExport {
4026 path: PathBuf::from("test.ts"),
4027 export_name: "foo".to_string(),
4028 is_type_only: false,
4029 line: 1,
4030 col: 0,
4031 span_start: 0,
4032 is_re_export: false,
4033 deprecated: false,
4034 deprecated_reason: None,
4035 }));
4036 assert_eq!(results.total_issues(), 1);
4037 assert!(results.has_issues());
4038 }
4039
4040 #[test]
4041 fn merge_into_appends_counts_and_preserves_existing_optional_metadata() {
4042 let framework_contract = crate::semantic::SemanticFrameworkContract {
4043 framework: "lit".to_string(),
4044 package: "lit".to_string(),
4045 heritage_symbol: "LitElement".to_string(),
4046 heritage_names: vec!["LitElement".to_string()],
4047 relation: crate::semantic::SemanticFrameworkRelation::Extends,
4048 members: vec!["render".to_string()],
4049 };
4050 let mut target = AnalysisResults {
4051 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4052 path: PathBuf::from("a.ts"),
4053 })],
4054 suppression_count: 2,
4055 security_unresolved_edge_files: 1,
4056 security_unresolved_callee_sites: 3,
4057 entry_point_summary: Some(EntryPointSummary {
4058 total: 1,
4059 by_source: vec![("existing".to_string(), 1)],
4060 }),
4061 semantic_framework_contracts: vec![framework_contract.clone()],
4062 ..AnalysisResults::default()
4063 };
4064 let source = AnalysisResults {
4065 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4066 path: PathBuf::from("b.ts"),
4067 })],
4068 suppression_count: 4,
4069 security_unresolved_edge_files: 5,
4070 security_unresolved_callee_sites: 6,
4071 unused_load_data_keys_global_abstain: true,
4072 entry_point_summary: Some(EntryPointSummary {
4073 total: 1,
4074 by_source: vec![("incoming".to_string(), 1)],
4075 }),
4076 render_fan_in: Some(RenderFanInMetric::default()),
4077 semantic_framework_contracts: vec![framework_contract],
4078 ..AnalysisResults::default()
4079 };
4080
4081 target.merge_into(source);
4082
4083 assert_eq!(target.unused_files.len(), 2);
4084 assert_eq!(target.suppression_count, 6);
4085 assert_eq!(target.security_unresolved_edge_files, 6);
4086 assert_eq!(target.security_unresolved_callee_sites, 9);
4087 assert!(target.unused_load_data_keys_global_abstain);
4088 assert_eq!(
4089 target
4090 .entry_point_summary
4091 .as_ref()
4092 .map(|summary| summary.total),
4093 Some(1)
4094 );
4095 assert_eq!(
4096 target
4097 .entry_point_summary
4098 .as_ref()
4099 .and_then(|summary| summary.by_source.first())
4100 .map(|(name, _)| name.as_str()),
4101 Some("existing")
4102 );
4103 assert!(target.render_fan_in.is_some());
4104 assert_eq!(target.semantic_framework_contracts.len(), 1);
4105 }
4106
4107 fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
4108 UnusedExport {
4109 path: PathBuf::from(path),
4110 export_name: export_name.to_string(),
4111 is_type_only,
4112 line: 1,
4113 col: 0,
4114 span_start: 0,
4115 is_re_export: false,
4116 deprecated: false,
4117 deprecated_reason: None,
4118 }
4119 }
4120
4121 fn test_unused_dependency(
4122 package_name: &str,
4123 location: DependencyLocation,
4124 ) -> UnusedDependency {
4125 UnusedDependency {
4126 package_name: package_name.to_string(),
4127 location,
4128 path: PathBuf::from("package.json"),
4129 line: 5,
4130 used_in_workspaces: Vec::new(),
4131 }
4132 }
4133
4134 fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
4135 UnusedMember {
4136 path: PathBuf::from("members.ts"),
4137 parent_name: "Parent".to_string(),
4138 member_name: member_name.to_string(),
4139 kind,
4140 line: 1,
4141 col: 0,
4142 }
4143 }
4144
4145 #[test]
4146 fn results_total_counts_all_types() {
4147 let results = AnalysisResults {
4148 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4149 path: PathBuf::from("a.ts"),
4150 })],
4151 unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
4152 "b.ts", "x", false,
4153 ))],
4154 unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
4155 "c.ts", "T", true,
4156 ))],
4157 unused_dependencies: vec![UnusedDependencyFinding::with_actions(
4158 test_unused_dependency("dep", DependencyLocation::Dependencies),
4159 )],
4160 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
4161 test_unused_dependency("dev", DependencyLocation::DevDependencies),
4162 )],
4163 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
4164 "A",
4165 MemberKind::EnumMember,
4166 ))],
4167 unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
4168 "m",
4169 MemberKind::ClassMethod,
4170 ))],
4171 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
4172 path: PathBuf::from("f.ts"),
4173 specifier: "./missing".to_string(),
4174 line: 1,
4175 col: 0,
4176 specifier_col: 0,
4177 })],
4178 unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
4179 UnlistedDependency {
4180 package_name: "unlisted".to_string(),
4181 imported_from: vec![ImportSite {
4182 path: PathBuf::from("g.ts"),
4183 line: 1,
4184 col: 0,
4185 }],
4186 },
4187 )],
4188 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
4189 export_name: "dup".to_string(),
4190 locations: vec![
4191 DuplicateLocation {
4192 path: PathBuf::from("h.ts"),
4193 line: 15,
4194 col: 0,
4195 },
4196 DuplicateLocation {
4197 path: PathBuf::from("i.ts"),
4198 line: 30,
4199 col: 0,
4200 },
4201 ],
4202 })],
4203 unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
4204 test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
4205 )],
4206 type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
4207 TypeOnlyDependency {
4208 package_name: "type-only".to_string(),
4209 path: PathBuf::from("package.json"),
4210 line: 8,
4211 },
4212 )],
4213 test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
4214 TestOnlyDependency {
4215 package_name: "test-only".to_string(),
4216 path: PathBuf::from("package.json"),
4217 line: 9,
4218 },
4219 )],
4220 circular_dependencies: vec![CircularDependencyFinding::with_actions(
4221 CircularDependency {
4222 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4223 length: 2,
4224 line: 3,
4225 col: 0,
4226 edges: Vec::new(),
4227 is_cross_package: false,
4228 },
4229 )],
4230 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
4231 from_path: PathBuf::from("src/ui/Button.tsx"),
4232 to_path: PathBuf::from("src/db/queries.ts"),
4233 from_zone: "ui".to_string(),
4234 to_zone: "database".to_string(),
4235 import_specifier: "../db/queries".to_string(),
4236 line: 3,
4237 col: 0,
4238 })],
4239 ..Default::default()
4240 };
4241
4242 // 15 categories, one of each
4243 assert_eq!(results.total_issues(), 15);
4244 assert!(results.has_issues());
4245 }
4246
4247 // ── total_issues counts each category independently ─────────
4248
4249 #[test]
4250 fn total_issues_sums_all_categories_independently() {
4251 let mut results = AnalysisResults::default();
4252 results
4253 .unused_files
4254 .push(UnusedFileFinding::with_actions(UnusedFile {
4255 path: PathBuf::from("a.ts"),
4256 }));
4257 assert_eq!(results.total_issues(), 1);
4258
4259 results
4260 .unused_files
4261 .push(UnusedFileFinding::with_actions(UnusedFile {
4262 path: PathBuf::from("b.ts"),
4263 }));
4264 assert_eq!(results.total_issues(), 2);
4265
4266 results
4267 .unresolved_imports
4268 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
4269 path: PathBuf::from("c.ts"),
4270 specifier: "./missing".to_string(),
4271 line: 1,
4272 col: 0,
4273 specifier_col: 0,
4274 }));
4275 assert_eq!(results.total_issues(), 3);
4276 }
4277
4278 // ── sort: unused_files by path ──────────────────────────────
4279
4280 #[test]
4281 fn sort_unused_files_by_path() {
4282 let mut r = AnalysisResults::default();
4283 r.unused_files
4284 .push(UnusedFileFinding::with_actions(UnusedFile {
4285 path: PathBuf::from("z.ts"),
4286 }));
4287 r.unused_files
4288 .push(UnusedFileFinding::with_actions(UnusedFile {
4289 path: PathBuf::from("a.ts"),
4290 }));
4291 r.unused_files
4292 .push(UnusedFileFinding::with_actions(UnusedFile {
4293 path: PathBuf::from("m.ts"),
4294 }));
4295 r.sort();
4296 let paths: Vec<_> = r
4297 .unused_files
4298 .iter()
4299 .map(|f| f.file.path.to_string_lossy().to_string())
4300 .collect();
4301 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
4302 }
4303
4304 // ── sort: unused_exports by path, line, name ────────────────
4305
4306 #[test]
4307 fn sort_unused_exports_by_path_line_name() {
4308 let mut r = AnalysisResults::default();
4309 let mk = |path: &str, line: u32, name: &str| {
4310 UnusedExportFinding::with_actions(UnusedExport {
4311 path: PathBuf::from(path),
4312 export_name: name.to_string(),
4313 is_type_only: false,
4314 line,
4315 col: 0,
4316 span_start: 0,
4317 is_re_export: false,
4318 deprecated: false,
4319 deprecated_reason: None,
4320 })
4321 };
4322 r.unused_exports.push(mk("b.ts", 5, "beta"));
4323 r.unused_exports.push(mk("a.ts", 10, "zeta"));
4324 r.unused_exports.push(mk("a.ts", 10, "alpha"));
4325 r.unused_exports.push(mk("a.ts", 1, "gamma"));
4326 r.sort();
4327 let keys: Vec<_> = r
4328 .unused_exports
4329 .iter()
4330 .map(|e| {
4331 format!(
4332 "{}:{}:{}",
4333 e.export.path.to_string_lossy(),
4334 e.export.line,
4335 e.export.export_name
4336 )
4337 })
4338 .collect();
4339 assert_eq!(
4340 keys,
4341 vec![
4342 "a.ts:1:gamma",
4343 "a.ts:10:alpha",
4344 "a.ts:10:zeta",
4345 "b.ts:5:beta"
4346 ]
4347 );
4348 }
4349
4350 // ── sort: unused_types (same sort as unused_exports) ────────
4351
4352 #[test]
4353 fn sort_unused_types_by_path_line_name() {
4354 let mut r = AnalysisResults::default();
4355 let mk = |path: &str, line: u32, name: &str| {
4356 UnusedTypeFinding::with_actions(UnusedExport {
4357 path: PathBuf::from(path),
4358 export_name: name.to_string(),
4359 is_type_only: true,
4360 line,
4361 col: 0,
4362 span_start: 0,
4363 is_re_export: false,
4364 deprecated: false,
4365 deprecated_reason: None,
4366 })
4367 };
4368 r.unused_types.push(mk("z.ts", 1, "Z"));
4369 r.unused_types.push(mk("a.ts", 1, "A"));
4370 r.sort();
4371 assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
4372 assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
4373 }
4374
4375 // ── sort: unused_dependencies by path, line, name ───────────
4376
4377 #[test]
4378 fn sort_unused_dependencies_by_path_line_name() {
4379 let mut r = AnalysisResults::default();
4380 let mk = |path: &str, line: u32, name: &str| {
4381 UnusedDependencyFinding::with_actions(UnusedDependency {
4382 package_name: name.to_string(),
4383 location: DependencyLocation::Dependencies,
4384 path: PathBuf::from(path),
4385 line,
4386 used_in_workspaces: Vec::new(),
4387 })
4388 };
4389 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
4390 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
4391 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
4392 r.sort();
4393 let names: Vec<_> = r
4394 .unused_dependencies
4395 .iter()
4396 .map(|d| d.dep.package_name.as_str())
4397 .collect();
4398 assert_eq!(names, vec!["axios", "react", "zlib"]);
4399 }
4400
4401 // ── sort: unused_dev_dependencies ───────────────────────────
4402
4403 #[test]
4404 fn sort_unused_dev_dependencies() {
4405 let mut r = AnalysisResults::default();
4406 r.unused_dev_dependencies
4407 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
4408 package_name: "vitest".to_string(),
4409 location: DependencyLocation::DevDependencies,
4410 path: PathBuf::from("package.json"),
4411 line: 10,
4412 used_in_workspaces: Vec::new(),
4413 }));
4414 r.unused_dev_dependencies
4415 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
4416 package_name: "jest".to_string(),
4417 location: DependencyLocation::DevDependencies,
4418 path: PathBuf::from("package.json"),
4419 line: 5,
4420 used_in_workspaces: Vec::new(),
4421 }));
4422 r.sort();
4423 assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
4424 assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
4425 }
4426
4427 // ── sort: unused_optional_dependencies ──────────────────────
4428
4429 #[test]
4430 fn sort_unused_optional_dependencies() {
4431 let mut r = AnalysisResults::default();
4432 r.unused_optional_dependencies
4433 .push(UnusedOptionalDependencyFinding::with_actions(
4434 UnusedDependency {
4435 package_name: "zod".to_string(),
4436 location: DependencyLocation::OptionalDependencies,
4437 path: PathBuf::from("package.json"),
4438 line: 3,
4439 used_in_workspaces: Vec::new(),
4440 },
4441 ));
4442 r.unused_optional_dependencies
4443 .push(UnusedOptionalDependencyFinding::with_actions(
4444 UnusedDependency {
4445 package_name: "ajv".to_string(),
4446 location: DependencyLocation::OptionalDependencies,
4447 path: PathBuf::from("package.json"),
4448 line: 2,
4449 used_in_workspaces: Vec::new(),
4450 },
4451 ));
4452 r.sort();
4453 assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
4454 assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
4455 }
4456
4457 // ── sort: unused_enum_members by path, line, parent, member ─
4458
4459 #[test]
4460 fn sort_unused_enum_members_by_path_line_parent_member() {
4461 let mut r = AnalysisResults::default();
4462 let mk = |path: &str, line: u32, parent: &str, member: &str| {
4463 UnusedEnumMemberFinding::with_actions(UnusedMember {
4464 path: PathBuf::from(path),
4465 parent_name: parent.to_string(),
4466 member_name: member.to_string(),
4467 kind: MemberKind::EnumMember,
4468 line,
4469 col: 0,
4470 })
4471 };
4472 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
4473 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
4474 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
4475 r.sort();
4476 let keys: Vec<_> = r
4477 .unused_enum_members
4478 .iter()
4479 .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
4480 .collect();
4481 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
4482 }
4483
4484 // ── sort: unused_class_members by path, line, parent, member
4485
4486 #[test]
4487 fn sort_unused_class_members() {
4488 let mut r = AnalysisResults::default();
4489 let mk = |path: &str, line: u32, parent: &str, member: &str| {
4490 UnusedClassMemberFinding::with_actions(UnusedMember {
4491 path: PathBuf::from(path),
4492 parent_name: parent.to_string(),
4493 member_name: member.to_string(),
4494 kind: MemberKind::ClassMethod,
4495 line,
4496 col: 0,
4497 })
4498 };
4499 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
4500 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
4501 r.sort();
4502 assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
4503 assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
4504 }
4505
4506 // ── sort: unresolved_imports by path, line, col, specifier ──
4507
4508 #[test]
4509 fn sort_unresolved_imports_by_path_line_col_specifier() {
4510 let mut r = AnalysisResults::default();
4511 let mk = |path: &str, line: u32, col: u32, spec: &str| {
4512 UnresolvedImportFinding::with_actions(UnresolvedImport {
4513 path: PathBuf::from(path),
4514 specifier: spec.to_string(),
4515 line,
4516 col,
4517 specifier_col: 0,
4518 })
4519 };
4520 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
4521 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
4522 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
4523 r.sort();
4524 let specs: Vec<_> = r
4525 .unresolved_imports
4526 .iter()
4527 .map(|i| i.import.specifier.as_str())
4528 .collect();
4529 assert_eq!(specs, vec!["./m", "./a", "./z"]);
4530 }
4531
4532 // ── sort: unlisted_dependencies + inner imported_from ───────
4533
4534 #[test]
4535 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
4536 let mut r = AnalysisResults::default();
4537 r.unlisted_dependencies
4538 .push(UnlistedDependencyFinding::with_actions(
4539 UnlistedDependency {
4540 package_name: "zod".to_string(),
4541 imported_from: vec![
4542 ImportSite {
4543 path: PathBuf::from("b.ts"),
4544 line: 10,
4545 col: 0,
4546 },
4547 ImportSite {
4548 path: PathBuf::from("a.ts"),
4549 line: 1,
4550 col: 0,
4551 },
4552 ],
4553 },
4554 ));
4555 r.unlisted_dependencies
4556 .push(UnlistedDependencyFinding::with_actions(
4557 UnlistedDependency {
4558 package_name: "axios".to_string(),
4559 imported_from: vec![ImportSite {
4560 path: PathBuf::from("c.ts"),
4561 line: 1,
4562 col: 0,
4563 }],
4564 },
4565 ));
4566 r.sort();
4567
4568 // Outer sort: by package_name
4569 assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
4570 assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
4571
4572 // Inner sort: imported_from sorted by path, then line
4573 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
4574 .dep
4575 .imported_from
4576 .iter()
4577 .map(|s| s.path.to_string_lossy().to_string())
4578 .collect();
4579 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
4580 }
4581
4582 // ── sort: duplicate_exports + inner locations ───────────────
4583
4584 #[test]
4585 fn sort_duplicate_exports_by_name_and_inner_locations() {
4586 let mut r = AnalysisResults::default();
4587 r.duplicate_exports
4588 .push(DuplicateExportFinding::with_actions(DuplicateExport {
4589 export_name: "z".to_string(),
4590 locations: vec![
4591 DuplicateLocation {
4592 path: PathBuf::from("c.ts"),
4593 line: 1,
4594 col: 0,
4595 },
4596 DuplicateLocation {
4597 path: PathBuf::from("a.ts"),
4598 line: 5,
4599 col: 0,
4600 },
4601 ],
4602 }));
4603 r.duplicate_exports
4604 .push(DuplicateExportFinding::with_actions(DuplicateExport {
4605 export_name: "a".to_string(),
4606 locations: vec![DuplicateLocation {
4607 path: PathBuf::from("b.ts"),
4608 line: 1,
4609 col: 0,
4610 }],
4611 }));
4612 r.sort();
4613
4614 // Outer sort: by export_name
4615 assert_eq!(r.duplicate_exports[0].export.export_name, "a");
4616 assert_eq!(r.duplicate_exports[1].export.export_name, "z");
4617
4618 // Inner sort: locations sorted by path, then line
4619 let z_locs: Vec<_> = r.duplicate_exports[1]
4620 .export
4621 .locations
4622 .iter()
4623 .map(|l| l.path.to_string_lossy().to_string())
4624 .collect();
4625 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
4626 }
4627
4628 // ── sort: type_only_dependencies ────────────────────────────
4629
4630 #[test]
4631 fn sort_type_only_dependencies() {
4632 let mut r = AnalysisResults::default();
4633 r.type_only_dependencies
4634 .push(TypeOnlyDependencyFinding::with_actions(
4635 TypeOnlyDependency {
4636 package_name: "zod".to_string(),
4637 path: PathBuf::from("package.json"),
4638 line: 10,
4639 },
4640 ));
4641 r.type_only_dependencies
4642 .push(TypeOnlyDependencyFinding::with_actions(
4643 TypeOnlyDependency {
4644 package_name: "ajv".to_string(),
4645 path: PathBuf::from("package.json"),
4646 line: 5,
4647 },
4648 ));
4649 r.sort();
4650 assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
4651 assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
4652 }
4653
4654 // ── sort: test_only_dependencies ────────────────────────────
4655
4656 #[test]
4657 fn sort_test_only_dependencies() {
4658 let mut r = AnalysisResults::default();
4659 r.test_only_dependencies
4660 .push(TestOnlyDependencyFinding::with_actions(
4661 TestOnlyDependency {
4662 package_name: "vitest".to_string(),
4663 path: PathBuf::from("package.json"),
4664 line: 15,
4665 },
4666 ));
4667 r.test_only_dependencies
4668 .push(TestOnlyDependencyFinding::with_actions(
4669 TestOnlyDependency {
4670 package_name: "jest".to_string(),
4671 path: PathBuf::from("package.json"),
4672 line: 10,
4673 },
4674 ));
4675 r.sort();
4676 assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
4677 assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
4678 }
4679
4680 // ── sort: circular_dependencies by files, then length ───────
4681
4682 #[test]
4683 fn sort_circular_dependencies_by_files_then_length() {
4684 let mut r = AnalysisResults::default();
4685 r.circular_dependencies
4686 .push(CircularDependencyFinding::with_actions(
4687 CircularDependency {
4688 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
4689 length: 2,
4690 line: 1,
4691 col: 0,
4692 edges: Vec::new(),
4693 is_cross_package: false,
4694 },
4695 ));
4696 r.circular_dependencies
4697 .push(CircularDependencyFinding::with_actions(
4698 CircularDependency {
4699 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4700 length: 2,
4701 line: 1,
4702 col: 0,
4703 edges: Vec::new(),
4704 is_cross_package: true,
4705 },
4706 ));
4707 r.sort();
4708 assert_eq!(
4709 r.circular_dependencies[0].cycle.files[0],
4710 PathBuf::from("a.ts")
4711 );
4712 assert_eq!(
4713 r.circular_dependencies[1].cycle.files[0],
4714 PathBuf::from("b.ts")
4715 );
4716 }
4717
4718 // ── sort: boundary_violations by from_path, line, col, to_path
4719
4720 #[test]
4721 fn sort_boundary_violations() {
4722 let mut r = AnalysisResults::default();
4723 let mk = |from: &str, line: u32, col: u32, to: &str| {
4724 BoundaryViolationFinding::with_actions(BoundaryViolation {
4725 from_path: PathBuf::from(from),
4726 to_path: PathBuf::from(to),
4727 from_zone: "a".to_string(),
4728 to_zone: "b".to_string(),
4729 import_specifier: to.to_string(),
4730 line,
4731 col,
4732 })
4733 };
4734 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
4735 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
4736 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
4737 r.sort();
4738 let from_paths: Vec<_> = r
4739 .boundary_violations
4740 .iter()
4741 .map(|v| {
4742 format!(
4743 "{}:{}",
4744 v.violation.from_path.to_string_lossy(),
4745 v.violation.line
4746 )
4747 })
4748 .collect();
4749 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
4750 }
4751
4752 // ── sort: export_usages + inner reference_locations ─────────
4753
4754 #[test]
4755 fn sort_export_usages_and_inner_reference_locations() {
4756 let mut r = AnalysisResults::default();
4757 r.export_usages.push(ExportUsage {
4758 path: PathBuf::from("z.ts"),
4759 export_name: "foo".to_string(),
4760 line: 1,
4761 col: 0,
4762 reference_count: 2,
4763 reference_locations: vec![
4764 ReferenceLocation {
4765 path: PathBuf::from("c.ts"),
4766 line: 10,
4767 col: 0,
4768 },
4769 ReferenceLocation {
4770 path: PathBuf::from("a.ts"),
4771 line: 5,
4772 col: 0,
4773 },
4774 ],
4775 });
4776 r.export_usages.push(ExportUsage {
4777 path: PathBuf::from("a.ts"),
4778 export_name: "bar".to_string(),
4779 line: 1,
4780 col: 0,
4781 reference_count: 1,
4782 reference_locations: vec![ReferenceLocation {
4783 path: PathBuf::from("b.ts"),
4784 line: 1,
4785 col: 0,
4786 }],
4787 });
4788 r.sort();
4789
4790 // Outer sort: by path, then line, then export_name
4791 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
4792 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
4793
4794 // Inner sort: reference_locations sorted by path, line, col
4795 let refs: Vec<_> = r.export_usages[1]
4796 .reference_locations
4797 .iter()
4798 .map(|l| l.path.to_string_lossy().to_string())
4799 .collect();
4800 assert_eq!(refs, vec!["a.ts", "c.ts"]);
4801 }
4802
4803 // ── serialization ──────────────────────────────────────────
4804
4805 #[test]
4806 fn serialize_empty_results() {
4807 let r = AnalysisResults::default();
4808 let json = serde_json::to_value(&r).unwrap();
4809
4810 // All arrays should be present and empty
4811 assert!(json["unused_files"].as_array().unwrap().is_empty());
4812 assert!(json["unused_exports"].as_array().unwrap().is_empty());
4813 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
4814
4815 // Skipped fields should be absent
4816 assert!(json.get("export_usages").is_none());
4817 assert!(json.get("entry_point_summary").is_none());
4818 }
4819
4820 #[test]
4821 fn serialize_unused_file_path() {
4822 let r = UnusedFile {
4823 path: PathBuf::from("src/utils/index.ts"),
4824 };
4825 let json = serde_json::to_value(&r).unwrap();
4826 assert_eq!(json["path"], "src/utils/index.ts");
4827 }
4828
4829 #[test]
4830 fn serialize_dependency_location_camel_case() {
4831 let dep = UnusedDependency {
4832 package_name: "react".to_string(),
4833 location: DependencyLocation::DevDependencies,
4834 path: PathBuf::from("package.json"),
4835 line: 5,
4836 used_in_workspaces: Vec::new(),
4837 };
4838 let json = serde_json::to_value(&dep).unwrap();
4839 assert_eq!(json["location"], "devDependencies");
4840
4841 let dep2 = UnusedDependency {
4842 package_name: "react".to_string(),
4843 location: DependencyLocation::Dependencies,
4844 path: PathBuf::from("package.json"),
4845 line: 3,
4846 used_in_workspaces: Vec::new(),
4847 };
4848 let json2 = serde_json::to_value(&dep2).unwrap();
4849 assert_eq!(json2["location"], "dependencies");
4850
4851 let dep3 = UnusedDependency {
4852 package_name: "fsevents".to_string(),
4853 location: DependencyLocation::OptionalDependencies,
4854 path: PathBuf::from("package.json"),
4855 line: 7,
4856 used_in_workspaces: Vec::new(),
4857 };
4858 let json3 = serde_json::to_value(&dep3).unwrap();
4859 assert_eq!(json3["location"], "optionalDependencies");
4860 }
4861
4862 #[test]
4863 fn serialize_circular_dependency_skips_false_cross_package() {
4864 let cd = CircularDependency {
4865 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4866 length: 2,
4867 line: 1,
4868 col: 0,
4869 edges: Vec::new(),
4870 is_cross_package: false,
4871 };
4872 let json = serde_json::to_value(&cd).unwrap();
4873 // skip_serializing_if = "std::ops::Not::not" means false is skipped
4874 assert!(json.get("is_cross_package").is_none());
4875 }
4876
4877 #[test]
4878 fn serialize_circular_dependency_includes_true_cross_package() {
4879 let cd = CircularDependency {
4880 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
4881 length: 2,
4882 line: 1,
4883 col: 0,
4884 edges: Vec::new(),
4885 is_cross_package: true,
4886 };
4887 let json = serde_json::to_value(&cd).unwrap();
4888 assert_eq!(json["is_cross_package"], true);
4889 }
4890
4891 #[test]
4892 fn serialize_unused_export_fields() {
4893 let e = UnusedExport {
4894 path: PathBuf::from("src/mod.ts"),
4895 export_name: "helper".to_string(),
4896 is_type_only: true,
4897 line: 42,
4898 col: 7,
4899 span_start: 100,
4900 is_re_export: true,
4901 deprecated: false,
4902 deprecated_reason: None,
4903 };
4904 let json = serde_json::to_value(&e).unwrap();
4905 assert_eq!(json["path"], "src/mod.ts");
4906 assert_eq!(json["export_name"], "helper");
4907 assert_eq!(json["is_type_only"], true);
4908 assert_eq!(json["line"], 42);
4909 assert_eq!(json["col"], 7);
4910 assert_eq!(json["span_start"], 100);
4911 assert_eq!(json["is_re_export"], true);
4912 }
4913
4914 #[test]
4915 fn serialize_boundary_violation_fields() {
4916 let v = BoundaryViolation {
4917 from_path: PathBuf::from("src/ui/button.tsx"),
4918 to_path: PathBuf::from("src/db/queries.ts"),
4919 from_zone: "ui".to_string(),
4920 to_zone: "db".to_string(),
4921 import_specifier: "../db/queries".to_string(),
4922 line: 3,
4923 col: 0,
4924 };
4925 let json = serde_json::to_value(&v).unwrap();
4926 assert_eq!(json["from_path"], "src/ui/button.tsx");
4927 assert_eq!(json["to_path"], "src/db/queries.ts");
4928 assert_eq!(json["from_zone"], "ui");
4929 assert_eq!(json["to_zone"], "db");
4930 assert_eq!(json["import_specifier"], "../db/queries");
4931 }
4932
4933 #[test]
4934 fn serialize_unlisted_dependency_with_import_sites() {
4935 let d = UnlistedDependency {
4936 package_name: "chalk".to_string(),
4937 imported_from: vec![
4938 ImportSite {
4939 path: PathBuf::from("a.ts"),
4940 line: 1,
4941 col: 0,
4942 },
4943 ImportSite {
4944 path: PathBuf::from("b.ts"),
4945 line: 5,
4946 col: 3,
4947 },
4948 ],
4949 };
4950 let json = serde_json::to_value(&d).unwrap();
4951 assert_eq!(json["package_name"], "chalk");
4952 let sites = json["imported_from"].as_array().unwrap();
4953 assert_eq!(sites.len(), 2);
4954 assert_eq!(sites[0]["path"], "a.ts");
4955 assert_eq!(sites[1]["line"], 5);
4956 }
4957
4958 #[test]
4959 fn serialize_duplicate_export_with_locations() {
4960 let d = DuplicateExport {
4961 export_name: "Button".to_string(),
4962 locations: vec![
4963 DuplicateLocation {
4964 path: PathBuf::from("src/a.ts"),
4965 line: 10,
4966 col: 0,
4967 },
4968 DuplicateLocation {
4969 path: PathBuf::from("src/b.ts"),
4970 line: 20,
4971 col: 5,
4972 },
4973 ],
4974 };
4975 let json = serde_json::to_value(&d).unwrap();
4976 assert_eq!(json["export_name"], "Button");
4977 let locs = json["locations"].as_array().unwrap();
4978 assert_eq!(locs.len(), 2);
4979 assert_eq!(locs[0]["line"], 10);
4980 assert_eq!(locs[1]["col"], 5);
4981 }
4982
4983 #[test]
4984 fn serialize_type_only_dependency() {
4985 let d = TypeOnlyDependency {
4986 package_name: "@types/react".to_string(),
4987 path: PathBuf::from("package.json"),
4988 line: 12,
4989 };
4990 let json = serde_json::to_value(&d).unwrap();
4991 assert_eq!(json["package_name"], "@types/react");
4992 assert_eq!(json["line"], 12);
4993 }
4994
4995 #[test]
4996 fn serialize_test_only_dependency() {
4997 let d = TestOnlyDependency {
4998 package_name: "vitest".to_string(),
4999 path: PathBuf::from("package.json"),
5000 line: 8,
5001 };
5002 let json = serde_json::to_value(&d).unwrap();
5003 assert_eq!(json["package_name"], "vitest");
5004 assert_eq!(json["line"], 8);
5005 }
5006
5007 #[test]
5008 fn serialize_unused_member() {
5009 let m = UnusedMember {
5010 path: PathBuf::from("enums.ts"),
5011 parent_name: "Status".to_string(),
5012 member_name: "Pending".to_string(),
5013 kind: MemberKind::EnumMember,
5014 line: 3,
5015 col: 4,
5016 };
5017 let json = serde_json::to_value(&m).unwrap();
5018 assert_eq!(json["parent_name"], "Status");
5019 assert_eq!(json["member_name"], "Pending");
5020 assert_eq!(json["line"], 3);
5021 }
5022
5023 #[test]
5024 fn serialize_unresolved_import() {
5025 let i = UnresolvedImport {
5026 path: PathBuf::from("app.ts"),
5027 specifier: "./missing-module".to_string(),
5028 line: 7,
5029 col: 0,
5030 specifier_col: 21,
5031 };
5032 let json = serde_json::to_value(&i).unwrap();
5033 assert_eq!(json["specifier"], "./missing-module");
5034 assert_eq!(json["specifier_col"], 21);
5035 }
5036
5037 // ── deserialize: CircularDependency serde(default) fields ──
5038
5039 #[test]
5040 fn deserialize_circular_dependency_with_defaults() {
5041 // CircularDependency derives Deserialize; line/col/is_cross_package have #[serde(default)]
5042 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
5043 let cd: CircularDependency = serde_json::from_str(json).unwrap();
5044 assert_eq!(cd.files.len(), 2);
5045 assert_eq!(cd.length, 2);
5046 assert_eq!(cd.line, 0);
5047 assert_eq!(cd.col, 0);
5048 assert!(!cd.is_cross_package);
5049 }
5050
5051 #[test]
5052 fn deserialize_circular_dependency_with_all_fields() {
5053 let json =
5054 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
5055 let cd: CircularDependency = serde_json::from_str(json).unwrap();
5056 assert_eq!(cd.line, 5);
5057 assert_eq!(cd.col, 10);
5058 assert!(cd.is_cross_package);
5059 }
5060
5061 // ── clone produces independent copies ───────────────────────
5062
5063 fn protected_architecture_findings(path: &Path) -> AnalysisResults {
5064 AnalysisResults {
5065 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
5066 from_path: path.to_path_buf(),
5067 to_path: PathBuf::from("src/target.ts"),
5068 from_zone: "ui".to_string(),
5069 to_zone: "data".to_string(),
5070 import_specifier: "../target".to_string(),
5071 line: 1,
5072 col: 0,
5073 })],
5074 boundary_coverage_violations: vec![BoundaryCoverageViolationFinding::with_actions(
5075 BoundaryCoverageViolation {
5076 path: path.to_path_buf(),
5077 line: 1,
5078 col: 0,
5079 },
5080 )],
5081 boundary_call_violations: vec![BoundaryCallViolationFinding::with_actions(
5082 BoundaryCallViolation {
5083 path: path.to_path_buf(),
5084 line: 1,
5085 col: 0,
5086 zone: "ui".to_string(),
5087 callee: "cp.exec".to_string(),
5088 pattern: "child_process.*".to_string(),
5089 },
5090 )],
5091 policy_violations: vec![PolicyViolationFinding::with_actions(PolicyViolation {
5092 path: path.to_path_buf(),
5093 line: 1,
5094 col: 0,
5095 pack: "security".to_string(),
5096 rule_id: "no-eval".to_string(),
5097 kind: PolicyRuleKind::BannedCall,
5098 matched: "eval".to_string(),
5099 severity: PolicyViolationSeverity::Error,
5100 message: None,
5101 })],
5102 stale_suppressions: vec![StaleSuppression {
5103 path: path.to_path_buf(),
5104 line: 1,
5105 col: 0,
5106 origin: SuppressionOrigin::Comment {
5107 issue_kind: Some("unused-file".to_string()),
5108 reason: None,
5109 is_file_level: false,
5110 kind_known: true,
5111 },
5112 missing_reason: false,
5113 actions: StaleSuppression::actions_for(false),
5114 effective_severity: None,
5115 }],
5116 ..AnalysisResults::default()
5117 }
5118 }
5119
5120 fn protected_framework_findings() -> AnalysisResults {
5121 AnalysisResults {
5122 invalid_client_exports: vec![InvalidClientExportFinding::with_actions(
5123 InvalidClientExport {
5124 path: PathBuf::from("ignored/client.ts"),
5125 export_name: "metadata".to_string(),
5126 directive: "use client".to_string(),
5127 line: 1,
5128 col: 0,
5129 },
5130 )],
5131 mixed_client_server_barrels: vec![MixedClientServerBarrelFinding::with_actions(
5132 MixedClientServerBarrel {
5133 path: PathBuf::from("ignored/barrel.ts"),
5134 client_origin: "./client".to_string(),
5135 server_origin: "./server".to_string(),
5136 line: 1,
5137 col: 0,
5138 },
5139 )],
5140 misplaced_directives: vec![MisplacedDirectiveFinding::with_actions(
5141 MisplacedDirective {
5142 path: PathBuf::from("ignored/directive.ts"),
5143 directive: "use client".to_string(),
5144 line: 2,
5145 col: 0,
5146 },
5147 )],
5148 route_collisions: vec![RouteCollisionFinding::with_actions(RouteCollision {
5149 path: PathBuf::from("ignored/app/about/page.tsx"),
5150 url: "/about".to_string(),
5151 conflicting_paths: vec![PathBuf::from("src/app/about/page.tsx")],
5152 line: 1,
5153 col: 0,
5154 })],
5155 dynamic_segment_name_conflicts: vec![DynamicSegmentNameConflictFinding::with_actions(
5156 DynamicSegmentNameConflict {
5157 path: PathBuf::from("ignored/app/shop/[id]/page.tsx"),
5158 position: "/shop".to_string(),
5159 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
5160 conflicting_paths: vec![PathBuf::from("src/app/shop/[slug]/page.tsx")],
5161 line: 1,
5162 col: 0,
5163 },
5164 )],
5165 ..AnalysisResults::default()
5166 }
5167 }
5168
5169 #[test]
5170 fn finding_ignore_hides_dead_code_but_retains_protected_findings() {
5171 let ignored_path = PathBuf::from("ignored/dead.ts");
5172 let mut results = protected_architecture_findings(&ignored_path);
5173 results.merge_into(protected_framework_findings());
5174 results.unused_files = vec![
5175 UnusedFileFinding::with_actions(UnusedFile { path: ignored_path }),
5176 UnusedFileFinding::with_actions(UnusedFile {
5177 path: PathBuf::from("src/visible.ts"),
5178 }),
5179 ];
5180
5181 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5182
5183 assert_eq!(results.unused_files.len(), 1);
5184 assert_eq!(
5185 results.unused_files[0].file.path,
5186 PathBuf::from("src/visible.ts")
5187 );
5188 assert_eq!(results.boundary_violations.len(), 1);
5189 assert_eq!(results.boundary_coverage_violations.len(), 1);
5190 assert_eq!(results.boundary_call_violations.len(), 1);
5191 assert_eq!(results.policy_violations.len(), 1);
5192 assert_eq!(results.stale_suppressions.len(), 1);
5193 assert_eq!(results.invalid_client_exports.len(), 1);
5194 assert_eq!(results.mixed_client_server_barrels.len(), 1);
5195 assert_eq!(results.misplaced_directives.len(), 1);
5196 assert_eq!(results.route_collisions.len(), 1);
5197 assert_eq!(results.dynamic_segment_name_conflicts.len(), 1);
5198 }
5199
5200 #[test]
5201 fn finding_ignore_requires_every_source_owner_to_match() {
5202 let duplicate = |paths: &[&str]| {
5203 DuplicateExportFinding::with_actions(DuplicateExport {
5204 export_name: "shared".to_string(),
5205 locations: paths
5206 .iter()
5207 .map(|path| DuplicateLocation {
5208 path: PathBuf::from(path),
5209 line: 1,
5210 col: 0,
5211 })
5212 .collect(),
5213 })
5214 };
5215 let mut results = AnalysisResults {
5216 duplicate_exports: vec![
5217 duplicate(&["ignored/a.ts", "ignored/b.ts"]),
5218 duplicate(&["ignored/a.ts", "src/b.ts"]),
5219 duplicate(&[]),
5220 ],
5221 ..AnalysisResults::default()
5222 };
5223
5224 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5225
5226 assert_eq!(results.duplicate_exports.len(), 2);
5227 assert_eq!(results.duplicate_exports[0].export.locations.len(), 2);
5228 assert!(results.duplicate_exports[1].export.locations.is_empty());
5229 }
5230
5231 #[test]
5232 fn finding_ignore_retains_unowned_package_issues() {
5233 let mut results = AnalysisResults {
5234 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
5235 package_name: "unused-package".to_string(),
5236 location: DependencyLocation::Dependencies,
5237 path: PathBuf::from("ignored/package.json"),
5238 line: 3,
5239 used_in_workspaces: vec![],
5240 })],
5241 ..AnalysisResults::default()
5242 };
5243
5244 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5245
5246 assert_eq!(results.unused_dependencies.len(), 1);
5247 }
5248
5249 fn thin_wrapper_finding(path: &str) -> ThinWrapperFinding {
5250 ThinWrapperFinding::with_actions(ThinWrapper {
5251 file: PathBuf::from(path),
5252 line: 1,
5253 component: "Wrapper".to_string(),
5254 child_component: "Child".to_string(),
5255 })
5256 }
5257
5258 fn duplicate_prop_shape_finding(path: &str) -> DuplicatePropShapeFinding {
5259 DuplicatePropShapeFinding::with_actions(DuplicatePropShape {
5260 file: PathBuf::from(path),
5261 line: 1,
5262 component: "Card".to_string(),
5263 shape: vec!["title".to_string(), "subtitle".to_string()],
5264 group_size: 3,
5265 sharing_components: vec![],
5266 })
5267 }
5268
5269 fn prop_drilling_chain_finding(paths: &[&str]) -> PropDrillingChainFinding {
5270 PropDrillingChainFinding::with_actions(PropDrillingChain {
5271 prop: "user".to_string(),
5272 depth: paths.len() as u32,
5273 hops: paths
5274 .iter()
5275 .map(|path| PropDrillHop {
5276 file: PathBuf::from(path),
5277 line: 1,
5278 component: "Hop".to_string(),
5279 })
5280 .collect(),
5281 })
5282 }
5283
5284 #[test]
5285 fn finding_ignore_hides_thin_wrappers_by_wrapper_file() {
5286 let mut results = AnalysisResults {
5287 thin_wrappers: vec![
5288 thin_wrapper_finding("ignored/Wrapper.tsx"),
5289 thin_wrapper_finding("src/Wrapper.tsx"),
5290 ],
5291 ..AnalysisResults::default()
5292 };
5293
5294 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5295
5296 assert_eq!(results.thin_wrappers.len(), 1);
5297 assert_eq!(
5298 results.thin_wrappers[0].wrapper.file,
5299 PathBuf::from("src/Wrapper.tsx")
5300 );
5301 }
5302
5303 #[test]
5304 fn finding_ignore_hides_duplicate_prop_shapes_by_component_file() {
5305 let mut results = AnalysisResults {
5306 duplicate_prop_shapes: vec![
5307 duplicate_prop_shape_finding("ignored/Card.tsx"),
5308 duplicate_prop_shape_finding("src/Card.tsx"),
5309 ],
5310 ..AnalysisResults::default()
5311 };
5312
5313 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5314
5315 assert_eq!(results.duplicate_prop_shapes.len(), 1);
5316 assert_eq!(
5317 results.duplicate_prop_shapes[0].shape.file,
5318 PathBuf::from("src/Card.tsx")
5319 );
5320 }
5321
5322 #[test]
5323 fn finding_ignore_hides_prop_drilling_chains_only_when_every_hop_matches() {
5324 let mut results = AnalysisResults {
5325 prop_drilling_chains: vec![
5326 prop_drilling_chain_finding(&["ignored/a.tsx", "ignored/b.tsx"]),
5327 prop_drilling_chain_finding(&["ignored/a.tsx", "src/b.tsx"]),
5328 prop_drilling_chain_finding(&[]),
5329 ],
5330 ..AnalysisResults::default()
5331 };
5332
5333 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5334
5335 assert_eq!(results.prop_drilling_chains.len(), 2);
5336 assert_eq!(results.prop_drilling_chains[0].chain.hops.len(), 2);
5337 assert!(results.prop_drilling_chains[1].chain.hops.is_empty());
5338 }
5339
5340 #[test]
5341 fn finding_ignore_retains_security_findings_and_blind_spot_diagnostics() {
5342 let path = PathBuf::from("ignored/leak.ts");
5343 let mut results = AnalysisResults {
5344 security_findings: vec![SecurityFinding {
5345 finding_id: "id".to_string(),
5346 kind: SecurityFindingKind::TaintedSink,
5347 category: Some("dangerous-html".to_string()),
5348 cwe: Some(79),
5349 path: path.clone(),
5350 line: 1,
5351 col: 0,
5352 evidence: "candidate".to_string(),
5353 source_backed: false,
5354 source_read: None,
5355 severity: SecuritySeverity::Low,
5356 trace: vec![TraceHop {
5357 path: path.clone(),
5358 line: 1,
5359 col: 0,
5360 role: TraceHopRole::Sink,
5361 }],
5362 actions: vec![],
5363 dead_code: None,
5364 reachability: None,
5365 candidate: SecurityCandidate {
5366 source_kind: None,
5367 sink: SecurityCandidateSink {
5368 path: path.clone(),
5369 line: 1,
5370 col: 0,
5371 category: Some("dangerous-html".to_string()),
5372 cwe: Some(79),
5373 callee: None,
5374 url_shape: None,
5375 },
5376 boundary: SecurityCandidateBoundary::default(),
5377 network: None,
5378 },
5379 taint_flow: None,
5380 runtime: None,
5381 attack_surface: None,
5382 }],
5383 security_unresolved_callee_diagnostics: vec![SecurityUnresolvedCalleeDiagnostic {
5384 path,
5385 line: 1,
5386 col: 0,
5387 reason: SkippedSecurityCalleeReason::DynamicDispatch,
5388 expression_kind: SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
5389 }],
5390 ..AnalysisResults::default()
5391 };
5392
5393 results.remove_ignored_dead_code_findings(|path| path.starts_with("ignored"));
5394
5395 assert_eq!(results.security_findings.len(), 1);
5396 assert_eq!(results.security_unresolved_callee_diagnostics.len(), 1);
5397 }
5398
5399 // ── export_usages not counted in total_issues ───────────────
5400
5401 #[test]
5402 fn export_usages_not_counted_in_total_issues() {
5403 let mut r = AnalysisResults::default();
5404 r.export_usages.push(ExportUsage {
5405 path: PathBuf::from("mod.ts"),
5406 export_name: "foo".to_string(),
5407 line: 1,
5408 col: 0,
5409 reference_count: 3,
5410 reference_locations: vec![],
5411 });
5412 // export_usages is metadata, not an issue type
5413 assert_eq!(r.total_issues(), 0);
5414 assert!(!r.has_issues());
5415 }
5416
5417 // ── entry_point_summary not counted in total_issues ─────────
5418
5419 #[test]
5420 fn entry_point_summary_not_counted_in_total_issues() {
5421 let r = AnalysisResults {
5422 entry_point_summary: Some(EntryPointSummary {
5423 total: 10,
5424 by_source: vec![("config".to_string(), 10)],
5425 }),
5426 ..AnalysisResults::default()
5427 };
5428 assert_eq!(r.total_issues(), 0);
5429 assert!(!r.has_issues());
5430 }
5431}