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