1use std::path::{Path, PathBuf};
7
8use fallow_config::WorkspaceInfo;
9use fallow_core::results::AnalysisResults;
10use rustc_hash::FxHashMap;
11
12use super::relative_path;
13use crate::codeowners::{self, CodeOwners, UNOWNED_LABEL};
14
15pub enum OwnershipResolver {
20 Owner(CodeOwners),
22 Directory,
24 Package(PackageResolver),
26}
27
28pub struct PackageResolver {
33 workspaces: Vec<(PathBuf, String)>,
35}
36
37const ROOT_PACKAGE_LABEL: &str = "(root)";
38
39impl PackageResolver {
40 pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
45 let mut ws: Vec<(PathBuf, String)> = workspaces
46 .iter()
47 .map(|w| {
48 let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
49 (rel.to_path_buf(), w.name.clone())
50 })
51 .collect();
52 ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
53 Self { workspaces: ws }
54 }
55
56 fn resolve(&self, rel_path: &Path) -> &str {
58 self.workspaces
59 .iter()
60 .find(|(root, _)| rel_path.starts_with(root))
61 .map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
62 }
63}
64
65impl OwnershipResolver {
66 pub fn resolve(&self, rel_path: &Path) -> String {
68 match self {
69 Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
70 Self::Directory => codeowners::directory_group(rel_path).to_string(),
71 Self::Package(pr) => pr.resolve(rel_path).to_string(),
72 }
73 }
74
75 pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
80 match self {
81 Self::Owner(co) => {
82 if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
83 (owner.to_string(), Some(rule.to_string()))
84 } else {
85 (UNOWNED_LABEL.to_string(), None)
86 }
87 }
88 Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
89 Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
90 }
91 }
92
93 pub fn mode_label(&self) -> &'static str {
95 match self {
96 Self::Owner(_) => "owner",
97 Self::Directory => "directory",
98 Self::Package(_) => "package",
99 }
100 }
101}
102
103pub struct ResultGroup {
105 pub key: String,
107 pub results: AnalysisResults,
109}
110
111pub fn group_analysis_results(
117 results: &AnalysisResults,
118 root: &Path,
119 resolver: &OwnershipResolver,
120) -> Vec<ResultGroup> {
121 let mut groups: FxHashMap<String, AnalysisResults> = FxHashMap::default();
122
123 let key_for = |path: &Path| -> String { resolver.resolve(relative_path(path, root)) };
124
125 for item in &results.unused_files {
127 groups
128 .entry(key_for(&item.path))
129 .or_default()
130 .unused_files
131 .push(item.clone());
132 }
133 for item in &results.unused_exports {
134 groups
135 .entry(key_for(&item.path))
136 .or_default()
137 .unused_exports
138 .push(item.clone());
139 }
140 for item in &results.unused_types {
141 groups
142 .entry(key_for(&item.path))
143 .or_default()
144 .unused_types
145 .push(item.clone());
146 }
147 for item in &results.unused_enum_members {
148 groups
149 .entry(key_for(&item.path))
150 .or_default()
151 .unused_enum_members
152 .push(item.clone());
153 }
154 for item in &results.unused_class_members {
155 groups
156 .entry(key_for(&item.path))
157 .or_default()
158 .unused_class_members
159 .push(item.clone());
160 }
161 for item in &results.unresolved_imports {
162 groups
163 .entry(key_for(&item.path))
164 .or_default()
165 .unresolved_imports
166 .push(item.clone());
167 }
168
169 for item in &results.unused_dependencies {
171 groups
172 .entry(key_for(&item.path))
173 .or_default()
174 .unused_dependencies
175 .push(item.clone());
176 }
177 for item in &results.unused_dev_dependencies {
178 groups
179 .entry(key_for(&item.path))
180 .or_default()
181 .unused_dev_dependencies
182 .push(item.clone());
183 }
184 for item in &results.unused_optional_dependencies {
185 groups
186 .entry(key_for(&item.path))
187 .or_default()
188 .unused_optional_dependencies
189 .push(item.clone());
190 }
191 for item in &results.type_only_dependencies {
192 groups
193 .entry(key_for(&item.path))
194 .or_default()
195 .type_only_dependencies
196 .push(item.clone());
197 }
198 for item in &results.test_only_dependencies {
199 groups
200 .entry(key_for(&item.path))
201 .or_default()
202 .test_only_dependencies
203 .push(item.clone());
204 }
205
206 for item in &results.unlisted_dependencies {
208 let key = item
209 .imported_from
210 .first()
211 .map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
212 groups
213 .entry(key)
214 .or_default()
215 .unlisted_dependencies
216 .push(item.clone());
217 }
218 for item in &results.duplicate_exports {
219 let key = item
220 .locations
221 .first()
222 .map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
223 groups
224 .entry(key)
225 .or_default()
226 .duplicate_exports
227 .push(item.clone());
228 }
229 for item in &results.circular_dependencies {
230 let key = item
231 .files
232 .first()
233 .map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
234 groups
235 .entry(key)
236 .or_default()
237 .circular_dependencies
238 .push(item.clone());
239 }
240 for item in &results.boundary_violations {
241 groups
242 .entry(key_for(&item.from_path))
243 .or_default()
244 .boundary_violations
245 .push(item.clone());
246 }
247 for item in &results.stale_suppressions {
248 groups
249 .entry(key_for(&item.path))
250 .or_default()
251 .stale_suppressions
252 .push(item.clone());
253 }
254
255 let mut sorted: Vec<_> = groups
257 .into_iter()
258 .map(|(key, results)| ResultGroup { key, results })
259 .collect();
260 sorted.sort_by(|a, b| {
261 let a_unowned = a.key == UNOWNED_LABEL;
262 let b_unowned = b.key == UNOWNED_LABEL;
263 match (a_unowned, b_unowned) {
264 (true, false) => std::cmp::Ordering::Greater,
265 (false, true) => std::cmp::Ordering::Less,
266 _ => b
267 .results
268 .total_issues()
269 .cmp(&a.results.total_issues())
270 .then_with(|| a.key.cmp(&b.key)),
271 }
272 });
273 sorted
274}
275
276pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
278 resolver.resolve(relative_path(path, root))
279}
280
281#[cfg(test)]
282mod tests {
283 use std::path::{Path, PathBuf};
284
285 use fallow_core::results::*;
286
287 use super::*;
288 use crate::codeowners::CodeOwners;
289
290 fn root() -> PathBuf {
293 PathBuf::from("/root")
294 }
295
296 fn unused_file(path: &str) -> UnusedFile {
297 UnusedFile {
298 path: PathBuf::from(path),
299 }
300 }
301
302 fn unused_export(path: &str, name: &str) -> UnusedExport {
303 UnusedExport {
304 path: PathBuf::from(path),
305 export_name: name.to_string(),
306 is_type_only: false,
307 line: 1,
308 col: 0,
309 span_start: 0,
310 is_re_export: false,
311 }
312 }
313
314 fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
315 UnlistedDependency {
316 package_name: name.to_string(),
317 imported_from: sites,
318 }
319 }
320
321 fn import_site(path: &str) -> ImportSite {
322 ImportSite {
323 path: PathBuf::from(path),
324 line: 1,
325 col: 0,
326 }
327 }
328
329 #[test]
332 fn empty_results_returns_empty_vec() {
333 let results = AnalysisResults::default();
334 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
335 assert!(groups.is_empty());
336 }
337
338 #[test]
341 fn single_group_all_same_directory() {
342 let mut results = AnalysisResults::default();
343 results.unused_files.push(unused_file("/root/src/a.ts"));
344 results.unused_files.push(unused_file("/root/src/b.ts"));
345 results
346 .unused_exports
347 .push(unused_export("/root/src/c.ts", "foo"));
348
349 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
350
351 assert_eq!(groups.len(), 1);
352 assert_eq!(groups[0].key, "src");
353 assert_eq!(groups[0].results.unused_files.len(), 2);
354 assert_eq!(groups[0].results.unused_exports.len(), 1);
355 assert_eq!(groups[0].results.total_issues(), 3);
356 }
357
358 #[test]
361 fn multiple_groups_split_by_directory() {
362 let mut results = AnalysisResults::default();
363 results.unused_files.push(unused_file("/root/src/a.ts"));
364 results.unused_files.push(unused_file("/root/lib/b.ts"));
365 results
366 .unused_exports
367 .push(unused_export("/root/src/c.ts", "bar"));
368
369 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
370
371 assert_eq!(groups.len(), 2);
372
373 let src_group = groups.iter().find(|g| g.key == "src").unwrap();
374 let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
375
376 assert_eq!(src_group.results.total_issues(), 2);
377 assert_eq!(lib_group.results.total_issues(), 1);
378 }
379
380 #[test]
383 fn sort_order_descending_by_total_issues() {
384 let mut results = AnalysisResults::default();
385 results.unused_files.push(unused_file("/root/lib/a.ts"));
387 results.unused_files.push(unused_file("/root/src/a.ts"));
389 results.unused_files.push(unused_file("/root/src/b.ts"));
390 results
391 .unused_exports
392 .push(unused_export("/root/src/c.ts", "x"));
393 results.unused_files.push(unused_file("/root/test/a.ts"));
395 results.unused_files.push(unused_file("/root/test/b.ts"));
396
397 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
398
399 assert_eq!(groups.len(), 3);
400 assert_eq!(groups[0].key, "src");
401 assert_eq!(groups[0].results.total_issues(), 3);
402 assert_eq!(groups[1].key, "test");
403 assert_eq!(groups[1].results.total_issues(), 2);
404 assert_eq!(groups[2].key, "lib");
405 assert_eq!(groups[2].results.total_issues(), 1);
406 }
407
408 #[test]
409 fn sort_order_alphabetical_tiebreaker() {
410 let mut results = AnalysisResults::default();
411 results.unused_files.push(unused_file("/root/beta/a.ts"));
412 results.unused_files.push(unused_file("/root/alpha/a.ts"));
413
414 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
415
416 assert_eq!(groups.len(), 2);
417 assert_eq!(groups[0].key, "alpha");
419 assert_eq!(groups[1].key, "beta");
420 }
421
422 #[test]
425 fn unowned_sorts_last_regardless_of_count() {
426 let mut results = AnalysisResults::default();
427 results.unused_files.push(unused_file("/root/src/a.ts"));
429 results
431 .unlisted_dependencies
432 .push(unlisted_dep("pkg-a", vec![]));
433 results
434 .unlisted_dependencies
435 .push(unlisted_dep("pkg-b", vec![]));
436 results
437 .unlisted_dependencies
438 .push(unlisted_dep("pkg-c", vec![]));
439
440 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
441
442 assert_eq!(groups.len(), 2);
443 assert_eq!(groups[0].key, "src");
445 assert_eq!(groups[1].key, UNOWNED_LABEL);
446 assert_eq!(groups[1].results.total_issues(), 3);
447 }
448
449 #[test]
452 fn unlisted_dep_empty_imported_from_goes_to_unowned() {
453 let mut results = AnalysisResults::default();
454 results
455 .unlisted_dependencies
456 .push(unlisted_dep("missing-pkg", vec![]));
457
458 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
459
460 assert_eq!(groups.len(), 1);
461 assert_eq!(groups[0].key, UNOWNED_LABEL);
462 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
463 }
464
465 #[test]
466 fn unlisted_dep_with_import_site_goes_to_directory() {
467 let mut results = AnalysisResults::default();
468 results.unlisted_dependencies.push(unlisted_dep(
469 "lodash",
470 vec![import_site("/root/src/util.ts")],
471 ));
472
473 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
474
475 assert_eq!(groups.len(), 1);
476 assert_eq!(groups[0].key, "src");
477 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
478 }
479
480 #[test]
483 fn directory_mode_groups_by_first_path_component() {
484 let mut results = AnalysisResults::default();
485 results
486 .unused_files
487 .push(unused_file("/root/packages/ui/Button.ts"));
488 results
489 .unused_files
490 .push(unused_file("/root/packages/auth/login.ts"));
491 results
492 .unused_exports
493 .push(unused_export("/root/apps/web/index.ts", "main"));
494
495 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
496
497 assert_eq!(groups.len(), 2);
498
499 let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
500 let apps = groups.iter().find(|g| g.key == "apps").unwrap();
501
502 assert_eq!(pkgs.results.total_issues(), 2);
503 assert_eq!(apps.results.total_issues(), 1);
504 }
505
506 #[test]
509 fn owner_mode_groups_by_codeowners_owner() {
510 let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
511 let resolver = OwnershipResolver::Owner(co);
512
513 let mut results = AnalysisResults::default();
514 results.unused_files.push(unused_file("/root/src/app.ts"));
515 results.unused_files.push(unused_file("/root/README.md"));
516
517 let groups = group_analysis_results(&results, &root(), &resolver);
518
519 assert_eq!(groups.len(), 2);
520
521 let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
522 let default = groups.iter().find(|g| g.key == "@default").unwrap();
523
524 assert_eq!(frontend.results.unused_files.len(), 1);
525 assert_eq!(default.results.unused_files.len(), 1);
526 }
527
528 #[test]
529 fn owner_mode_unmatched_goes_to_unowned() {
530 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
532 let resolver = OwnershipResolver::Owner(co);
533
534 let mut results = AnalysisResults::default();
535 results.unused_files.push(unused_file("/root/README.md"));
536
537 let groups = group_analysis_results(&results, &root(), &resolver);
538
539 assert_eq!(groups.len(), 1);
540 assert_eq!(groups[0].key, UNOWNED_LABEL);
541 }
542
543 #[test]
546 fn boundary_violations_grouped_by_from_path() {
547 let mut results = AnalysisResults::default();
548 results.boundary_violations.push(BoundaryViolation {
549 from_path: PathBuf::from("/root/src/bad.ts"),
550 to_path: PathBuf::from("/root/lib/secret.ts"),
551 from_zone: "src".to_string(),
552 to_zone: "lib".to_string(),
553 import_specifier: "../lib/secret".to_string(),
554 line: 1,
555 col: 0,
556 });
557
558 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
559
560 assert_eq!(groups.len(), 1);
561 assert_eq!(groups[0].key, "src");
562 assert_eq!(groups[0].results.boundary_violations.len(), 1);
563 }
564
565 #[test]
568 fn circular_dep_empty_files_goes_to_unowned() {
569 let mut results = AnalysisResults::default();
570 results.circular_dependencies.push(CircularDependency {
571 files: vec![],
572 length: 0,
573 line: 0,
574 col: 0,
575 is_cross_package: false,
576 });
577
578 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
579
580 assert_eq!(groups.len(), 1);
581 assert_eq!(groups[0].key, UNOWNED_LABEL);
582 }
583
584 #[test]
585 fn circular_dep_uses_first_file() {
586 let mut results = AnalysisResults::default();
587 results.circular_dependencies.push(CircularDependency {
588 files: vec![
589 PathBuf::from("/root/src/a.ts"),
590 PathBuf::from("/root/lib/b.ts"),
591 ],
592 length: 2,
593 line: 1,
594 col: 0,
595 is_cross_package: false,
596 });
597
598 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
599
600 assert_eq!(groups.len(), 1);
601 assert_eq!(groups[0].key, "src");
602 }
603
604 #[test]
607 fn duplicate_exports_empty_locations_goes_to_unowned() {
608 let mut results = AnalysisResults::default();
609 results.duplicate_exports.push(DuplicateExport {
610 export_name: "dup".to_string(),
611 locations: vec![],
612 });
613
614 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
615
616 assert_eq!(groups.len(), 1);
617 assert_eq!(groups[0].key, UNOWNED_LABEL);
618 }
619
620 #[test]
623 fn resolve_owner_returns_directory() {
624 let owner = resolve_owner(
625 Path::new("/root/src/file.ts"),
626 &root(),
627 &OwnershipResolver::Directory,
628 );
629 assert_eq!(owner, "src");
630 }
631
632 #[test]
633 fn resolve_owner_returns_codeowner() {
634 let co = CodeOwners::parse("/src/ @team\n").unwrap();
635 let resolver = OwnershipResolver::Owner(co);
636 let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
637 assert_eq!(owner, "@team");
638 }
639
640 #[test]
643 fn mode_label_owner() {
644 let co = CodeOwners::parse("").unwrap();
645 let resolver = OwnershipResolver::Owner(co);
646 assert_eq!(resolver.mode_label(), "owner");
647 }
648
649 #[test]
650 fn mode_label_directory() {
651 assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
652 }
653
654 #[test]
655 fn mode_label_package() {
656 let pr = PackageResolver { workspaces: vec![] };
657 assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
658 }
659
660 #[test]
663 fn package_resolver_matches_longest_prefix() {
664 let ws = vec![
665 fallow_config::WorkspaceInfo {
666 name: "packages/ui".to_string(),
667 root: PathBuf::from("/root/packages/ui"),
668 is_internal_dependency: false,
669 },
670 fallow_config::WorkspaceInfo {
671 name: "packages".to_string(),
672 root: PathBuf::from("/root/packages"),
673 is_internal_dependency: false,
674 },
675 ];
676 let pr = PackageResolver::new(Path::new("/root"), &ws);
677 assert_eq!(
679 pr.resolve(Path::new("packages/ui/Button.ts")),
680 "packages/ui"
681 );
682 }
683
684 #[test]
685 fn package_resolver_root_fallback() {
686 let ws = vec![fallow_config::WorkspaceInfo {
687 name: "packages/ui".to_string(),
688 root: PathBuf::from("/root/packages/ui"),
689 is_internal_dependency: false,
690 }];
691 let pr = PackageResolver::new(Path::new("/root"), &ws);
692 assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
694 }
695
696 #[test]
697 fn package_mode_groups_by_workspace() {
698 let ws = vec![
699 fallow_config::WorkspaceInfo {
700 name: "ui".to_string(),
701 root: PathBuf::from("/root/packages/ui"),
702 is_internal_dependency: false,
703 },
704 fallow_config::WorkspaceInfo {
705 name: "auth".to_string(),
706 root: PathBuf::from("/root/packages/auth"),
707 is_internal_dependency: false,
708 },
709 ];
710 let pr = PackageResolver::new(Path::new("/root"), &ws);
711 let resolver = OwnershipResolver::Package(pr);
712
713 let mut results = AnalysisResults::default();
714 results
715 .unused_files
716 .push(unused_file("/root/packages/ui/Button.ts"));
717 results
718 .unused_files
719 .push(unused_file("/root/packages/auth/login.ts"));
720 results.unused_files.push(unused_file("/root/src/main.ts"));
721
722 let groups = group_analysis_results(&results, &root(), &resolver);
723 assert_eq!(groups.len(), 3);
724
725 let ui_group = groups.iter().find(|g| g.key == "ui");
726 let auth_group = groups.iter().find(|g| g.key == "auth");
727 let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
728
729 assert!(ui_group.is_some());
730 assert!(auth_group.is_some());
731 assert!(root_group.is_some());
732 }
733
734 #[test]
737 fn resolve_with_rule_directory_mode_no_rule() {
738 let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
739 assert_eq!(key, "src");
740 assert!(rule.is_none());
741 }
742
743 #[test]
744 fn resolve_with_rule_owner_mode_with_match() {
745 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
746 let resolver = OwnershipResolver::Owner(co);
747 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
748 assert_eq!(key, "@frontend");
749 assert!(rule.is_some());
750 assert!(rule.unwrap().contains("src"));
751 }
752
753 #[test]
754 fn resolve_with_rule_owner_mode_no_match() {
755 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
756 let resolver = OwnershipResolver::Owner(co);
757 let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
758 assert_eq!(key, UNOWNED_LABEL);
759 assert!(rule.is_none());
760 }
761
762 #[test]
763 fn resolve_with_rule_package_mode_no_rule() {
764 let pr = PackageResolver { workspaces: vec![] };
765 let resolver = OwnershipResolver::Package(pr);
766 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
767 assert_eq!(key, ROOT_PACKAGE_LABEL);
768 assert!(rule.is_none());
769 }
770
771 #[test]
774 fn group_unused_optional_deps() {
775 let mut results = AnalysisResults::default();
776 results.unused_optional_dependencies.push(UnusedDependency {
777 package_name: "fsevents".to_string(),
778 location: fallow_core::results::DependencyLocation::OptionalDependencies,
779 path: PathBuf::from("/root/package.json"),
780 line: 5,
781 });
782
783 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
784 assert_eq!(groups.len(), 1);
785 assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
786 }
787
788 #[test]
789 fn group_type_only_deps() {
790 let mut results = AnalysisResults::default();
791 results
792 .type_only_dependencies
793 .push(fallow_core::results::TypeOnlyDependency {
794 package_name: "zod".to_string(),
795 path: PathBuf::from("/root/package.json"),
796 line: 8,
797 });
798
799 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
800 assert_eq!(groups.len(), 1);
801 assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
802 }
803
804 #[test]
805 fn group_test_only_deps() {
806 let mut results = AnalysisResults::default();
807 results
808 .test_only_dependencies
809 .push(fallow_core::results::TestOnlyDependency {
810 package_name: "vitest".to_string(),
811 path: PathBuf::from("/root/package.json"),
812 line: 10,
813 });
814
815 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
816 assert_eq!(groups.len(), 1);
817 assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
818 }
819
820 #[test]
821 fn group_unused_enum_members() {
822 let mut results = AnalysisResults::default();
823 results
824 .unused_enum_members
825 .push(fallow_core::results::UnusedMember {
826 path: PathBuf::from("/root/src/types.ts"),
827 parent_name: "Status".to_string(),
828 member_name: "Deprecated".to_string(),
829 kind: fallow_core::extract::MemberKind::EnumMember,
830 line: 5,
831 col: 0,
832 });
833
834 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
835 assert_eq!(groups.len(), 1);
836 assert_eq!(groups[0].key, "src");
837 assert_eq!(groups[0].results.unused_enum_members.len(), 1);
838 }
839
840 #[test]
841 fn group_unused_class_members() {
842 let mut results = AnalysisResults::default();
843 results
844 .unused_class_members
845 .push(fallow_core::results::UnusedMember {
846 path: PathBuf::from("/root/lib/service.ts"),
847 parent_name: "UserService".to_string(),
848 member_name: "legacyMethod".to_string(),
849 kind: fallow_core::extract::MemberKind::ClassMethod,
850 line: 42,
851 col: 0,
852 });
853
854 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
855 assert_eq!(groups.len(), 1);
856 assert_eq!(groups[0].key, "lib");
857 assert_eq!(groups[0].results.unused_class_members.len(), 1);
858 }
859
860 #[test]
861 fn group_unresolved_imports() {
862 let mut results = AnalysisResults::default();
863 results
864 .unresolved_imports
865 .push(fallow_core::results::UnresolvedImport {
866 path: PathBuf::from("/root/src/app.ts"),
867 specifier: "./missing".to_string(),
868 line: 1,
869 col: 0,
870 specifier_col: 0,
871 });
872
873 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
874 assert_eq!(groups.len(), 1);
875 assert_eq!(groups[0].key, "src");
876 assert_eq!(groups[0].results.unresolved_imports.len(), 1);
877 }
878}