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