Skip to main content

fallow_types/
results.rs

1//! Analysis result types for all issue categories.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::serde_path;
9
10/// Complete analysis results.
11///
12/// # Examples
13///
14/// ```
15/// use fallow_types::results::{AnalysisResults, UnusedFile};
16/// use std::path::PathBuf;
17///
18/// let mut results = AnalysisResults::default();
19/// assert_eq!(results.total_issues(), 0);
20/// assert!(!results.has_issues());
21///
22/// results.unused_files.push(UnusedFile {
23///     path: PathBuf::from("src/dead.ts"),
24/// });
25/// assert_eq!(results.total_issues(), 1);
26/// assert!(results.has_issues());
27/// ```
28#[derive(Debug, Default, Clone, Serialize)]
29pub struct AnalysisResults {
30    /// Files not reachable from any entry point.
31    pub unused_files: Vec<UnusedFile>,
32    /// Exports never imported by other modules.
33    pub unused_exports: Vec<UnusedExport>,
34    /// Type exports never imported by other modules.
35    pub unused_types: Vec<UnusedExport>,
36    /// Dependencies listed in package.json but never imported.
37    pub unused_dependencies: Vec<UnusedDependency>,
38    /// Dev dependencies listed in package.json but never imported.
39    pub unused_dev_dependencies: Vec<UnusedDependency>,
40    /// Optional dependencies listed in package.json but never imported.
41    pub unused_optional_dependencies: Vec<UnusedDependency>,
42    /// Enum members never accessed.
43    pub unused_enum_members: Vec<UnusedMember>,
44    /// Class members never accessed.
45    pub unused_class_members: Vec<UnusedMember>,
46    /// Import specifiers that could not be resolved.
47    pub unresolved_imports: Vec<UnresolvedImport>,
48    /// Dependencies used in code but not listed in package.json.
49    pub unlisted_dependencies: Vec<UnlistedDependency>,
50    /// Exports with the same name across multiple modules.
51    pub duplicate_exports: Vec<DuplicateExport>,
52    /// Production dependencies only used via type-only imports (could be devDependencies).
53    /// Only populated in production mode.
54    pub type_only_dependencies: Vec<TypeOnlyDependency>,
55    /// Production dependencies only imported by test files (could be devDependencies).
56    #[serde(default)]
57    pub test_only_dependencies: Vec<TestOnlyDependency>,
58    /// Circular dependency chains detected in the module graph.
59    pub circular_dependencies: Vec<CircularDependency>,
60    /// Imports that cross architecture boundary rules.
61    #[serde(default)]
62    pub boundary_violations: Vec<BoundaryViolation>,
63    /// Usage counts for all exports across the project. Used by the LSP for Code Lens.
64    /// Not included in issue counts -- this is metadata, not an issue type.
65    /// Skipped during serialization: this is internal LSP data, not part of the JSON output schema.
66    #[serde(skip)]
67    pub export_usages: Vec<ExportUsage>,
68}
69
70impl AnalysisResults {
71    /// Total number of issues found.
72    ///
73    /// Sums across all issue categories (unused files, exports, types,
74    /// dependencies, members, unresolved imports, unlisted deps, duplicates,
75    /// type-only deps, circular deps, and boundary violations).
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use fallow_types::results::{AnalysisResults, UnusedFile, UnresolvedImport};
81    /// use std::path::PathBuf;
82    ///
83    /// let mut results = AnalysisResults::default();
84    /// results.unused_files.push(UnusedFile { path: PathBuf::from("a.ts") });
85    /// results.unresolved_imports.push(UnresolvedImport {
86    ///     path: PathBuf::from("b.ts"),
87    ///     specifier: "./missing".to_string(),
88    ///     line: 1,
89    ///     col: 0,
90    ///     specifier_col: 0,
91    /// });
92    /// assert_eq!(results.total_issues(), 2);
93    /// ```
94    #[must_use]
95    pub const fn total_issues(&self) -> usize {
96        self.unused_files.len()
97            + self.unused_exports.len()
98            + self.unused_types.len()
99            + self.unused_dependencies.len()
100            + self.unused_dev_dependencies.len()
101            + self.unused_optional_dependencies.len()
102            + self.unused_enum_members.len()
103            + self.unused_class_members.len()
104            + self.unresolved_imports.len()
105            + self.unlisted_dependencies.len()
106            + self.duplicate_exports.len()
107            + self.type_only_dependencies.len()
108            + self.test_only_dependencies.len()
109            + self.circular_dependencies.len()
110            + self.boundary_violations.len()
111    }
112
113    /// Whether any issues were found.
114    #[must_use]
115    pub const fn has_issues(&self) -> bool {
116        self.total_issues() > 0
117    }
118
119    /// Sort all result arrays for deterministic output ordering.
120    ///
121    /// Parallel collection (rayon, `FxHashMap` iteration) does not guarantee
122    /// insertion order, so the same project can produce different orderings
123    /// across runs. This method canonicalises every result list by sorting on
124    /// (path, line, col, name) so that JSON/SARIF/human output is stable.
125    pub fn sort(&mut self) {
126        self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
127
128        self.unused_exports.sort_by(|a, b| {
129            a.path
130                .cmp(&b.path)
131                .then(a.line.cmp(&b.line))
132                .then(a.export_name.cmp(&b.export_name))
133        });
134
135        self.unused_types.sort_by(|a, b| {
136            a.path
137                .cmp(&b.path)
138                .then(a.line.cmp(&b.line))
139                .then(a.export_name.cmp(&b.export_name))
140        });
141
142        self.unused_dependencies.sort_by(|a, b| {
143            a.path
144                .cmp(&b.path)
145                .then(a.line.cmp(&b.line))
146                .then(a.package_name.cmp(&b.package_name))
147        });
148
149        self.unused_dev_dependencies.sort_by(|a, b| {
150            a.path
151                .cmp(&b.path)
152                .then(a.line.cmp(&b.line))
153                .then(a.package_name.cmp(&b.package_name))
154        });
155
156        self.unused_optional_dependencies.sort_by(|a, b| {
157            a.path
158                .cmp(&b.path)
159                .then(a.line.cmp(&b.line))
160                .then(a.package_name.cmp(&b.package_name))
161        });
162
163        self.unused_enum_members.sort_by(|a, b| {
164            a.path
165                .cmp(&b.path)
166                .then(a.line.cmp(&b.line))
167                .then(a.parent_name.cmp(&b.parent_name))
168                .then(a.member_name.cmp(&b.member_name))
169        });
170
171        self.unused_class_members.sort_by(|a, b| {
172            a.path
173                .cmp(&b.path)
174                .then(a.line.cmp(&b.line))
175                .then(a.parent_name.cmp(&b.parent_name))
176                .then(a.member_name.cmp(&b.member_name))
177        });
178
179        self.unresolved_imports.sort_by(|a, b| {
180            a.path
181                .cmp(&b.path)
182                .then(a.line.cmp(&b.line))
183                .then(a.col.cmp(&b.col))
184                .then(a.specifier.cmp(&b.specifier))
185        });
186
187        self.unlisted_dependencies
188            .sort_by(|a, b| a.package_name.cmp(&b.package_name));
189        for dep in &mut self.unlisted_dependencies {
190            dep.imported_from
191                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
192        }
193
194        self.duplicate_exports
195            .sort_by(|a, b| a.export_name.cmp(&b.export_name));
196        for dup in &mut self.duplicate_exports {
197            dup.locations
198                .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
199        }
200
201        self.type_only_dependencies.sort_by(|a, b| {
202            a.path
203                .cmp(&b.path)
204                .then(a.line.cmp(&b.line))
205                .then(a.package_name.cmp(&b.package_name))
206        });
207
208        self.test_only_dependencies.sort_by(|a, b| {
209            a.path
210                .cmp(&b.path)
211                .then(a.line.cmp(&b.line))
212                .then(a.package_name.cmp(&b.package_name))
213        });
214
215        self.circular_dependencies
216            .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
217
218        self.boundary_violations.sort_by(|a, b| {
219            a.from_path
220                .cmp(&b.from_path)
221                .then(a.line.cmp(&b.line))
222                .then(a.col.cmp(&b.col))
223                .then(a.to_path.cmp(&b.to_path))
224        });
225
226        for usage in &mut self.export_usages {
227            usage.reference_locations.sort_by(|a, b| {
228                a.path
229                    .cmp(&b.path)
230                    .then(a.line.cmp(&b.line))
231                    .then(a.col.cmp(&b.col))
232            });
233        }
234        self.export_usages.sort_by(|a, b| {
235            a.path
236                .cmp(&b.path)
237                .then(a.line.cmp(&b.line))
238                .then(a.export_name.cmp(&b.export_name))
239        });
240    }
241}
242
243/// A file that is not reachable from any entry point.
244#[derive(Debug, Clone, Serialize)]
245pub struct UnusedFile {
246    /// Absolute path to the unused file.
247    #[serde(serialize_with = "serde_path::serialize")]
248    pub path: PathBuf,
249}
250
251/// An export that is never imported by other modules.
252#[derive(Debug, Clone, Serialize)]
253pub struct UnusedExport {
254    /// File containing the unused export.
255    #[serde(serialize_with = "serde_path::serialize")]
256    pub path: PathBuf,
257    /// Name of the unused export.
258    pub export_name: String,
259    /// Whether this is a type-only export.
260    pub is_type_only: bool,
261    /// 1-based line number of the export.
262    pub line: u32,
263    /// 0-based byte column offset.
264    pub col: u32,
265    /// Byte offset into the source file (used by the fix command).
266    pub span_start: u32,
267    /// Whether this finding comes from a barrel/index re-export rather than the source definition.
268    pub is_re_export: bool,
269}
270
271/// A dependency that is listed in package.json but never imported.
272#[derive(Debug, Clone, Serialize)]
273pub struct UnusedDependency {
274    /// npm package name.
275    pub package_name: String,
276    /// Whether this is in `dependencies`, `devDependencies`, or `optionalDependencies`.
277    pub location: DependencyLocation,
278    /// Path to the package.json where this dependency is listed.
279    /// For root deps this is `<root>/package.json`, for workspace deps it is `<ws>/package.json`.
280    #[serde(serialize_with = "serde_path::serialize")]
281    pub path: PathBuf,
282    /// 1-based line number of the dependency entry in package.json.
283    pub line: u32,
284}
285
286/// Where in package.json a dependency is listed.
287///
288/// # Examples
289///
290/// ```
291/// use fallow_types::results::DependencyLocation;
292///
293/// // All three variants are constructible
294/// let loc = DependencyLocation::Dependencies;
295/// let dev = DependencyLocation::DevDependencies;
296/// let opt = DependencyLocation::OptionalDependencies;
297/// // Debug output includes the variant name
298/// assert!(format!("{loc:?}").contains("Dependencies"));
299/// assert!(format!("{dev:?}").contains("DevDependencies"));
300/// assert!(format!("{opt:?}").contains("OptionalDependencies"));
301/// ```
302#[derive(Debug, Clone, Serialize)]
303#[serde(rename_all = "camelCase")]
304pub enum DependencyLocation {
305    /// Listed in `dependencies`.
306    Dependencies,
307    /// Listed in `devDependencies`.
308    DevDependencies,
309    /// Listed in `optionalDependencies`.
310    OptionalDependencies,
311}
312
313/// An unused enum or class member.
314#[derive(Debug, Clone, Serialize)]
315pub struct UnusedMember {
316    /// File containing the unused member.
317    #[serde(serialize_with = "serde_path::serialize")]
318    pub path: PathBuf,
319    /// Name of the parent enum or class.
320    pub parent_name: String,
321    /// Name of the unused member.
322    pub member_name: String,
323    /// Whether this is an enum member, class method, or class property.
324    pub kind: MemberKind,
325    /// 1-based line number.
326    pub line: u32,
327    /// 0-based byte column offset.
328    pub col: u32,
329}
330
331/// An import that could not be resolved.
332#[derive(Debug, Clone, Serialize)]
333pub struct UnresolvedImport {
334    /// File containing the unresolved import.
335    #[serde(serialize_with = "serde_path::serialize")]
336    pub path: PathBuf,
337    /// The import specifier that could not be resolved.
338    pub specifier: String,
339    /// 1-based line number.
340    pub line: u32,
341    /// 0-based byte column offset of the import statement.
342    pub col: u32,
343    /// 0-based byte column offset of the source string literal (the specifier in quotes).
344    /// Used by the LSP to underline just the specifier, not the entire import line.
345    pub specifier_col: u32,
346}
347
348/// A dependency used in code but not listed in package.json.
349#[derive(Debug, Clone, Serialize)]
350pub struct UnlistedDependency {
351    /// npm package name.
352    pub package_name: String,
353    /// Import sites where this unlisted dependency is used (file path, line, column).
354    pub imported_from: Vec<ImportSite>,
355}
356
357/// A location where an import occurs.
358#[derive(Debug, Clone, Serialize)]
359pub struct ImportSite {
360    /// File containing the import.
361    #[serde(serialize_with = "serde_path::serialize")]
362    pub path: PathBuf,
363    /// 1-based line number.
364    pub line: u32,
365    /// 0-based byte column offset.
366    pub col: u32,
367}
368
369/// An export that appears multiple times across the project.
370#[derive(Debug, Clone, Serialize)]
371pub struct DuplicateExport {
372    /// The duplicated export name.
373    pub export_name: String,
374    /// Locations where this export name appears.
375    pub locations: Vec<DuplicateLocation>,
376}
377
378/// A location where a duplicate export appears.
379#[derive(Debug, Clone, Serialize)]
380pub struct DuplicateLocation {
381    /// File containing the duplicate export.
382    #[serde(serialize_with = "serde_path::serialize")]
383    pub path: PathBuf,
384    /// 1-based line number.
385    pub line: u32,
386    /// 0-based byte column offset.
387    pub col: u32,
388}
389
390/// A production dependency that is only used via type-only imports.
391/// In production builds, type imports are erased, so this dependency
392/// is not needed at runtime and could be moved to devDependencies.
393#[derive(Debug, Clone, Serialize)]
394pub struct TypeOnlyDependency {
395    /// npm package name.
396    pub package_name: String,
397    /// Path to the package.json where the dependency is listed.
398    #[serde(serialize_with = "serde_path::serialize")]
399    pub path: PathBuf,
400    /// 1-based line number of the dependency entry in package.json.
401    pub line: u32,
402}
403
404/// A production dependency that is only imported by test files.
405/// Since it is never used in production code, it could be moved to devDependencies.
406#[derive(Debug, Clone, Serialize)]
407pub struct TestOnlyDependency {
408    /// npm package name.
409    pub package_name: String,
410    /// Path to the package.json where the dependency is listed.
411    #[serde(serialize_with = "serde_path::serialize")]
412    pub path: PathBuf,
413    /// 1-based line number of the dependency entry in package.json.
414    pub line: u32,
415}
416
417/// A circular dependency chain detected in the module graph.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct CircularDependency {
420    /// Files forming the cycle, in import order.
421    #[serde(serialize_with = "serde_path::serialize_vec")]
422    pub files: Vec<PathBuf>,
423    /// Number of files in the cycle.
424    pub length: usize,
425    /// 1-based line number of the import that starts the cycle (in the first file).
426    #[serde(default)]
427    pub line: u32,
428    /// 0-based byte column offset of the import that starts the cycle.
429    #[serde(default)]
430    pub col: u32,
431}
432
433/// An import that crosses an architecture boundary rule.
434#[derive(Debug, Clone, Serialize)]
435pub struct BoundaryViolation {
436    /// The file making the disallowed import.
437    #[serde(serialize_with = "serde_path::serialize")]
438    pub from_path: PathBuf,
439    /// The file being imported that violates the boundary.
440    #[serde(serialize_with = "serde_path::serialize")]
441    pub to_path: PathBuf,
442    /// The zone the importing file belongs to.
443    pub from_zone: String,
444    /// The zone the imported file belongs to.
445    pub to_zone: String,
446    /// The raw import specifier from the source file.
447    pub import_specifier: String,
448    /// 1-based line number of the import statement in the source file.
449    pub line: u32,
450    /// 0-based byte column offset of the import statement.
451    pub col: u32,
452}
453
454/// Usage count for an export symbol. Used by the LSP Code Lens to show
455/// reference counts above each export declaration.
456#[derive(Debug, Clone, Serialize)]
457pub struct ExportUsage {
458    /// File containing the export.
459    #[serde(serialize_with = "serde_path::serialize")]
460    pub path: PathBuf,
461    /// Name of the exported symbol.
462    pub export_name: String,
463    /// 1-based line number.
464    pub line: u32,
465    /// 0-based byte column offset.
466    pub col: u32,
467    /// Number of files that reference this export.
468    pub reference_count: usize,
469    /// Locations where this export is referenced. Used by the LSP Code Lens
470    /// to enable click-to-navigate via `editor.action.showReferences`.
471    pub reference_locations: Vec<ReferenceLocation>,
472}
473
474/// A location where an export is referenced (import site in another file).
475#[derive(Debug, Clone, Serialize)]
476pub struct ReferenceLocation {
477    /// File containing the import that references the export.
478    #[serde(serialize_with = "serde_path::serialize")]
479    pub path: PathBuf,
480    /// 1-based line number.
481    pub line: u32,
482    /// 0-based byte column offset.
483    pub col: u32,
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn empty_results_no_issues() {
492        let results = AnalysisResults::default();
493        assert_eq!(results.total_issues(), 0);
494        assert!(!results.has_issues());
495    }
496
497    #[test]
498    fn results_with_unused_file() {
499        let mut results = AnalysisResults::default();
500        results.unused_files.push(UnusedFile {
501            path: PathBuf::from("test.ts"),
502        });
503        assert_eq!(results.total_issues(), 1);
504        assert!(results.has_issues());
505    }
506
507    #[test]
508    fn results_with_unused_export() {
509        let mut results = AnalysisResults::default();
510        results.unused_exports.push(UnusedExport {
511            path: PathBuf::from("test.ts"),
512            export_name: "foo".to_string(),
513            is_type_only: false,
514            line: 1,
515            col: 0,
516            span_start: 0,
517            is_re_export: false,
518        });
519        assert_eq!(results.total_issues(), 1);
520        assert!(results.has_issues());
521    }
522
523    #[test]
524    fn results_total_counts_all_types() {
525        let mut results = AnalysisResults::default();
526        results.unused_files.push(UnusedFile {
527            path: PathBuf::from("a.ts"),
528        });
529        results.unused_exports.push(UnusedExport {
530            path: PathBuf::from("b.ts"),
531            export_name: "x".to_string(),
532            is_type_only: false,
533            line: 1,
534            col: 0,
535            span_start: 0,
536            is_re_export: false,
537        });
538        results.unused_types.push(UnusedExport {
539            path: PathBuf::from("c.ts"),
540            export_name: "T".to_string(),
541            is_type_only: true,
542            line: 1,
543            col: 0,
544            span_start: 0,
545            is_re_export: false,
546        });
547        results.unused_dependencies.push(UnusedDependency {
548            package_name: "dep".to_string(),
549            location: DependencyLocation::Dependencies,
550            path: PathBuf::from("package.json"),
551            line: 5,
552        });
553        results.unused_dev_dependencies.push(UnusedDependency {
554            package_name: "dev".to_string(),
555            location: DependencyLocation::DevDependencies,
556            path: PathBuf::from("package.json"),
557            line: 5,
558        });
559        results.unused_enum_members.push(UnusedMember {
560            path: PathBuf::from("d.ts"),
561            parent_name: "E".to_string(),
562            member_name: "A".to_string(),
563            kind: MemberKind::EnumMember,
564            line: 1,
565            col: 0,
566        });
567        results.unused_class_members.push(UnusedMember {
568            path: PathBuf::from("e.ts"),
569            parent_name: "C".to_string(),
570            member_name: "m".to_string(),
571            kind: MemberKind::ClassMethod,
572            line: 1,
573            col: 0,
574        });
575        results.unresolved_imports.push(UnresolvedImport {
576            path: PathBuf::from("f.ts"),
577            specifier: "./missing".to_string(),
578            line: 1,
579            col: 0,
580            specifier_col: 0,
581        });
582        results.unlisted_dependencies.push(UnlistedDependency {
583            package_name: "unlisted".to_string(),
584            imported_from: vec![ImportSite {
585                path: PathBuf::from("g.ts"),
586                line: 1,
587                col: 0,
588            }],
589        });
590        results.duplicate_exports.push(DuplicateExport {
591            export_name: "dup".to_string(),
592            locations: vec![
593                DuplicateLocation {
594                    path: PathBuf::from("h.ts"),
595                    line: 15,
596                    col: 0,
597                },
598                DuplicateLocation {
599                    path: PathBuf::from("i.ts"),
600                    line: 30,
601                    col: 0,
602                },
603            ],
604        });
605        results.unused_optional_dependencies.push(UnusedDependency {
606            package_name: "optional".to_string(),
607            location: DependencyLocation::OptionalDependencies,
608            path: PathBuf::from("package.json"),
609            line: 5,
610        });
611        results.type_only_dependencies.push(TypeOnlyDependency {
612            package_name: "type-only".to_string(),
613            path: PathBuf::from("package.json"),
614            line: 8,
615        });
616        results.test_only_dependencies.push(TestOnlyDependency {
617            package_name: "test-only".to_string(),
618            path: PathBuf::from("package.json"),
619            line: 9,
620        });
621        results.circular_dependencies.push(CircularDependency {
622            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
623            length: 2,
624            line: 3,
625            col: 0,
626        });
627        results.boundary_violations.push(BoundaryViolation {
628            from_path: PathBuf::from("src/ui/Button.tsx"),
629            to_path: PathBuf::from("src/db/queries.ts"),
630            from_zone: "ui".to_string(),
631            to_zone: "database".to_string(),
632            import_specifier: "../db/queries".to_string(),
633            line: 3,
634            col: 0,
635        });
636
637        // 15 categories, one of each
638        assert_eq!(results.total_issues(), 15);
639        assert!(results.has_issues());
640    }
641
642    // ── total_issues / has_issues consistency ──────────────────
643
644    #[test]
645    fn total_issues_and_has_issues_are_consistent() {
646        let results = AnalysisResults::default();
647        assert_eq!(results.total_issues(), 0);
648        assert!(!results.has_issues());
649        assert_eq!(results.total_issues() > 0, results.has_issues());
650    }
651
652    // ── total_issues counts each category independently ─────────
653
654    #[test]
655    fn total_issues_sums_all_categories_independently() {
656        let mut results = AnalysisResults::default();
657        results.unused_files.push(UnusedFile {
658            path: PathBuf::from("a.ts"),
659        });
660        assert_eq!(results.total_issues(), 1);
661
662        results.unused_files.push(UnusedFile {
663            path: PathBuf::from("b.ts"),
664        });
665        assert_eq!(results.total_issues(), 2);
666
667        results.unresolved_imports.push(UnresolvedImport {
668            path: PathBuf::from("c.ts"),
669            specifier: "./missing".to_string(),
670            line: 1,
671            col: 0,
672            specifier_col: 0,
673        });
674        assert_eq!(results.total_issues(), 3);
675    }
676
677    // ── default is truly empty ──────────────────────────────────
678
679    #[test]
680    fn default_results_all_fields_empty() {
681        let r = AnalysisResults::default();
682        assert!(r.unused_files.is_empty());
683        assert!(r.unused_exports.is_empty());
684        assert!(r.unused_types.is_empty());
685        assert!(r.unused_dependencies.is_empty());
686        assert!(r.unused_dev_dependencies.is_empty());
687        assert!(r.unused_optional_dependencies.is_empty());
688        assert!(r.unused_enum_members.is_empty());
689        assert!(r.unused_class_members.is_empty());
690        assert!(r.unresolved_imports.is_empty());
691        assert!(r.unlisted_dependencies.is_empty());
692        assert!(r.duplicate_exports.is_empty());
693        assert!(r.type_only_dependencies.is_empty());
694        assert!(r.test_only_dependencies.is_empty());
695        assert!(r.circular_dependencies.is_empty());
696        assert!(r.boundary_violations.is_empty());
697        assert!(r.export_usages.is_empty());
698    }
699}