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