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 edges: Vec::new(),
678 is_cross_package: false,
679 },
680 ));
681
682 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
683
684 assert_eq!(groups.len(), 1);
685 assert_eq!(groups[0].key, UNOWNED_LABEL);
686 }
687
688 #[test]
689 fn circular_dep_uses_first_file() {
690 let mut results = AnalysisResults::default();
691 results
692 .circular_dependencies
693 .push(CircularDependencyFinding::with_actions(
694 CircularDependency {
695 files: vec![
696 PathBuf::from("/root/src/a.ts"),
697 PathBuf::from("/root/lib/b.ts"),
698 ],
699 length: 2,
700 line: 1,
701 col: 0,
702 edges: Vec::new(),
703 is_cross_package: false,
704 },
705 ));
706
707 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
708
709 assert_eq!(groups.len(), 1);
710 assert_eq!(groups[0].key, "src");
711 }
712
713 #[test]
714 fn duplicate_exports_empty_locations_goes_to_unowned() {
715 let mut results = AnalysisResults::default();
716 results
717 .duplicate_exports
718 .push(DuplicateExportFinding::with_actions(DuplicateExport {
719 export_name: "dup".to_string(),
720 locations: vec![],
721 }));
722
723 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
724
725 assert_eq!(groups.len(), 1);
726 assert_eq!(groups[0].key, UNOWNED_LABEL);
727 }
728
729 #[test]
730 fn resolve_owner_returns_directory() {
731 let owner = resolve_owner(
732 Path::new("/root/src/file.ts"),
733 &root(),
734 &OwnershipResolver::Directory,
735 );
736 assert_eq!(owner, "src");
737 }
738
739 #[test]
740 fn resolve_owner_returns_codeowner() {
741 let co = CodeOwners::parse("/src/ @team\n").unwrap();
742 let resolver = OwnershipResolver::Owner(co);
743 let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
744 assert_eq!(owner, "@team");
745 }
746
747 #[test]
748 fn mode_label_owner() {
749 let co = CodeOwners::parse("").unwrap();
750 let resolver = OwnershipResolver::Owner(co);
751 assert_eq!(resolver.mode_label(), "owner");
752 }
753
754 #[test]
755 fn mode_label_directory() {
756 assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
757 }
758
759 #[test]
760 fn mode_label_package() {
761 let pr = PackageResolver { workspaces: vec![] };
762 assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
763 }
764
765 #[test]
766 fn mode_label_section() {
767 let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
768 assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
769 }
770
771 #[test]
772 fn section_mode_groups_distinct_sections_with_shared_owners() {
773 let content = "\
774 [billing] @core-reviewers @alice @bob\n\
775 src/billing/\n\
776 [notifications] @core-reviewers @alice @bob\n\
777 src/notifications/\n\
778 ";
779 let co = CodeOwners::parse(content).unwrap();
780 let resolver = OwnershipResolver::Section(co);
781
782 let mut results = AnalysisResults::default();
783 results
784 .unused_files
785 .push(unused_file("/root/src/billing/a.ts"));
786 results
787 .unused_files
788 .push(unused_file("/root/src/billing/b.ts"));
789 results
790 .unused_files
791 .push(unused_file("/root/src/notifications/c.ts"));
792
793 let groups = group_analysis_results(&results, &root(), &resolver);
794
795 assert_eq!(groups.len(), 2);
796 let billing = groups.iter().find(|g| g.key == "billing").unwrap();
797 let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
798 assert_eq!(billing.results.total_issues(), 2);
799 assert_eq!(notifications.results.total_issues(), 1);
800 assert_eq!(
801 billing.owners.as_deref(),
802 Some(
803 [
804 "@core-reviewers".to_string(),
805 "@alice".to_string(),
806 "@bob".to_string()
807 ]
808 .as_slice()
809 )
810 );
811 assert_eq!(
812 notifications.owners.as_deref(),
813 Some(
814 [
815 "@core-reviewers".to_string(),
816 "@alice".to_string(),
817 "@bob".to_string()
818 ]
819 .as_slice()
820 )
821 );
822 }
823
824 #[test]
825 fn section_mode_pre_section_rule_goes_to_no_section() {
826 let content = "\
827 * @default\n\
828 [Utilities] @utils\n\
829 src/utils/\n\
830 ";
831 let co = CodeOwners::parse(content).unwrap();
832 let resolver = OwnershipResolver::Section(co);
833
834 let mut results = AnalysisResults::default();
835 results.unused_files.push(unused_file("/root/README.md"));
836 results
837 .unused_files
838 .push(unused_file("/root/src/utils/greet.ts"));
839
840 let groups = group_analysis_results(&results, &root(), &resolver);
841
842 assert_eq!(groups.len(), 2);
843 let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
844 let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
845 assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
846 assert_eq!(
847 utils.owners.as_deref(),
848 Some(["@utils".to_string()].as_slice())
849 );
850 }
851
852 #[test]
853 fn section_mode_unmatched_goes_to_unowned() {
854 let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
855 let resolver = OwnershipResolver::Section(co);
856
857 let mut results = AnalysisResults::default();
858 results.unused_files.push(unused_file("/root/README.md"));
859
860 let groups = group_analysis_results(&results, &root(), &resolver);
861
862 assert_eq!(groups.len(), 1);
863 assert_eq!(groups[0].key, UNOWNED_LABEL);
864 assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
865 }
866
867 #[test]
868 fn directory_mode_groups_have_no_owners_metadata() {
869 let mut results = AnalysisResults::default();
870 results.unused_files.push(unused_file("/root/src/a.ts"));
871 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
872 assert_eq!(groups[0].owners, None);
873 }
874
875 #[test]
876 fn package_resolver_matches_longest_prefix() {
877 let ws = vec![
878 fallow_config::WorkspaceInfo {
879 name: "packages/ui".to_string(),
880 root: PathBuf::from("/root/packages/ui"),
881 is_internal_dependency: false,
882 },
883 fallow_config::WorkspaceInfo {
884 name: "packages".to_string(),
885 root: PathBuf::from("/root/packages"),
886 is_internal_dependency: false,
887 },
888 ];
889 let pr = PackageResolver::new(Path::new("/root"), &ws);
890 assert_eq!(
891 pr.resolve(Path::new("packages/ui/Button.ts")),
892 "packages/ui"
893 );
894 }
895
896 #[test]
897 fn package_resolver_root_fallback() {
898 let ws = vec![fallow_config::WorkspaceInfo {
899 name: "packages/ui".to_string(),
900 root: PathBuf::from("/root/packages/ui"),
901 is_internal_dependency: false,
902 }];
903 let pr = PackageResolver::new(Path::new("/root"), &ws);
904 assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
905 }
906
907 #[test]
908 fn package_mode_groups_by_workspace() {
909 let ws = vec![
910 fallow_config::WorkspaceInfo {
911 name: "ui".to_string(),
912 root: PathBuf::from("/root/packages/ui"),
913 is_internal_dependency: false,
914 },
915 fallow_config::WorkspaceInfo {
916 name: "auth".to_string(),
917 root: PathBuf::from("/root/packages/auth"),
918 is_internal_dependency: false,
919 },
920 ];
921 let pr = PackageResolver::new(Path::new("/root"), &ws);
922 let resolver = OwnershipResolver::Package(pr);
923
924 let mut results = AnalysisResults::default();
925 results
926 .unused_files
927 .push(unused_file("/root/packages/ui/Button.ts"));
928 results
929 .unused_files
930 .push(unused_file("/root/packages/auth/login.ts"));
931 results.unused_files.push(unused_file("/root/src/main.ts"));
932
933 let groups = group_analysis_results(&results, &root(), &resolver);
934 assert_eq!(groups.len(), 3);
935
936 let ui_group = groups.iter().find(|g| g.key == "ui");
937 let auth_group = groups.iter().find(|g| g.key == "auth");
938 let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
939
940 assert!(ui_group.is_some());
941 assert!(auth_group.is_some());
942 assert!(root_group.is_some());
943 }
944
945 #[test]
946 fn resolve_with_rule_directory_mode_no_rule() {
947 let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
948 assert_eq!(key, "src");
949 assert!(rule.is_none());
950 }
951
952 #[test]
953 fn resolve_with_rule_owner_mode_with_match() {
954 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
955 let resolver = OwnershipResolver::Owner(co);
956 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
957 assert_eq!(key, "@frontend");
958 assert!(rule.is_some());
959 assert!(rule.unwrap().contains("src"));
960 }
961
962 #[test]
963 fn resolve_with_rule_owner_mode_no_match() {
964 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
965 let resolver = OwnershipResolver::Owner(co);
966 let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
967 assert_eq!(key, UNOWNED_LABEL);
968 assert!(rule.is_none());
969 }
970
971 #[test]
972 fn resolve_with_rule_package_mode_no_rule() {
973 let pr = PackageResolver { workspaces: vec![] };
974 let resolver = OwnershipResolver::Package(pr);
975 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
976 assert_eq!(key, ROOT_PACKAGE_LABEL);
977 assert!(rule.is_none());
978 }
979
980 #[test]
981 fn group_unused_optional_deps() {
982 let mut results = AnalysisResults::default();
983 results
984 .unused_optional_dependencies
985 .push(UnusedOptionalDependencyFinding::with_actions(
986 UnusedDependency {
987 package_name: "fsevents".to_string(),
988 location: fallow_core::results::DependencyLocation::OptionalDependencies,
989 path: PathBuf::from("/root/package.json"),
990 line: 5,
991 used_in_workspaces: Vec::new(),
992 },
993 ));
994
995 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
996 assert_eq!(groups.len(), 1);
997 assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
998 }
999
1000 #[test]
1001 fn group_type_only_deps() {
1002 let mut results = AnalysisResults::default();
1003 results.type_only_dependencies.push(
1004 fallow_core::results::TypeOnlyDependencyFinding::with_actions(TypeOnlyDependency {
1005 package_name: "zod".to_string(),
1006 path: PathBuf::from("/root/package.json"),
1007 line: 8,
1008 }),
1009 );
1010
1011 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1012 assert_eq!(groups.len(), 1);
1013 assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
1014 }
1015
1016 #[test]
1017 fn group_test_only_deps() {
1018 let mut results = AnalysisResults::default();
1019 results.test_only_dependencies.push(
1020 fallow_core::results::TestOnlyDependencyFinding::with_actions(TestOnlyDependency {
1021 package_name: "vitest".to_string(),
1022 path: PathBuf::from("/root/package.json"),
1023 line: 10,
1024 }),
1025 );
1026
1027 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1028 assert_eq!(groups.len(), 1);
1029 assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1030 }
1031
1032 #[test]
1033 fn group_unused_enum_members() {
1034 let mut results = AnalysisResults::default();
1035 results.unused_enum_members.push(
1036 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
1037 path: PathBuf::from("/root/src/types.ts"),
1038 parent_name: "Status".to_string(),
1039 member_name: "Deprecated".to_string(),
1040 kind: fallow_core::extract::MemberKind::EnumMember,
1041 line: 5,
1042 col: 0,
1043 }),
1044 );
1045
1046 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1047 assert_eq!(groups.len(), 1);
1048 assert_eq!(groups[0].key, "src");
1049 assert_eq!(groups[0].results.unused_enum_members.len(), 1);
1050 }
1051
1052 #[test]
1053 fn group_unused_class_members() {
1054 let mut results = AnalysisResults::default();
1055 results.unused_class_members.push(
1056 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
1057 path: PathBuf::from("/root/lib/service.ts"),
1058 parent_name: "UserService".to_string(),
1059 member_name: "legacyMethod".to_string(),
1060 kind: fallow_core::extract::MemberKind::ClassMethod,
1061 line: 42,
1062 col: 0,
1063 }),
1064 );
1065
1066 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1067 assert_eq!(groups.len(), 1);
1068 assert_eq!(groups[0].key, "lib");
1069 assert_eq!(groups[0].results.unused_class_members.len(), 1);
1070 }
1071
1072 #[test]
1073 fn group_unresolved_imports() {
1074 let mut results = AnalysisResults::default();
1075 results.unresolved_imports.push(
1076 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
1077 fallow_core::results::UnresolvedImport {
1078 path: PathBuf::from("/root/src/app.ts"),
1079 specifier: "./missing".to_string(),
1080 line: 1,
1081 col: 0,
1082 specifier_col: 0,
1083 },
1084 ),
1085 );
1086
1087 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1088 assert_eq!(groups.len(), 1);
1089 assert_eq!(groups[0].key, "src");
1090 assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1091 }
1092}