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