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.boundary_coverage_violations {
310 groups
311 .entry(key_for(&item.violation.path))
312 .or_default()
313 .boundary_coverage_violations
314 .push(item.clone());
315 }
316 for item in &results.boundary_call_violations {
317 groups
318 .entry(key_for(&item.violation.path))
319 .or_default()
320 .boundary_call_violations
321 .push(item.clone());
322 }
323 for item in &results.policy_violations {
324 groups
325 .entry(key_for(&item.violation.path))
326 .or_default()
327 .policy_violations
328 .push(item.clone());
329 }
330 for item in &results.stale_suppressions {
331 groups
332 .entry(key_for(&item.path))
333 .or_default()
334 .stale_suppressions
335 .push(item.clone());
336 }
337 group_workspace_config_issues(results, &mut groups, &mut key_for);
338
339 finalize_groups(groups, group_owners, is_section_mode)
340}
341
342fn group_workspace_config_issues(
343 results: &AnalysisResults,
344 groups: &mut FxHashMap<String, AnalysisResults>,
345 mut key_for: impl FnMut(&Path) -> String,
346) {
347 for item in &results.unused_catalog_entries {
348 groups
349 .entry(key_for(&item.entry.path))
350 .or_default()
351 .unused_catalog_entries
352 .push(item.clone());
353 }
354 for item in &results.empty_catalog_groups {
355 groups
356 .entry(key_for(&item.group.path))
357 .or_default()
358 .empty_catalog_groups
359 .push(item.clone());
360 }
361 for item in &results.unresolved_catalog_references {
362 groups
363 .entry(key_for(&item.reference.path))
364 .or_default()
365 .unresolved_catalog_references
366 .push(item.clone());
367 }
368 for item in &results.unused_dependency_overrides {
369 groups
370 .entry(key_for(&item.entry.path))
371 .or_default()
372 .unused_dependency_overrides
373 .push(item.clone());
374 }
375 for item in &results.misconfigured_dependency_overrides {
376 groups
377 .entry(key_for(&item.entry.path))
378 .or_default()
379 .misconfigured_dependency_overrides
380 .push(item.clone());
381 }
382}
383
384fn finalize_groups(
389 groups: FxHashMap<String, AnalysisResults>,
390 mut group_owners: FxHashMap<String, Vec<String>>,
391 is_section_mode: bool,
392) -> Vec<ResultGroup> {
393 let mut sorted: Vec<_> = groups
394 .into_iter()
395 .map(|(key, results)| {
396 let owners = if is_section_mode {
397 Some(group_owners.remove(&key).unwrap_or_default())
398 } else {
399 None
400 };
401 ResultGroup {
402 key,
403 owners,
404 results,
405 }
406 })
407 .collect();
408 sorted.sort_by(|a, b| {
409 let a_unowned = a.key == UNOWNED_LABEL;
410 let b_unowned = b.key == UNOWNED_LABEL;
411 match (a_unowned, b_unowned) {
412 (true, false) => std::cmp::Ordering::Greater,
413 (false, true) => std::cmp::Ordering::Less,
414 _ => b
415 .results
416 .total_issues()
417 .cmp(&a.results.total_issues())
418 .then_with(|| a.key.cmp(&b.key)),
419 }
420 });
421 sorted
422}
423
424pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
426 resolver.resolve(relative_path(path, root))
427}
428
429#[cfg(test)]
430mod tests {
431 use std::path::{Path, PathBuf};
432
433 use fallow_core::results::*;
434
435 use super::*;
436 use crate::codeowners::CodeOwners;
437
438 fn root() -> PathBuf {
439 PathBuf::from("/root")
440 }
441
442 fn unused_file(path: &str) -> UnusedFileFinding {
443 UnusedFileFinding::with_actions(UnusedFile {
444 path: PathBuf::from(path),
445 })
446 }
447
448 fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
449 UnusedExportFinding::with_actions(UnusedExport {
450 path: PathBuf::from(path),
451 export_name: name.to_string(),
452 is_type_only: false,
453 line: 1,
454 col: 0,
455 span_start: 0,
456 is_re_export: false,
457 })
458 }
459
460 fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependencyFinding {
461 UnlistedDependencyFinding::with_actions(UnlistedDependency {
462 package_name: name.to_string(),
463 imported_from: sites,
464 })
465 }
466
467 fn import_site(path: &str) -> ImportSite {
468 ImportSite {
469 path: PathBuf::from(path),
470 line: 1,
471 col: 0,
472 }
473 }
474
475 #[test]
476 fn empty_results_returns_empty_vec() {
477 let results = AnalysisResults::default();
478 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
479 assert!(groups.is_empty());
480 }
481
482 #[test]
483 fn single_group_all_same_directory() {
484 let mut results = AnalysisResults::default();
485 results.unused_files.push(unused_file("/root/src/a.ts"));
486 results.unused_files.push(unused_file("/root/src/b.ts"));
487 results
488 .unused_exports
489 .push(unused_export("/root/src/c.ts", "foo"));
490
491 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
492
493 assert_eq!(groups.len(), 1);
494 assert_eq!(groups[0].key, "src");
495 assert_eq!(groups[0].results.unused_files.len(), 2);
496 assert_eq!(groups[0].results.unused_exports.len(), 1);
497 assert_eq!(groups[0].results.total_issues(), 3);
498 }
499
500 #[test]
501 fn multiple_groups_split_by_directory() {
502 let mut results = AnalysisResults::default();
503 results.unused_files.push(unused_file("/root/src/a.ts"));
504 results.unused_files.push(unused_file("/root/lib/b.ts"));
505 results
506 .unused_exports
507 .push(unused_export("/root/src/c.ts", "bar"));
508
509 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
510
511 assert_eq!(groups.len(), 2);
512
513 let src_group = groups.iter().find(|g| g.key == "src").unwrap();
514 let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
515
516 assert_eq!(src_group.results.total_issues(), 2);
517 assert_eq!(lib_group.results.total_issues(), 1);
518 }
519
520 #[test]
521 fn sort_order_descending_by_total_issues() {
522 let mut results = AnalysisResults::default();
523 results.unused_files.push(unused_file("/root/lib/a.ts"));
524 results.unused_files.push(unused_file("/root/src/a.ts"));
525 results.unused_files.push(unused_file("/root/src/b.ts"));
526 results
527 .unused_exports
528 .push(unused_export("/root/src/c.ts", "x"));
529 results.unused_files.push(unused_file("/root/test/a.ts"));
530 results.unused_files.push(unused_file("/root/test/b.ts"));
531
532 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
533
534 assert_eq!(groups.len(), 3);
535 assert_eq!(groups[0].key, "src");
536 assert_eq!(groups[0].results.total_issues(), 3);
537 assert_eq!(groups[1].key, "test");
538 assert_eq!(groups[1].results.total_issues(), 2);
539 assert_eq!(groups[2].key, "lib");
540 assert_eq!(groups[2].results.total_issues(), 1);
541 }
542
543 #[test]
544 fn sort_order_alphabetical_tiebreaker() {
545 let mut results = AnalysisResults::default();
546 results.unused_files.push(unused_file("/root/beta/a.ts"));
547 results.unused_files.push(unused_file("/root/alpha/a.ts"));
548
549 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
550
551 assert_eq!(groups.len(), 2);
552 assert_eq!(groups[0].key, "alpha");
553 assert_eq!(groups[1].key, "beta");
554 }
555
556 #[test]
557 fn unowned_sorts_last_regardless_of_count() {
558 let mut results = AnalysisResults::default();
559 results.unused_files.push(unused_file("/root/src/a.ts"));
560 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");
574 assert_eq!(groups[1].key, UNOWNED_LABEL);
575 assert_eq!(groups[1].results.total_issues(), 3);
576 }
577
578 #[test]
579 fn unlisted_dep_empty_imported_from_goes_to_unowned() {
580 let mut results = AnalysisResults::default();
581 results
582 .unlisted_dependencies
583 .push(unlisted_dep("missing-pkg", vec![]));
584
585 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
586
587 assert_eq!(groups.len(), 1);
588 assert_eq!(groups[0].key, UNOWNED_LABEL);
589 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
590 }
591
592 #[test]
593 fn unlisted_dep_with_import_site_goes_to_directory() {
594 let mut results = AnalysisResults::default();
595 results.unlisted_dependencies.push(unlisted_dep(
596 "lodash",
597 vec![import_site("/root/src/util.ts")],
598 ));
599
600 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
601
602 assert_eq!(groups.len(), 1);
603 assert_eq!(groups[0].key, "src");
604 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
605 }
606
607 #[test]
608 fn directory_mode_groups_by_first_path_component() {
609 let mut results = AnalysisResults::default();
610 results
611 .unused_files
612 .push(unused_file("/root/packages/ui/Button.ts"));
613 results
614 .unused_files
615 .push(unused_file("/root/packages/auth/login.ts"));
616 results
617 .unused_exports
618 .push(unused_export("/root/apps/web/index.ts", "main"));
619
620 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
621
622 assert_eq!(groups.len(), 2);
623
624 let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
625 let apps = groups.iter().find(|g| g.key == "apps").unwrap();
626
627 assert_eq!(pkgs.results.total_issues(), 2);
628 assert_eq!(apps.results.total_issues(), 1);
629 }
630
631 #[test]
632 fn owner_mode_groups_by_codeowners_owner() {
633 let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
634 let resolver = OwnershipResolver::Owner(co);
635
636 let mut results = AnalysisResults::default();
637 results.unused_files.push(unused_file("/root/src/app.ts"));
638 results.unused_files.push(unused_file("/root/README.md"));
639
640 let groups = group_analysis_results(&results, &root(), &resolver);
641
642 assert_eq!(groups.len(), 2);
643
644 let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
645 let default = groups.iter().find(|g| g.key == "@default").unwrap();
646
647 assert_eq!(frontend.results.unused_files.len(), 1);
648 assert_eq!(default.results.unused_files.len(), 1);
649 }
650
651 #[test]
652 fn owner_mode_unmatched_goes_to_unowned() {
653 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
654 let resolver = OwnershipResolver::Owner(co);
655
656 let mut results = AnalysisResults::default();
657 results.unused_files.push(unused_file("/root/README.md"));
658
659 let groups = group_analysis_results(&results, &root(), &resolver);
660
661 assert_eq!(groups.len(), 1);
662 assert_eq!(groups[0].key, UNOWNED_LABEL);
663 }
664
665 #[test]
666 fn boundary_violations_grouped_by_from_path() {
667 let mut results = AnalysisResults::default();
668 results
669 .boundary_violations
670 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
671 from_path: PathBuf::from("/root/src/bad.ts"),
672 to_path: PathBuf::from("/root/lib/secret.ts"),
673 from_zone: "src".to_string(),
674 to_zone: "lib".to_string(),
675 import_specifier: "../lib/secret".to_string(),
676 line: 1,
677 col: 0,
678 }));
679
680 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
681
682 assert_eq!(groups.len(), 1);
683 assert_eq!(groups[0].key, "src");
684 assert_eq!(groups[0].results.boundary_violations.len(), 1);
685 }
686
687 #[test]
688 fn circular_dep_empty_files_goes_to_unowned() {
689 let mut results = AnalysisResults::default();
690 results
691 .circular_dependencies
692 .push(CircularDependencyFinding::with_actions(
693 CircularDependency {
694 files: vec![],
695 length: 0,
696 line: 0,
697 col: 0,
698 edges: Vec::new(),
699 is_cross_package: false,
700 },
701 ));
702
703 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
704
705 assert_eq!(groups.len(), 1);
706 assert_eq!(groups[0].key, UNOWNED_LABEL);
707 }
708
709 #[test]
710 fn circular_dep_uses_first_file() {
711 let mut results = AnalysisResults::default();
712 results
713 .circular_dependencies
714 .push(CircularDependencyFinding::with_actions(
715 CircularDependency {
716 files: vec![
717 PathBuf::from("/root/src/a.ts"),
718 PathBuf::from("/root/lib/b.ts"),
719 ],
720 length: 2,
721 line: 1,
722 col: 0,
723 edges: Vec::new(),
724 is_cross_package: false,
725 },
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]
735 fn duplicate_exports_empty_locations_goes_to_unowned() {
736 let mut results = AnalysisResults::default();
737 results
738 .duplicate_exports
739 .push(DuplicateExportFinding::with_actions(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]
751 fn resolve_owner_returns_directory() {
752 let owner = resolve_owner(
753 Path::new("/root/src/file.ts"),
754 &root(),
755 &OwnershipResolver::Directory,
756 );
757 assert_eq!(owner, "src");
758 }
759
760 #[test]
761 fn resolve_owner_returns_codeowner() {
762 let co = CodeOwners::parse("/src/ @team\n").unwrap();
763 let resolver = OwnershipResolver::Owner(co);
764 let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
765 assert_eq!(owner, "@team");
766 }
767
768 #[test]
769 fn mode_label_owner() {
770 let co = CodeOwners::parse("").unwrap();
771 let resolver = OwnershipResolver::Owner(co);
772 assert_eq!(resolver.mode_label(), "owner");
773 }
774
775 #[test]
776 fn mode_label_directory() {
777 assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
778 }
779
780 #[test]
781 fn mode_label_package() {
782 let pr = PackageResolver { workspaces: vec![] };
783 assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
784 }
785
786 #[test]
787 fn mode_label_section() {
788 let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
789 assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
790 }
791
792 #[test]
793 fn section_mode_groups_distinct_sections_with_shared_owners() {
794 let content = "\
795 [billing] @core-reviewers @alice @bob\n\
796 src/billing/\n\
797 [notifications] @core-reviewers @alice @bob\n\
798 src/notifications/\n\
799 ";
800 let co = CodeOwners::parse(content).unwrap();
801 let resolver = OwnershipResolver::Section(co);
802
803 let mut results = AnalysisResults::default();
804 results
805 .unused_files
806 .push(unused_file("/root/src/billing/a.ts"));
807 results
808 .unused_files
809 .push(unused_file("/root/src/billing/b.ts"));
810 results
811 .unused_files
812 .push(unused_file("/root/src/notifications/c.ts"));
813
814 let groups = group_analysis_results(&results, &root(), &resolver);
815
816 assert_eq!(groups.len(), 2);
817 let billing = groups.iter().find(|g| g.key == "billing").unwrap();
818 let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
819 assert_eq!(billing.results.total_issues(), 2);
820 assert_eq!(notifications.results.total_issues(), 1);
821 assert_eq!(
822 billing.owners.as_deref(),
823 Some(
824 [
825 "@core-reviewers".to_string(),
826 "@alice".to_string(),
827 "@bob".to_string()
828 ]
829 .as_slice()
830 )
831 );
832 assert_eq!(
833 notifications.owners.as_deref(),
834 Some(
835 [
836 "@core-reviewers".to_string(),
837 "@alice".to_string(),
838 "@bob".to_string()
839 ]
840 .as_slice()
841 )
842 );
843 }
844
845 #[test]
846 fn section_mode_pre_section_rule_goes_to_no_section() {
847 let content = "\
848 * @default\n\
849 [Utilities] @utils\n\
850 src/utils/\n\
851 ";
852 let co = CodeOwners::parse(content).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 results
858 .unused_files
859 .push(unused_file("/root/src/utils/greet.ts"));
860
861 let groups = group_analysis_results(&results, &root(), &resolver);
862
863 assert_eq!(groups.len(), 2);
864 let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
865 let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
866 assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
867 assert_eq!(
868 utils.owners.as_deref(),
869 Some(["@utils".to_string()].as_slice())
870 );
871 }
872
873 #[test]
874 fn section_mode_unmatched_goes_to_unowned() {
875 let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
876 let resolver = OwnershipResolver::Section(co);
877
878 let mut results = AnalysisResults::default();
879 results.unused_files.push(unused_file("/root/README.md"));
880
881 let groups = group_analysis_results(&results, &root(), &resolver);
882
883 assert_eq!(groups.len(), 1);
884 assert_eq!(groups[0].key, UNOWNED_LABEL);
885 assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
886 }
887
888 #[test]
889 fn directory_mode_groups_have_no_owners_metadata() {
890 let mut results = AnalysisResults::default();
891 results.unused_files.push(unused_file("/root/src/a.ts"));
892 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
893 assert_eq!(groups[0].owners, None);
894 }
895
896 #[test]
897 fn package_resolver_matches_longest_prefix() {
898 let ws = vec![
899 fallow_config::WorkspaceInfo {
900 name: "packages/ui".to_string(),
901 root: PathBuf::from("/root/packages/ui"),
902 is_internal_dependency: false,
903 },
904 fallow_config::WorkspaceInfo {
905 name: "packages".to_string(),
906 root: PathBuf::from("/root/packages"),
907 is_internal_dependency: false,
908 },
909 ];
910 let pr = PackageResolver::new(Path::new("/root"), &ws);
911 assert_eq!(
912 pr.resolve(Path::new("packages/ui/Button.ts")),
913 "packages/ui"
914 );
915 }
916
917 #[test]
918 fn package_resolver_root_fallback() {
919 let ws = vec![fallow_config::WorkspaceInfo {
920 name: "packages/ui".to_string(),
921 root: PathBuf::from("/root/packages/ui"),
922 is_internal_dependency: false,
923 }];
924 let pr = PackageResolver::new(Path::new("/root"), &ws);
925 assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
926 }
927
928 #[test]
929 fn package_mode_groups_by_workspace() {
930 let ws = vec![
931 fallow_config::WorkspaceInfo {
932 name: "ui".to_string(),
933 root: PathBuf::from("/root/packages/ui"),
934 is_internal_dependency: false,
935 },
936 fallow_config::WorkspaceInfo {
937 name: "auth".to_string(),
938 root: PathBuf::from("/root/packages/auth"),
939 is_internal_dependency: false,
940 },
941 ];
942 let pr = PackageResolver::new(Path::new("/root"), &ws);
943 let resolver = OwnershipResolver::Package(pr);
944
945 let mut results = AnalysisResults::default();
946 results
947 .unused_files
948 .push(unused_file("/root/packages/ui/Button.ts"));
949 results
950 .unused_files
951 .push(unused_file("/root/packages/auth/login.ts"));
952 results.unused_files.push(unused_file("/root/src/main.ts"));
953
954 let groups = group_analysis_results(&results, &root(), &resolver);
955 assert_eq!(groups.len(), 3);
956
957 let ui_group = groups.iter().find(|g| g.key == "ui");
958 let auth_group = groups.iter().find(|g| g.key == "auth");
959 let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
960
961 assert!(ui_group.is_some());
962 assert!(auth_group.is_some());
963 assert!(root_group.is_some());
964 }
965
966 #[test]
967 fn resolve_with_rule_directory_mode_no_rule() {
968 let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
969 assert_eq!(key, "src");
970 assert!(rule.is_none());
971 }
972
973 #[test]
974 fn resolve_with_rule_owner_mode_with_match() {
975 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
976 let resolver = OwnershipResolver::Owner(co);
977 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
978 assert_eq!(key, "@frontend");
979 assert!(rule.is_some());
980 assert!(rule.unwrap().contains("src"));
981 }
982
983 #[test]
984 fn resolve_with_rule_owner_mode_no_match() {
985 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
986 let resolver = OwnershipResolver::Owner(co);
987 let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
988 assert_eq!(key, UNOWNED_LABEL);
989 assert!(rule.is_none());
990 }
991
992 #[test]
993 fn resolve_with_rule_package_mode_no_rule() {
994 let pr = PackageResolver { workspaces: vec![] };
995 let resolver = OwnershipResolver::Package(pr);
996 let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
997 assert_eq!(key, ROOT_PACKAGE_LABEL);
998 assert!(rule.is_none());
999 }
1000
1001 #[test]
1002 fn group_unused_optional_deps() {
1003 let mut results = AnalysisResults::default();
1004 results
1005 .unused_optional_dependencies
1006 .push(UnusedOptionalDependencyFinding::with_actions(
1007 UnusedDependency {
1008 package_name: "fsevents".to_string(),
1009 location: fallow_core::results::DependencyLocation::OptionalDependencies,
1010 path: PathBuf::from("/root/package.json"),
1011 line: 5,
1012 used_in_workspaces: Vec::new(),
1013 },
1014 ));
1015
1016 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1017 assert_eq!(groups.len(), 1);
1018 assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
1019 }
1020
1021 #[test]
1022 fn group_type_only_deps() {
1023 let mut results = AnalysisResults::default();
1024 results.type_only_dependencies.push(
1025 fallow_core::results::TypeOnlyDependencyFinding::with_actions(TypeOnlyDependency {
1026 package_name: "zod".to_string(),
1027 path: PathBuf::from("/root/package.json"),
1028 line: 8,
1029 }),
1030 );
1031
1032 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1033 assert_eq!(groups.len(), 1);
1034 assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
1035 }
1036
1037 #[test]
1038 fn group_test_only_deps() {
1039 let mut results = AnalysisResults::default();
1040 results.test_only_dependencies.push(
1041 fallow_core::results::TestOnlyDependencyFinding::with_actions(TestOnlyDependency {
1042 package_name: "vitest".to_string(),
1043 path: PathBuf::from("/root/package.json"),
1044 line: 10,
1045 }),
1046 );
1047
1048 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1049 assert_eq!(groups.len(), 1);
1050 assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1051 }
1052
1053 #[test]
1054 fn group_unused_enum_members() {
1055 let mut results = AnalysisResults::default();
1056 results.unused_enum_members.push(
1057 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
1058 path: PathBuf::from("/root/src/types.ts"),
1059 parent_name: "Status".to_string(),
1060 member_name: "Deprecated".to_string(),
1061 kind: fallow_core::extract::MemberKind::EnumMember,
1062 line: 5,
1063 col: 0,
1064 }),
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.unused_enum_members.len(), 1);
1071 }
1072
1073 #[test]
1074 fn group_unused_class_members() {
1075 let mut results = AnalysisResults::default();
1076 results.unused_class_members.push(
1077 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
1078 path: PathBuf::from("/root/lib/service.ts"),
1079 parent_name: "UserService".to_string(),
1080 member_name: "legacyMethod".to_string(),
1081 kind: fallow_core::extract::MemberKind::ClassMethod,
1082 line: 42,
1083 col: 0,
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, "lib");
1090 assert_eq!(groups[0].results.unused_class_members.len(), 1);
1091 }
1092
1093 #[test]
1094 fn group_unresolved_imports() {
1095 let mut results = AnalysisResults::default();
1096 results.unresolved_imports.push(
1097 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
1098 fallow_core::results::UnresolvedImport {
1099 path: PathBuf::from("/root/src/app.ts"),
1100 specifier: "./missing".to_string(),
1101 line: 1,
1102 col: 0,
1103 specifier_col: 0,
1104 },
1105 ),
1106 );
1107
1108 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1109 assert_eq!(groups.len(), 1);
1110 assert_eq!(groups[0].key, "src");
1111 assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1112 }
1113}