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