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