Skip to main content

fallow_types/
results.rs

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