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