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