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