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