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, NO_SECTION_LABEL, UNOWNED_LABEL};
14
15pub enum OwnershipResolver {
20 Owner(CodeOwners),
22 Directory,
24 Package(PackageResolver),
26 Section(CodeOwners),
32}
33
34pub struct PackageResolver {
39 workspaces: Vec<(PathBuf, String)>,
41}
42
43const ROOT_PACKAGE_LABEL: &str = "(root)";
44
45impl PackageResolver {
46 pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
51 let mut ws: Vec<(PathBuf, String)> = workspaces
52 .iter()
53 .map(|w| {
54 let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
55 (rel.to_path_buf(), w.name.clone())
56 })
57 .collect();
58 ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
59 Self { workspaces: ws }
60 }
61
62 fn resolve(&self, rel_path: &Path) -> &str {
64 self.workspaces
65 .iter()
66 .find(|(root, _)| rel_path.starts_with(root))
67 .map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
68 }
69}
70
71impl OwnershipResolver {
72 pub fn resolve(&self, rel_path: &Path) -> String {
74 match self {
75 Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
76 Self::Directory => codeowners::directory_group(rel_path).to_string(),
77 Self::Package(pr) => pr.resolve(rel_path).to_string(),
78 Self::Section(co) => match co.section_of(rel_path) {
79 Some(Some(name)) => name.to_string(),
80 Some(None) => NO_SECTION_LABEL.to_string(),
81 None => UNOWNED_LABEL.to_string(),
82 },
83 }
84 }
85
86 pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
93 match self {
94 Self::Owner(co) => {
95 if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
96 (owner.to_string(), Some(rule.to_string()))
97 } else {
98 (UNOWNED_LABEL.to_string(), None)
99 }
100 }
101 Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
102 Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
103 Self::Section(co) => {
104 if let Some((section, _owners, rule)) = co.section_owners_and_rule_of(rel_path) {
105 let key = section.map_or_else(|| NO_SECTION_LABEL.to_string(), str::to_string);
106 (key, Some(rule.to_string()))
107 } else {
108 (UNOWNED_LABEL.to_string(), None)
109 }
110 }
111 }
112 }
113
114 pub fn mode_label(&self) -> &'static str {
116 match self {
117 Self::Owner(_) => "owner",
118 Self::Directory => "directory",
119 Self::Package(_) => "package",
120 Self::Section(_) => "section",
121 }
122 }
123
124 pub fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]> {
130 if let Self::Section(co) = self
131 && let Some((_, owners)) = co.section_and_owners_of(rel_path)
132 {
133 Some(owners)
134 } else {
135 None
136 }
137 }
138}
139
140pub struct ResultGroup {
142 pub key: String,
144 pub owners: Option<Vec<String>>,
149 pub results: AnalysisResults,
151}
152
153#[expect(
159 clippy::too_many_lines,
160 reason = "one per-issue-type loop body; each loop is 4-7 lines and tightly correlated; splitting into helpers per type would scatter the per-path-key derivation logic that this fn exists to consolidate. Workspace-config issue types already factored into `group_workspace_config_issues`."
161)]
162pub fn group_analysis_results(
163 results: &AnalysisResults,
164 root: &Path,
165 resolver: &OwnershipResolver,
166) -> Vec<ResultGroup> {
167 let mut groups: FxHashMap<String, AnalysisResults> = FxHashMap::default();
168 let mut group_owners: FxHashMap<String, Vec<String>> = FxHashMap::default();
172 let is_section_mode = matches!(resolver, OwnershipResolver::Section(_));
173
174 let mut key_for = |path: &Path| -> String {
175 let rel = relative_path(path, root);
176 let key = resolver.resolve(rel);
177 if is_section_mode && !group_owners.contains_key(&key) {
178 let owners = resolver
179 .section_owners_of(rel)
180 .map(<[String]>::to_vec)
181 .unwrap_or_default();
182 group_owners.insert(key.clone(), owners);
183 }
184 key
185 };
186
187 for item in &results.unused_files {
189 groups
190 .entry(key_for(&item.file.path))
191 .or_default()
192 .unused_files
193 .push(item.clone());
194 }
195 for item in &results.unused_exports {
196 groups
197 .entry(key_for(&item.export.path))
198 .or_default()
199 .unused_exports
200 .push(item.clone());
201 }
202 for item in &results.unused_types {
203 groups
204 .entry(key_for(&item.export.path))
205 .or_default()
206 .unused_types
207 .push(item.clone());
208 }
209 for item in &results.private_type_leaks {
210 groups
211 .entry(key_for(&item.leak.path))
212 .or_default()
213 .private_type_leaks
214 .push(item.clone());
215 }
216 for item in &results.unused_enum_members {
217 groups
218 .entry(key_for(&item.member.path))
219 .or_default()
220 .unused_enum_members
221 .push(item.clone());
222 }
223 for item in &results.unused_class_members {
224 groups
225 .entry(key_for(&item.member.path))
226 .or_default()
227 .unused_class_members
228 .push(item.clone());
229 }
230 for item in &results.unresolved_imports {
231 groups
232 .entry(key_for(&item.import.path))
233 .or_default()
234 .unresolved_imports
235 .push(item.clone());
236 }
237
238 for item in &results.unused_dependencies {
240 groups
241 .entry(key_for(&item.dep.path))
242 .or_default()
243 .unused_dependencies
244 .push(item.clone());
245 }
246 for item in &results.unused_dev_dependencies {
247 groups
248 .entry(key_for(&item.dep.path))
249 .or_default()
250 .unused_dev_dependencies
251 .push(item.clone());
252 }
253 for item in &results.unused_optional_dependencies {
254 groups
255 .entry(key_for(&item.dep.path))
256 .or_default()
257 .unused_optional_dependencies
258 .push(item.clone());
259 }
260 for item in &results.type_only_dependencies {
261 groups
262 .entry(key_for(&item.dep.path))
263 .or_default()
264 .type_only_dependencies
265 .push(item.clone());
266 }
267 for item in &results.test_only_dependencies {
268 groups
269 .entry(key_for(&item.dep.path))
270 .or_default()
271 .test_only_dependencies
272 .push(item.clone());
273 }
274
275 for item in &results.unlisted_dependencies {
277 let key = item
278 .dep
279 .imported_from
280 .first()
281 .map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
282 groups
283 .entry(key)
284 .or_default()
285 .unlisted_dependencies
286 .push(item.clone());
287 }
288 for item in &results.duplicate_exports {
289 let key = item
290 .export
291 .locations
292 .first()
293 .map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
294 groups
295 .entry(key)
296 .or_default()
297 .duplicate_exports
298 .push(item.clone());
299 }
300 for item in &results.circular_dependencies {
301 let key = item
302 .cycle
303 .files
304 .first()
305 .map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
306 groups
307 .entry(key)
308 .or_default()
309 .circular_dependencies
310 .push(item.clone());
311 }
312 for item in &results.boundary_violations {
313 groups
314 .entry(key_for(&item.violation.from_path))
315 .or_default()
316 .boundary_violations
317 .push(item.clone());
318 }
319 for item in &results.stale_suppressions {
320 groups
321 .entry(key_for(&item.path))
322 .or_default()
323 .stale_suppressions
324 .push(item.clone());
325 }
326 group_workspace_config_issues(results, &mut groups, &mut key_for);
327
328 finalize_groups(groups, group_owners, is_section_mode)
329}
330
331fn group_workspace_config_issues(
332 results: &AnalysisResults,
333 groups: &mut FxHashMap<String, AnalysisResults>,
334 mut key_for: impl FnMut(&Path) -> String,
335) {
336 for item in &results.unused_catalog_entries {
337 groups
338 .entry(key_for(&item.entry.path))
339 .or_default()
340 .unused_catalog_entries
341 .push(item.clone());
342 }
343 for item in &results.empty_catalog_groups {
344 groups
345 .entry(key_for(&item.group.path))
346 .or_default()
347 .empty_catalog_groups
348 .push(item.clone());
349 }
350 for item in &results.unresolved_catalog_references {
351 groups
352 .entry(key_for(&item.reference.path))
353 .or_default()
354 .unresolved_catalog_references
355 .push(item.clone());
356 }
357 for item in &results.unused_dependency_overrides {
358 groups
359 .entry(key_for(&item.entry.path))
360 .or_default()
361 .unused_dependency_overrides
362 .push(item.clone());
363 }
364 for item in &results.misconfigured_dependency_overrides {
365 groups
366 .entry(key_for(&item.entry.path))
367 .or_default()
368 .misconfigured_dependency_overrides
369 .push(item.clone());
370 }
371}
372
373fn finalize_groups(
378 groups: FxHashMap<String, AnalysisResults>,
379 mut group_owners: FxHashMap<String, Vec<String>>,
380 is_section_mode: bool,
381) -> Vec<ResultGroup> {
382 let mut sorted: Vec<_> = groups
383 .into_iter()
384 .map(|(key, results)| {
385 let owners = if is_section_mode {
386 Some(group_owners.remove(&key).unwrap_or_default())
387 } else {
388 None
389 };
390 ResultGroup {
391 key,
392 owners,
393 results,
394 }
395 })
396 .collect();
397 sorted.sort_by(|a, b| {
398 let a_unowned = a.key == UNOWNED_LABEL;
399 let b_unowned = b.key == UNOWNED_LABEL;
400 match (a_unowned, b_unowned) {
401 (true, false) => std::cmp::Ordering::Greater,
402 (false, true) => std::cmp::Ordering::Less,
403 _ => b
404 .results
405 .total_issues()
406 .cmp(&a.results.total_issues())
407 .then_with(|| a.key.cmp(&b.key)),
408 }
409 });
410 sorted
411}
412
413pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
415 resolver.resolve(relative_path(path, root))
416}
417
418#[cfg(test)]
419mod tests {
420 use std::path::{Path, PathBuf};
421
422 use fallow_core::results::*;
423
424 use super::*;
425 use crate::codeowners::CodeOwners;
426
427 fn root() -> PathBuf {
430 PathBuf::from("/root")
431 }
432
433 fn unused_file(path: &str) -> UnusedFileFinding {
434 UnusedFileFinding::with_actions(UnusedFile {
435 path: PathBuf::from(path),
436 })
437 }
438
439 fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
440 UnusedExportFinding::with_actions(UnusedExport {
441 path: PathBuf::from(path),
442 export_name: name.to_string(),
443 is_type_only: false,
444 line: 1,
445 col: 0,
446 span_start: 0,
447 is_re_export: false,
448 })
449 }
450
451 fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependencyFinding {
452 UnlistedDependencyFinding::with_actions(UnlistedDependency {
453 package_name: name.to_string(),
454 imported_from: sites,
455 })
456 }
457
458 fn import_site(path: &str) -> ImportSite {
459 ImportSite {
460 path: PathBuf::from(path),
461 line: 1,
462 col: 0,
463 }
464 }
465
466 #[test]
469 fn empty_results_returns_empty_vec() {
470 let results = AnalysisResults::default();
471 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
472 assert!(groups.is_empty());
473 }
474
475 #[test]
478 fn single_group_all_same_directory() {
479 let mut results = AnalysisResults::default();
480 results.unused_files.push(unused_file("/root/src/a.ts"));
481 results.unused_files.push(unused_file("/root/src/b.ts"));
482 results
483 .unused_exports
484 .push(unused_export("/root/src/c.ts", "foo"));
485
486 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
487
488 assert_eq!(groups.len(), 1);
489 assert_eq!(groups[0].key, "src");
490 assert_eq!(groups[0].results.unused_files.len(), 2);
491 assert_eq!(groups[0].results.unused_exports.len(), 1);
492 assert_eq!(groups[0].results.total_issues(), 3);
493 }
494
495 #[test]
498 fn multiple_groups_split_by_directory() {
499 let mut results = AnalysisResults::default();
500 results.unused_files.push(unused_file("/root/src/a.ts"));
501 results.unused_files.push(unused_file("/root/lib/b.ts"));
502 results
503 .unused_exports
504 .push(unused_export("/root/src/c.ts", "bar"));
505
506 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
507
508 assert_eq!(groups.len(), 2);
509
510 let src_group = groups.iter().find(|g| g.key == "src").unwrap();
511 let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
512
513 assert_eq!(src_group.results.total_issues(), 2);
514 assert_eq!(lib_group.results.total_issues(), 1);
515 }
516
517 #[test]
520 fn sort_order_descending_by_total_issues() {
521 let mut results = AnalysisResults::default();
522 results.unused_files.push(unused_file("/root/lib/a.ts"));
524 results.unused_files.push(unused_file("/root/src/a.ts"));
526 results.unused_files.push(unused_file("/root/src/b.ts"));
527 results
528 .unused_exports
529 .push(unused_export("/root/src/c.ts", "x"));
530 results.unused_files.push(unused_file("/root/test/a.ts"));
532 results.unused_files.push(unused_file("/root/test/b.ts"));
533
534 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
535
536 assert_eq!(groups.len(), 3);
537 assert_eq!(groups[0].key, "src");
538 assert_eq!(groups[0].results.total_issues(), 3);
539 assert_eq!(groups[1].key, "test");
540 assert_eq!(groups[1].results.total_issues(), 2);
541 assert_eq!(groups[2].key, "lib");
542 assert_eq!(groups[2].results.total_issues(), 1);
543 }
544
545 #[test]
546 fn sort_order_alphabetical_tiebreaker() {
547 let mut results = AnalysisResults::default();
548 results.unused_files.push(unused_file("/root/beta/a.ts"));
549 results.unused_files.push(unused_file("/root/alpha/a.ts"));
550
551 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
552
553 assert_eq!(groups.len(), 2);
554 assert_eq!(groups[0].key, "alpha");
556 assert_eq!(groups[1].key, "beta");
557 }
558
559 #[test]
562 fn unowned_sorts_last_regardless_of_count() {
563 let mut results = AnalysisResults::default();
564 results.unused_files.push(unused_file("/root/src/a.ts"));
566 results
568 .unlisted_dependencies
569 .push(unlisted_dep("pkg-a", vec![]));
570 results
571 .unlisted_dependencies
572 .push(unlisted_dep("pkg-b", vec![]));
573 results
574 .unlisted_dependencies
575 .push(unlisted_dep("pkg-c", vec![]));
576
577 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
578
579 assert_eq!(groups.len(), 2);
580 assert_eq!(groups[0].key, "src");
582 assert_eq!(groups[1].key, UNOWNED_LABEL);
583 assert_eq!(groups[1].results.total_issues(), 3);
584 }
585
586 #[test]
589 fn unlisted_dep_empty_imported_from_goes_to_unowned() {
590 let mut results = AnalysisResults::default();
591 results
592 .unlisted_dependencies
593 .push(unlisted_dep("missing-pkg", vec![]));
594
595 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
596
597 assert_eq!(groups.len(), 1);
598 assert_eq!(groups[0].key, UNOWNED_LABEL);
599 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
600 }
601
602 #[test]
603 fn unlisted_dep_with_import_site_goes_to_directory() {
604 let mut results = AnalysisResults::default();
605 results.unlisted_dependencies.push(unlisted_dep(
606 "lodash",
607 vec![import_site("/root/src/util.ts")],
608 ));
609
610 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
611
612 assert_eq!(groups.len(), 1);
613 assert_eq!(groups[0].key, "src");
614 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
615 }
616
617 #[test]
620 fn directory_mode_groups_by_first_path_component() {
621 let mut results = AnalysisResults::default();
622 results
623 .unused_files
624 .push(unused_file("/root/packages/ui/Button.ts"));
625 results
626 .unused_files
627 .push(unused_file("/root/packages/auth/login.ts"));
628 results
629 .unused_exports
630 .push(unused_export("/root/apps/web/index.ts", "main"));
631
632 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
633
634 assert_eq!(groups.len(), 2);
635
636 let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
637 let apps = groups.iter().find(|g| g.key == "apps").unwrap();
638
639 assert_eq!(pkgs.results.total_issues(), 2);
640 assert_eq!(apps.results.total_issues(), 1);
641 }
642
643 #[test]
646 fn owner_mode_groups_by_codeowners_owner() {
647 let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
648 let resolver = OwnershipResolver::Owner(co);
649
650 let mut results = AnalysisResults::default();
651 results.unused_files.push(unused_file("/root/src/app.ts"));
652 results.unused_files.push(unused_file("/root/README.md"));
653
654 let groups = group_analysis_results(&results, &root(), &resolver);
655
656 assert_eq!(groups.len(), 2);
657
658 let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
659 let default = groups.iter().find(|g| g.key == "@default").unwrap();
660
661 assert_eq!(frontend.results.unused_files.len(), 1);
662 assert_eq!(default.results.unused_files.len(), 1);
663 }
664
665 #[test]
666 fn owner_mode_unmatched_goes_to_unowned() {
667 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
669 let resolver = OwnershipResolver::Owner(co);
670
671 let mut results = AnalysisResults::default();
672 results.unused_files.push(unused_file("/root/README.md"));
673
674 let groups = group_analysis_results(&results, &root(), &resolver);
675
676 assert_eq!(groups.len(), 1);
677 assert_eq!(groups[0].key, UNOWNED_LABEL);
678 }
679
680 #[test]
683 fn boundary_violations_grouped_by_from_path() {
684 let mut results = AnalysisResults::default();
685 results
686 .boundary_violations
687 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
688 from_path: PathBuf::from("/root/src/bad.ts"),
689 to_path: PathBuf::from("/root/lib/secret.ts"),
690 from_zone: "src".to_string(),
691 to_zone: "lib".to_string(),
692 import_specifier: "../lib/secret".to_string(),
693 line: 1,
694 col: 0,
695 }));
696
697 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
698
699 assert_eq!(groups.len(), 1);
700 assert_eq!(groups[0].key, "src");
701 assert_eq!(groups[0].results.boundary_violations.len(), 1);
702 }
703
704 #[test]
707 fn circular_dep_empty_files_goes_to_unowned() {
708 let mut results = AnalysisResults::default();
709 results
710 .circular_dependencies
711 .push(CircularDependencyFinding::with_actions(
712 CircularDependency {
713 files: vec![],
714 length: 0,
715 line: 0,
716 col: 0,
717 is_cross_package: false,
718 },
719 ));
720
721 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
722
723 assert_eq!(groups.len(), 1);
724 assert_eq!(groups[0].key, UNOWNED_LABEL);
725 }
726
727 #[test]
728 fn circular_dep_uses_first_file() {
729 let mut results = AnalysisResults::default();
730 results
731 .circular_dependencies
732 .push(CircularDependencyFinding::with_actions(
733 CircularDependency {
734 files: vec![
735 PathBuf::from("/root/src/a.ts"),
736 PathBuf::from("/root/lib/b.ts"),
737 ],
738 length: 2,
739 line: 1,
740 col: 0,
741 is_cross_package: false,
742 },
743 ));
744
745 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
746
747 assert_eq!(groups.len(), 1);
748 assert_eq!(groups[0].key, "src");
749 }
750
751 #[test]
754 fn duplicate_exports_empty_locations_goes_to_unowned() {
755 let mut results = AnalysisResults::default();
756 results
757 .duplicate_exports
758 .push(DuplicateExportFinding::with_actions(DuplicateExport {
759 export_name: "dup".to_string(),
760 locations: vec![],
761 }));
762
763 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
764
765 assert_eq!(groups.len(), 1);
766 assert_eq!(groups[0].key, UNOWNED_LABEL);
767 }
768
769 #[test]
772 fn resolve_owner_returns_directory() {
773 let owner = resolve_owner(
774 Path::new("/root/src/file.ts"),
775 &root(),
776 &OwnershipResolver::Directory,
777 );
778 assert_eq!(owner, "src");
779 }
780
781 #[test]
782 fn resolve_owner_returns_codeowner() {
783 let co = CodeOwners::parse("/src/ @team\n").unwrap();
784 let resolver = OwnershipResolver::Owner(co);
785 let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
786 assert_eq!(owner, "@team");
787 }
788
789 #[test]
792 fn mode_label_owner() {
793 let co = CodeOwners::parse("").unwrap();
794 let resolver = OwnershipResolver::Owner(co);
795 assert_eq!(resolver.mode_label(), "owner");
796 }
797
798 #[test]
799 fn mode_label_directory() {
800 assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
801 }
802
803 #[test]
804 fn mode_label_package() {
805 let pr = PackageResolver { workspaces: vec![] };
806 assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
807 }
808
809 #[test]
810 fn mode_label_section() {
811 let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
812 assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
813 }
814
815 #[test]
818 fn section_mode_groups_distinct_sections_with_shared_owners() {
819 let content = "\
822 [billing] @core-reviewers @alice @bob\n\
823 src/billing/\n\
824 [notifications] @core-reviewers @alice @bob\n\
825 src/notifications/\n\
826 ";
827 let co = CodeOwners::parse(content).unwrap();
828 let resolver = OwnershipResolver::Section(co);
829
830 let mut results = AnalysisResults::default();
831 results
832 .unused_files
833 .push(unused_file("/root/src/billing/a.ts"));
834 results
835 .unused_files
836 .push(unused_file("/root/src/billing/b.ts"));
837 results
838 .unused_files
839 .push(unused_file("/root/src/notifications/c.ts"));
840
841 let groups = group_analysis_results(&results, &root(), &resolver);
842
843 assert_eq!(groups.len(), 2);
844 let billing = groups.iter().find(|g| g.key == "billing").unwrap();
845 let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
846 assert_eq!(billing.results.total_issues(), 2);
847 assert_eq!(notifications.results.total_issues(), 1);
848 assert_eq!(
849 billing.owners.as_deref(),
850 Some(
851 [
852 "@core-reviewers".to_string(),
853 "@alice".to_string(),
854 "@bob".to_string()
855 ]
856 .as_slice()
857 )
858 );
859 assert_eq!(
860 notifications.owners.as_deref(),
861 Some(
862 [
863 "@core-reviewers".to_string(),
864 "@alice".to_string(),
865 "@bob".to_string()
866 ]
867 .as_slice()
868 )
869 );
870 }
871
872 #[test]
873 fn section_mode_pre_section_rule_goes_to_no_section() {
874 let content = "\
875 * @default\n\
876 [Utilities] @utils\n\
877 src/utils/\n\
878 ";
879 let co = CodeOwners::parse(content).unwrap();
880 let resolver = OwnershipResolver::Section(co);
881
882 let mut results = AnalysisResults::default();
883 results.unused_files.push(unused_file("/root/README.md"));
884 results
885 .unused_files
886 .push(unused_file("/root/src/utils/greet.ts"));
887
888 let groups = group_analysis_results(&results, &root(), &resolver);
889
890 assert_eq!(groups.len(), 2);
891 let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
892 let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
893 assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
894 assert_eq!(
895 utils.owners.as_deref(),
896 Some(["@utils".to_string()].as_slice())
897 );
898 }
899
900 #[test]
901 fn section_mode_unmatched_goes_to_unowned() {
902 let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
903 let resolver = OwnershipResolver::Section(co);
904
905 let mut results = AnalysisResults::default();
906 results.unused_files.push(unused_file("/root/README.md"));
907
908 let groups = group_analysis_results(&results, &root(), &resolver);
909
910 assert_eq!(groups.len(), 1);
911 assert_eq!(groups[0].key, UNOWNED_LABEL);
912 assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
913 }
914
915 #[test]
916 fn directory_mode_groups_have_no_owners_metadata() {
917 let mut results = AnalysisResults::default();
918 results.unused_files.push(unused_file("/root/src/a.ts"));
919 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
920 assert_eq!(groups[0].owners, None);
921 }
922
923 #[test]
926 fn package_resolver_matches_longest_prefix() {
927 let ws = vec![
928 fallow_config::WorkspaceInfo {
929 name: "packages/ui".to_string(),
930 root: PathBuf::from("/root/packages/ui"),
931 is_internal_dependency: false,
932 },
933 fallow_config::WorkspaceInfo {
934 name: "packages".to_string(),
935 root: PathBuf::from("/root/packages"),
936 is_internal_dependency: false,
937 },
938 ];
939 let pr = PackageResolver::new(Path::new("/root"), &ws);
940 assert_eq!(
942 pr.resolve(Path::new("packages/ui/Button.ts")),
943 "packages/ui"
944 );
945 }
946
947 #[test]
948 fn package_resolver_root_fallback() {
949 let ws = vec![fallow_config::WorkspaceInfo {
950 name: "packages/ui".to_string(),
951 root: PathBuf::from("/root/packages/ui"),
952 is_internal_dependency: false,
953 }];
954 let pr = PackageResolver::new(Path::new("/root"), &ws);
955 assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
957 }
958
959 #[test]
960 fn package_mode_groups_by_workspace() {
961 let ws = vec![
962 fallow_config::WorkspaceInfo {
963 name: "ui".to_string(),
964 root: PathBuf::from("/root/packages/ui"),
965 is_internal_dependency: false,
966 },
967 fallow_config::WorkspaceInfo {
968 name: "auth".to_string(),
969 root: PathBuf::from("/root/packages/auth"),
970 is_internal_dependency: false,
971 },
972 ];
973 let pr = PackageResolver::new(Path::new("/root"), &ws);
974 let resolver = OwnershipResolver::Package(pr);
975
976 let mut results = AnalysisResults::default();
977 results
978 .unused_files
979 .push(unused_file("/root/packages/ui/Button.ts"));
980 results
981 .unused_files
982 .push(unused_file("/root/packages/auth/login.ts"));
983 results.unused_files.push(unused_file("/root/src/main.ts"));
984
985 let groups = group_analysis_results(&results, &root(), &resolver);
986 assert_eq!(groups.len(), 3);
987
988 let ui_group = groups.iter().find(|g| g.key == "ui");
989 let auth_group = groups.iter().find(|g| g.key == "auth");
990 let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
991
992 assert!(ui_group.is_some());
993 assert!(auth_group.is_some());
994 assert!(root_group.is_some());
995 }
996
997 #[test]
1000 fn resolve_with_rule_directory_mode_no_rule() {
1001 let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
1002 assert_eq!(key, "src");
1003 assert!(rule.is_none());
1004 }
1005
1006 #[test]
1007 fn resolve_with_rule_owner_mode_with_match() {
1008 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
1009 let resolver = OwnershipResolver::Owner(co);
1010 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
1011 assert_eq!(key, "@frontend");
1012 assert!(rule.is_some());
1013 assert!(rule.unwrap().contains("src"));
1014 }
1015
1016 #[test]
1017 fn resolve_with_rule_owner_mode_no_match() {
1018 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
1019 let resolver = OwnershipResolver::Owner(co);
1020 let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
1021 assert_eq!(key, UNOWNED_LABEL);
1022 assert!(rule.is_none());
1023 }
1024
1025 #[test]
1026 fn resolve_with_rule_package_mode_no_rule() {
1027 let pr = PackageResolver { workspaces: vec![] };
1028 let resolver = OwnershipResolver::Package(pr);
1029 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
1030 assert_eq!(key, ROOT_PACKAGE_LABEL);
1031 assert!(rule.is_none());
1032 }
1033
1034 #[test]
1037 fn group_unused_optional_deps() {
1038 let mut results = AnalysisResults::default();
1039 results
1040 .unused_optional_dependencies
1041 .push(UnusedOptionalDependencyFinding::with_actions(
1042 UnusedDependency {
1043 package_name: "fsevents".to_string(),
1044 location: fallow_core::results::DependencyLocation::OptionalDependencies,
1045 path: PathBuf::from("/root/package.json"),
1046 line: 5,
1047 used_in_workspaces: Vec::new(),
1048 },
1049 ));
1050
1051 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1052 assert_eq!(groups.len(), 1);
1053 assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
1054 }
1055
1056 #[test]
1057 fn group_type_only_deps() {
1058 let mut results = AnalysisResults::default();
1059 results.type_only_dependencies.push(
1060 fallow_core::results::TypeOnlyDependencyFinding::with_actions(TypeOnlyDependency {
1061 package_name: "zod".to_string(),
1062 path: PathBuf::from("/root/package.json"),
1063 line: 8,
1064 }),
1065 );
1066
1067 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1068 assert_eq!(groups.len(), 1);
1069 assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
1070 }
1071
1072 #[test]
1073 fn group_test_only_deps() {
1074 let mut results = AnalysisResults::default();
1075 results.test_only_dependencies.push(
1076 fallow_core::results::TestOnlyDependencyFinding::with_actions(TestOnlyDependency {
1077 package_name: "vitest".to_string(),
1078 path: PathBuf::from("/root/package.json"),
1079 line: 10,
1080 }),
1081 );
1082
1083 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1084 assert_eq!(groups.len(), 1);
1085 assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1086 }
1087
1088 #[test]
1089 fn group_unused_enum_members() {
1090 let mut results = AnalysisResults::default();
1091 results.unused_enum_members.push(
1092 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
1093 path: PathBuf::from("/root/src/types.ts"),
1094 parent_name: "Status".to_string(),
1095 member_name: "Deprecated".to_string(),
1096 kind: fallow_core::extract::MemberKind::EnumMember,
1097 line: 5,
1098 col: 0,
1099 }),
1100 );
1101
1102 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1103 assert_eq!(groups.len(), 1);
1104 assert_eq!(groups[0].key, "src");
1105 assert_eq!(groups[0].results.unused_enum_members.len(), 1);
1106 }
1107
1108 #[test]
1109 fn group_unused_class_members() {
1110 let mut results = AnalysisResults::default();
1111 results.unused_class_members.push(
1112 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
1113 path: PathBuf::from("/root/lib/service.ts"),
1114 parent_name: "UserService".to_string(),
1115 member_name: "legacyMethod".to_string(),
1116 kind: fallow_core::extract::MemberKind::ClassMethod,
1117 line: 42,
1118 col: 0,
1119 }),
1120 );
1121
1122 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1123 assert_eq!(groups.len(), 1);
1124 assert_eq!(groups[0].key, "lib");
1125 assert_eq!(groups[0].results.unused_class_members.len(), 1);
1126 }
1127
1128 #[test]
1129 fn group_unresolved_imports() {
1130 let mut results = AnalysisResults::default();
1131 results.unresolved_imports.push(
1132 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
1133 fallow_core::results::UnresolvedImport {
1134 path: PathBuf::from("/root/src/app.ts"),
1135 specifier: "./missing".to_string(),
1136 line: 1,
1137 col: 0,
1138 specifier_col: 0,
1139 },
1140 ),
1141 );
1142
1143 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1144 assert_eq!(groups.len(), 1);
1145 assert_eq!(groups[0].key, "src");
1146 assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1147 }
1148}