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