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