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