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