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_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
423/// Merge per-key results and owners into sorted `ResultGroup`s.
424///
425/// Ordering: most issues first, alphabetical tiebreaker, `(unowned)` pinned to
426/// the end. `group_owners` is consumed only when `is_section_mode` is true.
427fn 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
463/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
464pub 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}