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