Skip to main content

fallow_cli/report/
grouping.rs

1//! Grouping infrastructure for `--group-by owner|directory|package`.
2//!
3//! Partitions `AnalysisResults` into labeled groups by ownership (CODEOWNERS),
4//! by first directory component, or by workspace package.
5
6use 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
15/// Ownership resolver for `--group-by`.
16///
17/// Owns the `CodeOwners` data when grouping by owner, avoiding lifetime
18/// complexity in the report context.
19pub enum OwnershipResolver {
20    /// Group by CODEOWNERS file (first owner, last matching rule).
21    Owner(CodeOwners),
22    /// Group by first directory component.
23    Directory,
24    /// Group by workspace package (monorepo).
25    Package(PackageResolver),
26    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
27    Section(CodeOwners),
28}
29
30/// Resolves file paths to workspace package names via longest-prefix matching.
31///
32/// Stores workspace roots as paths relative to the project root so that
33/// resolution works with the relative paths passed to `OwnershipResolver::resolve`.
34pub struct PackageResolver {
35    /// `(relative_root, package_name)` sorted by path length descending.
36    workspaces: Vec<(PathBuf, String)>,
37}
38
39const ROOT_PACKAGE_LABEL: &str = "(root)";
40
41impl PackageResolver {
42    /// Build a resolver from discovered workspace info.
43    ///
44    /// Workspace roots are stored relative to `project_root` and sorted by path
45    /// length descending so the first match is always the most specific prefix.
46    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    /// Find the workspace package that owns `rel_path`, or `"(root)"` if none match.
59    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    /// Resolve the group key for a file path (relative to project root).
69    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    /// Resolve the group key and matching rule for a path.
83    ///
84    /// Returns `(owner, Some(pattern))` for Owner mode,
85    /// `(directory, None)` for Directory/Package mode,
86    /// `(section, Some(pattern))` for Section mode (pattern is the raw
87    /// CODEOWNERS pattern from the last matching rule).
88    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    /// Label for the grouping mode (used in JSON `grouped_by` field).
111    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    /// Look up the section default owners for a group key.
121    ///
122    /// Returns `Some(&[...])` only in Section mode when `rel_path` resolves
123    /// to a rule inside a named section. Used to emit the `owners` metadata
124    /// array in grouped JSON output.
125    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
136/// A single group: a label and its subset of results.
137pub struct ResultGroup {
138    /// Group label (owner name, directory, package, or section).
139    pub key: String,
140    /// Section default owners for `--group-by section`.
141    ///
142    /// `None` for all other grouping modes. `Some(vec![])` for the
143    /// `(no section)` and `(unowned)` buckets in Section mode.
144    pub owners: Option<Vec<String>>,
145    /// Issues belonging to this group.
146    pub results: AnalysisResults,
147}
148
149/// Partition analysis results into groups by ownership or directory.
150///
151/// Each issue is assigned to a group by extracting its primary file path
152/// and resolving the group key via the `OwnershipResolver`.
153/// Returns groups sorted alphabetically by key, with `(unowned)` last.
154pub 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_server_actions {
368            self.entry_for_path(&item.action.path)
369                .unused_server_actions
370                .push(item.clone());
371        }
372        for item in &results.unused_load_data_keys {
373            self.entry_for_path(&item.key.path)
374                .unused_load_data_keys
375                .push(item.clone());
376        }
377        for item in &results.stale_suppressions {
378            self.entry_for_path(&item.path)
379                .stale_suppressions
380                .push(item.clone());
381        }
382    }
383
384    fn group_workspace_config_issues(&mut self, results: &AnalysisResults) {
385        for item in &results.unused_catalog_entries {
386            self.entry_for_path(&item.entry.path)
387                .unused_catalog_entries
388                .push(item.clone());
389        }
390        for item in &results.empty_catalog_groups {
391            self.entry_for_path(&item.group.path)
392                .empty_catalog_groups
393                .push(item.clone());
394        }
395        for item in &results.unresolved_catalog_references {
396            self.entry_for_path(&item.reference.path)
397                .unresolved_catalog_references
398                .push(item.clone());
399        }
400        for item in &results.unused_dependency_overrides {
401            self.entry_for_path(&item.entry.path)
402                .unused_dependency_overrides
403                .push(item.clone());
404        }
405        for item in &results.misconfigured_dependency_overrides {
406            self.entry_for_path(&item.entry.path)
407                .misconfigured_dependency_overrides
408                .push(item.clone());
409        }
410    }
411}
412
413/// Merge per-key results and owners into sorted `ResultGroup`s.
414///
415/// Ordering: most issues first, alphabetical tiebreaker, `(unowned)` pinned to
416/// the end. `group_owners` is consumed only when `is_section_mode` is true.
417fn finalize_groups(
418    groups: FxHashMap<String, AnalysisResults>,
419    mut group_owners: FxHashMap<String, Vec<String>>,
420    is_section_mode: bool,
421) -> Vec<ResultGroup> {
422    let mut sorted: Vec<_> = groups
423        .into_iter()
424        .map(|(key, results)| {
425            let owners = if is_section_mode {
426                Some(group_owners.remove(&key).unwrap_or_default())
427            } else {
428                None
429            };
430            ResultGroup {
431                key,
432                owners,
433                results,
434            }
435        })
436        .collect();
437    sorted.sort_by(|a, b| {
438        let a_unowned = a.key == UNOWNED_LABEL;
439        let b_unowned = b.key == UNOWNED_LABEL;
440        match (a_unowned, b_unowned) {
441            (true, false) => std::cmp::Ordering::Greater,
442            (false, true) => std::cmp::Ordering::Less,
443            _ => b
444                .results
445                .total_issues()
446                .cmp(&a.results.total_issues())
447                .then_with(|| a.key.cmp(&b.key)),
448        }
449    });
450    sorted
451}
452
453/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
454pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
455    resolver.resolve(relative_path(path, root))
456}
457
458#[cfg(test)]
459mod tests {
460    use std::path::{Path, PathBuf};
461
462    use fallow_core::results::*;
463
464    use super::*;
465    use crate::codeowners::CodeOwners;
466
467    fn root() -> PathBuf {
468        PathBuf::from("/root")
469    }
470
471    fn unused_file(path: &str) -> UnusedFileFinding {
472        UnusedFileFinding::with_actions(UnusedFile {
473            path: PathBuf::from(path),
474        })
475    }
476
477    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
478        UnusedExportFinding::with_actions(UnusedExport {
479            path: PathBuf::from(path),
480            export_name: name.to_string(),
481            is_type_only: false,
482            line: 1,
483            col: 0,
484            span_start: 0,
485            is_re_export: false,
486        })
487    }
488
489    fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependencyFinding {
490        UnlistedDependencyFinding::with_actions(UnlistedDependency {
491            package_name: name.to_string(),
492            imported_from: sites,
493        })
494    }
495
496    fn import_site(path: &str) -> ImportSite {
497        ImportSite {
498            path: PathBuf::from(path),
499            line: 1,
500            col: 0,
501        }
502    }
503
504    #[test]
505    fn empty_results_returns_empty_vec() {
506        let results = AnalysisResults::default();
507        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
508        assert!(groups.is_empty());
509    }
510
511    #[test]
512    fn single_group_all_same_directory() {
513        let mut results = AnalysisResults::default();
514        results.unused_files.push(unused_file("/root/src/a.ts"));
515        results.unused_files.push(unused_file("/root/src/b.ts"));
516        results
517            .unused_exports
518            .push(unused_export("/root/src/c.ts", "foo"));
519
520        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
521
522        assert_eq!(groups.len(), 1);
523        assert_eq!(groups[0].key, "src");
524        assert_eq!(groups[0].results.unused_files.len(), 2);
525        assert_eq!(groups[0].results.unused_exports.len(), 1);
526        assert_eq!(groups[0].results.total_issues(), 3);
527    }
528
529    #[test]
530    fn multiple_groups_split_by_directory() {
531        let mut results = AnalysisResults::default();
532        results.unused_files.push(unused_file("/root/src/a.ts"));
533        results.unused_files.push(unused_file("/root/lib/b.ts"));
534        results
535            .unused_exports
536            .push(unused_export("/root/src/c.ts", "bar"));
537
538        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
539
540        assert_eq!(groups.len(), 2);
541
542        let src_group = groups.iter().find(|g| g.key == "src").unwrap();
543        let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
544
545        assert_eq!(src_group.results.total_issues(), 2);
546        assert_eq!(lib_group.results.total_issues(), 1);
547    }
548
549    #[test]
550    fn sort_order_descending_by_total_issues() {
551        let mut results = AnalysisResults::default();
552        results.unused_files.push(unused_file("/root/lib/a.ts"));
553        results.unused_files.push(unused_file("/root/src/a.ts"));
554        results.unused_files.push(unused_file("/root/src/b.ts"));
555        results
556            .unused_exports
557            .push(unused_export("/root/src/c.ts", "x"));
558        results.unused_files.push(unused_file("/root/test/a.ts"));
559        results.unused_files.push(unused_file("/root/test/b.ts"));
560
561        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
562
563        assert_eq!(groups.len(), 3);
564        assert_eq!(groups[0].key, "src");
565        assert_eq!(groups[0].results.total_issues(), 3);
566        assert_eq!(groups[1].key, "test");
567        assert_eq!(groups[1].results.total_issues(), 2);
568        assert_eq!(groups[2].key, "lib");
569        assert_eq!(groups[2].results.total_issues(), 1);
570    }
571
572    #[test]
573    fn sort_order_alphabetical_tiebreaker() {
574        let mut results = AnalysisResults::default();
575        results.unused_files.push(unused_file("/root/beta/a.ts"));
576        results.unused_files.push(unused_file("/root/alpha/a.ts"));
577
578        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
579
580        assert_eq!(groups.len(), 2);
581        assert_eq!(groups[0].key, "alpha");
582        assert_eq!(groups[1].key, "beta");
583    }
584
585    #[test]
586    fn unowned_sorts_last_regardless_of_count() {
587        let mut results = AnalysisResults::default();
588        results.unused_files.push(unused_file("/root/src/a.ts"));
589        results
590            .unlisted_dependencies
591            .push(unlisted_dep("pkg-a", vec![]));
592        results
593            .unlisted_dependencies
594            .push(unlisted_dep("pkg-b", vec![]));
595        results
596            .unlisted_dependencies
597            .push(unlisted_dep("pkg-c", vec![]));
598
599        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
600
601        assert_eq!(groups.len(), 2);
602        assert_eq!(groups[0].key, "src");
603        assert_eq!(groups[1].key, UNOWNED_LABEL);
604        assert_eq!(groups[1].results.total_issues(), 3);
605    }
606
607    #[test]
608    fn unlisted_dep_empty_imported_from_goes_to_unowned() {
609        let mut results = AnalysisResults::default();
610        results
611            .unlisted_dependencies
612            .push(unlisted_dep("missing-pkg", vec![]));
613
614        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
615
616        assert_eq!(groups.len(), 1);
617        assert_eq!(groups[0].key, UNOWNED_LABEL);
618        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
619    }
620
621    #[test]
622    fn unlisted_dep_with_import_site_goes_to_directory() {
623        let mut results = AnalysisResults::default();
624        results.unlisted_dependencies.push(unlisted_dep(
625            "lodash",
626            vec![import_site("/root/src/util.ts")],
627        ));
628
629        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
630
631        assert_eq!(groups.len(), 1);
632        assert_eq!(groups[0].key, "src");
633        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
634    }
635
636    #[test]
637    fn directory_mode_groups_by_first_path_component() {
638        let mut results = AnalysisResults::default();
639        results
640            .unused_files
641            .push(unused_file("/root/packages/ui/Button.ts"));
642        results
643            .unused_files
644            .push(unused_file("/root/packages/auth/login.ts"));
645        results
646            .unused_exports
647            .push(unused_export("/root/apps/web/index.ts", "main"));
648
649        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
650
651        assert_eq!(groups.len(), 2);
652
653        let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
654        let apps = groups.iter().find(|g| g.key == "apps").unwrap();
655
656        assert_eq!(pkgs.results.total_issues(), 2);
657        assert_eq!(apps.results.total_issues(), 1);
658    }
659
660    #[test]
661    fn owner_mode_groups_by_codeowners_owner() {
662        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
663        let resolver = OwnershipResolver::Owner(co);
664
665        let mut results = AnalysisResults::default();
666        results.unused_files.push(unused_file("/root/src/app.ts"));
667        results.unused_files.push(unused_file("/root/README.md"));
668
669        let groups = group_analysis_results(&results, &root(), &resolver);
670
671        assert_eq!(groups.len(), 2);
672
673        let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
674        let default = groups.iter().find(|g| g.key == "@default").unwrap();
675
676        assert_eq!(frontend.results.unused_files.len(), 1);
677        assert_eq!(default.results.unused_files.len(), 1);
678    }
679
680    #[test]
681    fn owner_mode_unmatched_goes_to_unowned() {
682        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
683        let resolver = OwnershipResolver::Owner(co);
684
685        let mut results = AnalysisResults::default();
686        results.unused_files.push(unused_file("/root/README.md"));
687
688        let groups = group_analysis_results(&results, &root(), &resolver);
689
690        assert_eq!(groups.len(), 1);
691        assert_eq!(groups[0].key, UNOWNED_LABEL);
692    }
693
694    #[test]
695    fn boundary_violations_grouped_by_from_path() {
696        let mut results = AnalysisResults::default();
697        results
698            .boundary_violations
699            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
700                from_path: PathBuf::from("/root/src/bad.ts"),
701                to_path: PathBuf::from("/root/lib/secret.ts"),
702                from_zone: "src".to_string(),
703                to_zone: "lib".to_string(),
704                import_specifier: "../lib/secret".to_string(),
705                line: 1,
706                col: 0,
707            }));
708
709        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
710
711        assert_eq!(groups.len(), 1);
712        assert_eq!(groups[0].key, "src");
713        assert_eq!(groups[0].results.boundary_violations.len(), 1);
714    }
715
716    #[test]
717    fn circular_dep_empty_files_goes_to_unowned() {
718        let mut results = AnalysisResults::default();
719        results
720            .circular_dependencies
721            .push(CircularDependencyFinding::with_actions(
722                CircularDependency {
723                    files: vec![],
724                    length: 0,
725                    line: 0,
726                    col: 0,
727                    edges: Vec::new(),
728                    is_cross_package: false,
729                },
730            ));
731
732        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
733
734        assert_eq!(groups.len(), 1);
735        assert_eq!(groups[0].key, UNOWNED_LABEL);
736    }
737
738    #[test]
739    fn circular_dep_uses_first_file() {
740        let mut results = AnalysisResults::default();
741        results
742            .circular_dependencies
743            .push(CircularDependencyFinding::with_actions(
744                CircularDependency {
745                    files: vec![
746                        PathBuf::from("/root/src/a.ts"),
747                        PathBuf::from("/root/lib/b.ts"),
748                    ],
749                    length: 2,
750                    line: 1,
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, "src");
761    }
762
763    #[test]
764    fn duplicate_exports_empty_locations_goes_to_unowned() {
765        let mut results = AnalysisResults::default();
766        results
767            .duplicate_exports
768            .push(DuplicateExportFinding::with_actions(DuplicateExport {
769                export_name: "dup".to_string(),
770                locations: vec![],
771            }));
772
773        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
774
775        assert_eq!(groups.len(), 1);
776        assert_eq!(groups[0].key, UNOWNED_LABEL);
777    }
778
779    #[test]
780    fn resolve_owner_returns_directory() {
781        let owner = resolve_owner(
782            Path::new("/root/src/file.ts"),
783            &root(),
784            &OwnershipResolver::Directory,
785        );
786        assert_eq!(owner, "src");
787    }
788
789    #[test]
790    fn resolve_owner_returns_codeowner() {
791        let co = CodeOwners::parse("/src/ @team\n").unwrap();
792        let resolver = OwnershipResolver::Owner(co);
793        let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
794        assert_eq!(owner, "@team");
795    }
796
797    #[test]
798    fn mode_label_owner() {
799        let co = CodeOwners::parse("").unwrap();
800        let resolver = OwnershipResolver::Owner(co);
801        assert_eq!(resolver.mode_label(), "owner");
802    }
803
804    #[test]
805    fn mode_label_directory() {
806        assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
807    }
808
809    #[test]
810    fn mode_label_package() {
811        let pr = PackageResolver { workspaces: vec![] };
812        assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
813    }
814
815    #[test]
816    fn mode_label_section() {
817        let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
818        assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
819    }
820
821    #[test]
822    fn section_mode_groups_distinct_sections_with_shared_owners() {
823        let content = "\
824            [billing] @core-reviewers @alice @bob\n\
825            src/billing/\n\
826            [notifications] @core-reviewers @alice @bob\n\
827            src/notifications/\n\
828        ";
829        let co = CodeOwners::parse(content).unwrap();
830        let resolver = OwnershipResolver::Section(co);
831
832        let mut results = AnalysisResults::default();
833        results
834            .unused_files
835            .push(unused_file("/root/src/billing/a.ts"));
836        results
837            .unused_files
838            .push(unused_file("/root/src/billing/b.ts"));
839        results
840            .unused_files
841            .push(unused_file("/root/src/notifications/c.ts"));
842
843        let groups = group_analysis_results(&results, &root(), &resolver);
844
845        assert_eq!(groups.len(), 2);
846        let billing = groups.iter().find(|g| g.key == "billing").unwrap();
847        let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
848        assert_eq!(billing.results.total_issues(), 2);
849        assert_eq!(notifications.results.total_issues(), 1);
850        assert_eq!(
851            billing.owners.as_deref(),
852            Some(
853                [
854                    "@core-reviewers".to_string(),
855                    "@alice".to_string(),
856                    "@bob".to_string()
857                ]
858                .as_slice()
859            )
860        );
861        assert_eq!(
862            notifications.owners.as_deref(),
863            Some(
864                [
865                    "@core-reviewers".to_string(),
866                    "@alice".to_string(),
867                    "@bob".to_string()
868                ]
869                .as_slice()
870            )
871        );
872    }
873
874    #[test]
875    fn section_mode_pre_section_rule_goes_to_no_section() {
876        let content = "\
877            * @default\n\
878            [Utilities] @utils\n\
879            src/utils/\n\
880        ";
881        let co = CodeOwners::parse(content).unwrap();
882        let resolver = OwnershipResolver::Section(co);
883
884        let mut results = AnalysisResults::default();
885        results.unused_files.push(unused_file("/root/README.md"));
886        results
887            .unused_files
888            .push(unused_file("/root/src/utils/greet.ts"));
889
890        let groups = group_analysis_results(&results, &root(), &resolver);
891
892        assert_eq!(groups.len(), 2);
893        let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
894        let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
895        assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
896        assert_eq!(
897            utils.owners.as_deref(),
898            Some(["@utils".to_string()].as_slice())
899        );
900    }
901
902    #[test]
903    fn section_mode_unmatched_goes_to_unowned() {
904        let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
905        let resolver = OwnershipResolver::Section(co);
906
907        let mut results = AnalysisResults::default();
908        results.unused_files.push(unused_file("/root/README.md"));
909
910        let groups = group_analysis_results(&results, &root(), &resolver);
911
912        assert_eq!(groups.len(), 1);
913        assert_eq!(groups[0].key, UNOWNED_LABEL);
914        assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
915    }
916
917    #[test]
918    fn directory_mode_groups_have_no_owners_metadata() {
919        let mut results = AnalysisResults::default();
920        results.unused_files.push(unused_file("/root/src/a.ts"));
921        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
922        assert_eq!(groups[0].owners, None);
923    }
924
925    #[test]
926    fn package_resolver_matches_longest_prefix() {
927        let ws = vec![
928            fallow_config::WorkspaceInfo {
929                name: "packages/ui".to_string(),
930                root: PathBuf::from("/root/packages/ui"),
931                is_internal_dependency: false,
932            },
933            fallow_config::WorkspaceInfo {
934                name: "packages".to_string(),
935                root: PathBuf::from("/root/packages"),
936                is_internal_dependency: false,
937            },
938        ];
939        let pr = PackageResolver::new(Path::new("/root"), &ws);
940        assert_eq!(
941            pr.resolve(Path::new("packages/ui/Button.ts")),
942            "packages/ui"
943        );
944    }
945
946    #[test]
947    fn package_resolver_root_fallback() {
948        let ws = vec![fallow_config::WorkspaceInfo {
949            name: "packages/ui".to_string(),
950            root: PathBuf::from("/root/packages/ui"),
951            is_internal_dependency: false,
952        }];
953        let pr = PackageResolver::new(Path::new("/root"), &ws);
954        assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
955    }
956
957    #[test]
958    fn package_mode_groups_by_workspace() {
959        let ws = vec![
960            fallow_config::WorkspaceInfo {
961                name: "ui".to_string(),
962                root: PathBuf::from("/root/packages/ui"),
963                is_internal_dependency: false,
964            },
965            fallow_config::WorkspaceInfo {
966                name: "auth".to_string(),
967                root: PathBuf::from("/root/packages/auth"),
968                is_internal_dependency: false,
969            },
970        ];
971        let pr = PackageResolver::new(Path::new("/root"), &ws);
972        let resolver = OwnershipResolver::Package(pr);
973
974        let mut results = AnalysisResults::default();
975        results
976            .unused_files
977            .push(unused_file("/root/packages/ui/Button.ts"));
978        results
979            .unused_files
980            .push(unused_file("/root/packages/auth/login.ts"));
981        results.unused_files.push(unused_file("/root/src/main.ts"));
982
983        let groups = group_analysis_results(&results, &root(), &resolver);
984        assert_eq!(groups.len(), 3);
985
986        let ui_group = groups.iter().find(|g| g.key == "ui");
987        let auth_group = groups.iter().find(|g| g.key == "auth");
988        let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
989
990        assert!(ui_group.is_some());
991        assert!(auth_group.is_some());
992        assert!(root_group.is_some());
993    }
994
995    #[test]
996    fn resolve_with_rule_directory_mode_no_rule() {
997        let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
998        assert_eq!(key, "src");
999        assert!(rule.is_none());
1000    }
1001
1002    #[test]
1003    fn resolve_with_rule_owner_mode_with_match() {
1004        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
1005        let resolver = OwnershipResolver::Owner(co);
1006        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
1007        assert_eq!(key, "@frontend");
1008        assert!(rule.is_some());
1009        assert!(rule.unwrap().contains("src"));
1010    }
1011
1012    #[test]
1013    fn resolve_with_rule_owner_mode_no_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("docs/readme.md"));
1017        assert_eq!(key, UNOWNED_LABEL);
1018        assert!(rule.is_none());
1019    }
1020
1021    #[test]
1022    fn resolve_with_rule_package_mode_no_rule() {
1023        let pr = PackageResolver { workspaces: vec![] };
1024        let resolver = OwnershipResolver::Package(pr);
1025        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
1026        assert_eq!(key, ROOT_PACKAGE_LABEL);
1027        assert!(rule.is_none());
1028    }
1029
1030    #[test]
1031    fn group_unused_optional_deps() {
1032        let mut results = AnalysisResults::default();
1033        results
1034            .unused_optional_dependencies
1035            .push(UnusedOptionalDependencyFinding::with_actions(
1036                UnusedDependency {
1037                    package_name: "fsevents".to_string(),
1038                    location: fallow_core::results::DependencyLocation::OptionalDependencies,
1039                    path: PathBuf::from("/root/package.json"),
1040                    line: 5,
1041                    used_in_workspaces: Vec::new(),
1042                },
1043            ));
1044
1045        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1046        assert_eq!(groups.len(), 1);
1047        assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
1048    }
1049
1050    #[test]
1051    fn group_type_only_deps() {
1052        let mut results = AnalysisResults::default();
1053        results.type_only_dependencies.push(
1054            fallow_core::results::TypeOnlyDependencyFinding::with_actions(TypeOnlyDependency {
1055                package_name: "zod".to_string(),
1056                path: PathBuf::from("/root/package.json"),
1057                line: 8,
1058            }),
1059        );
1060
1061        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1062        assert_eq!(groups.len(), 1);
1063        assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
1064    }
1065
1066    #[test]
1067    fn group_test_only_deps() {
1068        let mut results = AnalysisResults::default();
1069        results.test_only_dependencies.push(
1070            fallow_core::results::TestOnlyDependencyFinding::with_actions(TestOnlyDependency {
1071                package_name: "vitest".to_string(),
1072                path: PathBuf::from("/root/package.json"),
1073                line: 10,
1074            }),
1075        );
1076
1077        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1078        assert_eq!(groups.len(), 1);
1079        assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1080    }
1081
1082    #[test]
1083    fn group_unused_enum_members() {
1084        let mut results = AnalysisResults::default();
1085        results.unused_enum_members.push(
1086            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
1087                path: PathBuf::from("/root/src/types.ts"),
1088                parent_name: "Status".to_string(),
1089                member_name: "Deprecated".to_string(),
1090                kind: fallow_core::extract::MemberKind::EnumMember,
1091                line: 5,
1092                col: 0,
1093            }),
1094        );
1095
1096        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1097        assert_eq!(groups.len(), 1);
1098        assert_eq!(groups[0].key, "src");
1099        assert_eq!(groups[0].results.unused_enum_members.len(), 1);
1100    }
1101
1102    #[test]
1103    fn group_unused_class_members() {
1104        let mut results = AnalysisResults::default();
1105        results.unused_class_members.push(
1106            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
1107                path: PathBuf::from("/root/lib/service.ts"),
1108                parent_name: "UserService".to_string(),
1109                member_name: "legacyMethod".to_string(),
1110                kind: fallow_core::extract::MemberKind::ClassMethod,
1111                line: 42,
1112                col: 0,
1113            }),
1114        );
1115
1116        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1117        assert_eq!(groups.len(), 1);
1118        assert_eq!(groups[0].key, "lib");
1119        assert_eq!(groups[0].results.unused_class_members.len(), 1);
1120    }
1121
1122    #[test]
1123    fn group_unresolved_imports() {
1124        let mut results = AnalysisResults::default();
1125        results.unresolved_imports.push(
1126            fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
1127                fallow_core::results::UnresolvedImport {
1128                    path: PathBuf::from("/root/src/app.ts"),
1129                    specifier: "./missing".to_string(),
1130                    line: 1,
1131                    col: 0,
1132                    specifier_col: 0,
1133                },
1134            ),
1135        );
1136
1137        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1138        assert_eq!(groups.len(), 1);
1139        assert_eq!(groups[0].key, "src");
1140        assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1141    }
1142}