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