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