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