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