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        self.group_structure_issues(results);
303        self.group_framework_boundary_issues(results);
304        self.group_component_contract_issues(results);
305    }
306
307    /// Group circular deps, boundary violations, and policy violations by owner.
308    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    /// Group client-export, barrel, directive, inject, and unrendered issues by owner.
342    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    /// Group component-contract and stale-suppression issues by owner.
371    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
438/// Merge per-key results and owners into sorted `ResultGroup`s.
439///
440/// Ordering: most issues first, alphabetical tiebreaker, `(unowned)` pinned to
441/// the end. `group_owners` is consumed only when `is_section_mode` is true.
442fn 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
478/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
479pub 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}