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