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