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    ///
28    /// Distinct sections produce distinct groups even when they share the
29    /// same default owner. Rules that appear before any section header
30    /// fall into the `(no section)` bucket.
31    Section(CodeOwners),
32}
33
34/// Resolves file paths to workspace package names via longest-prefix matching.
35///
36/// Stores workspace roots as paths relative to the project root so that
37/// resolution works with the relative paths passed to `OwnershipResolver::resolve`.
38pub struct PackageResolver {
39    /// `(relative_root, package_name)` sorted by path length descending.
40    workspaces: Vec<(PathBuf, String)>,
41}
42
43const ROOT_PACKAGE_LABEL: &str = "(root)";
44
45impl PackageResolver {
46    /// Build a resolver from discovered workspace info.
47    ///
48    /// Workspace roots are stored relative to `project_root` and sorted by path
49    /// length descending so the first match is always the most specific prefix.
50    pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
51        let mut ws: Vec<(PathBuf, String)> = workspaces
52            .iter()
53            .map(|w| {
54                let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
55                (rel.to_path_buf(), w.name.clone())
56            })
57            .collect();
58        ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
59        Self { workspaces: ws }
60    }
61
62    /// Find the workspace package that owns `rel_path`, or `"(root)"` if none match.
63    fn resolve(&self, rel_path: &Path) -> &str {
64        self.workspaces
65            .iter()
66            .find(|(root, _)| rel_path.starts_with(root))
67            .map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
68    }
69}
70
71impl OwnershipResolver {
72    /// Resolve the group key for a file path (relative to project root).
73    pub fn resolve(&self, rel_path: &Path) -> String {
74        match self {
75            Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
76            Self::Directory => codeowners::directory_group(rel_path).to_string(),
77            Self::Package(pr) => pr.resolve(rel_path).to_string(),
78            Self::Section(co) => match co.section_of(rel_path) {
79                Some(Some(name)) => name.to_string(),
80                Some(None) => NO_SECTION_LABEL.to_string(),
81                None => UNOWNED_LABEL.to_string(),
82            },
83        }
84    }
85
86    /// Resolve the group key and matching rule for a path.
87    ///
88    /// Returns `(owner, Some(pattern))` for Owner mode,
89    /// `(directory, None)` for Directory/Package mode,
90    /// `(section, Some(pattern))` for Section mode (pattern is the raw
91    /// CODEOWNERS pattern from the last matching rule).
92    pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
93        match self {
94            Self::Owner(co) => {
95                if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
96                    (owner.to_string(), Some(rule.to_string()))
97                } else {
98                    (UNOWNED_LABEL.to_string(), None)
99                }
100            }
101            Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
102            Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
103            Self::Section(co) => {
104                if let Some((section, _owners, rule)) = co.section_owners_and_rule_of(rel_path) {
105                    let key = section.map_or_else(|| NO_SECTION_LABEL.to_string(), str::to_string);
106                    (key, Some(rule.to_string()))
107                } else {
108                    (UNOWNED_LABEL.to_string(), None)
109                }
110            }
111        }
112    }
113
114    /// Label for the grouping mode (used in JSON `grouped_by` field).
115    pub fn mode_label(&self) -> &'static str {
116        match self {
117            Self::Owner(_) => "owner",
118            Self::Directory => "directory",
119            Self::Package(_) => "package",
120            Self::Section(_) => "section",
121        }
122    }
123
124    /// Look up the section default owners for a group key.
125    ///
126    /// Returns `Some(&[...])` only in Section mode when `rel_path` resolves
127    /// to a rule inside a named section. Used to emit the `owners` metadata
128    /// array in grouped JSON output.
129    pub fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]> {
130        if let Self::Section(co) = self
131            && let Some((_, owners)) = co.section_and_owners_of(rel_path)
132        {
133            Some(owners)
134        } else {
135            None
136        }
137    }
138}
139
140/// A single group: a label and its subset of results.
141pub struct ResultGroup {
142    /// Group label (owner name, directory, package, or section).
143    pub key: String,
144    /// Section default owners for `--group-by section`.
145    ///
146    /// `None` for all other grouping modes. `Some(vec![])` for the
147    /// `(no section)` and `(unowned)` buckets in Section mode.
148    pub owners: Option<Vec<String>>,
149    /// Issues belonging to this group.
150    pub results: AnalysisResults,
151}
152
153/// Partition analysis results into groups by ownership or directory.
154///
155/// Each issue is assigned to a group by extracting its primary file path
156/// and resolving the group key via the `OwnershipResolver`.
157/// Returns groups sorted alphabetically by key, with `(unowned)` last.
158pub fn group_analysis_results(
159    results: &AnalysisResults,
160    root: &Path,
161    resolver: &OwnershipResolver,
162) -> Vec<ResultGroup> {
163    let mut groups: FxHashMap<String, AnalysisResults> = FxHashMap::default();
164    // Section-mode: remember the default owners for each section key. Written
165    // once per key (the first path that lands there); all subsequent paths in
166    // the same section share the same defaults.
167    let mut group_owners: FxHashMap<String, Vec<String>> = FxHashMap::default();
168    let is_section_mode = matches!(resolver, OwnershipResolver::Section(_));
169
170    let mut key_for = |path: &Path| -> String {
171        let rel = relative_path(path, root);
172        let key = resolver.resolve(rel);
173        if is_section_mode && !group_owners.contains_key(&key) {
174            let owners = resolver
175                .section_owners_of(rel)
176                .map(<[String]>::to_vec)
177                .unwrap_or_default();
178            group_owners.insert(key.clone(), owners);
179        }
180        key
181    };
182
183    // ── File-scoped issue types ─────────────────────────────────
184    for item in &results.unused_files {
185        groups
186            .entry(key_for(&item.path))
187            .or_default()
188            .unused_files
189            .push(item.clone());
190    }
191    for item in &results.unused_exports {
192        groups
193            .entry(key_for(&item.path))
194            .or_default()
195            .unused_exports
196            .push(item.clone());
197    }
198    for item in &results.unused_types {
199        groups
200            .entry(key_for(&item.path))
201            .or_default()
202            .unused_types
203            .push(item.clone());
204    }
205    for item in &results.private_type_leaks {
206        groups
207            .entry(key_for(&item.path))
208            .or_default()
209            .private_type_leaks
210            .push(item.clone());
211    }
212    for item in &results.unused_enum_members {
213        groups
214            .entry(key_for(&item.path))
215            .or_default()
216            .unused_enum_members
217            .push(item.clone());
218    }
219    for item in &results.unused_class_members {
220        groups
221            .entry(key_for(&item.path))
222            .or_default()
223            .unused_class_members
224            .push(item.clone());
225    }
226    for item in &results.unresolved_imports {
227        groups
228            .entry(key_for(&item.path))
229            .or_default()
230            .unresolved_imports
231            .push(item.clone());
232    }
233
234    // ── Dependency-scoped (use package.json path) ───────────────
235    for item in &results.unused_dependencies {
236        groups
237            .entry(key_for(&item.path))
238            .or_default()
239            .unused_dependencies
240            .push(item.clone());
241    }
242    for item in &results.unused_dev_dependencies {
243        groups
244            .entry(key_for(&item.path))
245            .or_default()
246            .unused_dev_dependencies
247            .push(item.clone());
248    }
249    for item in &results.unused_optional_dependencies {
250        groups
251            .entry(key_for(&item.path))
252            .or_default()
253            .unused_optional_dependencies
254            .push(item.clone());
255    }
256    for item in &results.type_only_dependencies {
257        groups
258            .entry(key_for(&item.path))
259            .or_default()
260            .type_only_dependencies
261            .push(item.clone());
262    }
263    for item in &results.test_only_dependencies {
264        groups
265            .entry(key_for(&item.path))
266            .or_default()
267            .test_only_dependencies
268            .push(item.clone());
269    }
270
271    // ── Multi-location types (use first location) ───────────────
272    for item in &results.unlisted_dependencies {
273        let key = item
274            .imported_from
275            .first()
276            .map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
277        groups
278            .entry(key)
279            .or_default()
280            .unlisted_dependencies
281            .push(item.clone());
282    }
283    for item in &results.duplicate_exports {
284        let key = item
285            .locations
286            .first()
287            .map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
288        groups
289            .entry(key)
290            .or_default()
291            .duplicate_exports
292            .push(item.clone());
293    }
294    for item in &results.circular_dependencies {
295        let key = item
296            .files
297            .first()
298            .map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
299        groups
300            .entry(key)
301            .or_default()
302            .circular_dependencies
303            .push(item.clone());
304    }
305    for item in &results.boundary_violations {
306        groups
307            .entry(key_for(&item.from_path))
308            .or_default()
309            .boundary_violations
310            .push(item.clone());
311    }
312    for item in &results.stale_suppressions {
313        groups
314            .entry(key_for(&item.path))
315            .or_default()
316            .stale_suppressions
317            .push(item.clone());
318    }
319    group_workspace_config_issues(results, &mut groups, &mut key_for);
320
321    finalize_groups(groups, group_owners, is_section_mode)
322}
323
324fn group_workspace_config_issues(
325    results: &AnalysisResults,
326    groups: &mut FxHashMap<String, AnalysisResults>,
327    mut key_for: impl FnMut(&Path) -> String,
328) {
329    for item in &results.unused_catalog_entries {
330        groups
331            .entry(key_for(&item.path))
332            .or_default()
333            .unused_catalog_entries
334            .push(item.clone());
335    }
336    for item in &results.empty_catalog_groups {
337        groups
338            .entry(key_for(&item.path))
339            .or_default()
340            .empty_catalog_groups
341            .push(item.clone());
342    }
343    for item in &results.unresolved_catalog_references {
344        groups
345            .entry(key_for(&item.path))
346            .or_default()
347            .unresolved_catalog_references
348            .push(item.clone());
349    }
350    for item in &results.unused_dependency_overrides {
351        groups
352            .entry(key_for(&item.path))
353            .or_default()
354            .unused_dependency_overrides
355            .push(item.clone());
356    }
357    for item in &results.misconfigured_dependency_overrides {
358        groups
359            .entry(key_for(&item.path))
360            .or_default()
361            .misconfigured_dependency_overrides
362            .push(item.clone());
363    }
364}
365
366/// Merge per-key results and owners into sorted `ResultGroup`s.
367///
368/// Ordering: most issues first, alphabetical tiebreaker, `(unowned)` pinned to
369/// the end. `group_owners` is consumed only when `is_section_mode` is true.
370fn finalize_groups(
371    groups: FxHashMap<String, AnalysisResults>,
372    mut group_owners: FxHashMap<String, Vec<String>>,
373    is_section_mode: bool,
374) -> Vec<ResultGroup> {
375    let mut sorted: Vec<_> = groups
376        .into_iter()
377        .map(|(key, results)| {
378            let owners = if is_section_mode {
379                Some(group_owners.remove(&key).unwrap_or_default())
380            } else {
381                None
382            };
383            ResultGroup {
384                key,
385                owners,
386                results,
387            }
388        })
389        .collect();
390    sorted.sort_by(|a, b| {
391        let a_unowned = a.key == UNOWNED_LABEL;
392        let b_unowned = b.key == UNOWNED_LABEL;
393        match (a_unowned, b_unowned) {
394            (true, false) => std::cmp::Ordering::Greater,
395            (false, true) => std::cmp::Ordering::Less,
396            _ => b
397                .results
398                .total_issues()
399                .cmp(&a.results.total_issues())
400                .then_with(|| a.key.cmp(&b.key)),
401        }
402    });
403    sorted
404}
405
406/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
407pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
408    resolver.resolve(relative_path(path, root))
409}
410
411#[cfg(test)]
412mod tests {
413    use std::path::{Path, PathBuf};
414
415    use fallow_core::results::*;
416
417    use super::*;
418    use crate::codeowners::CodeOwners;
419
420    // ── Helpers ────────────────────────────────────────────────────
421
422    fn root() -> PathBuf {
423        PathBuf::from("/root")
424    }
425
426    fn unused_file(path: &str) -> UnusedFile {
427        UnusedFile {
428            path: PathBuf::from(path),
429        }
430    }
431
432    fn unused_export(path: &str, name: &str) -> UnusedExport {
433        UnusedExport {
434            path: PathBuf::from(path),
435            export_name: name.to_string(),
436            is_type_only: false,
437            line: 1,
438            col: 0,
439            span_start: 0,
440            is_re_export: false,
441        }
442    }
443
444    fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
445        UnlistedDependency {
446            package_name: name.to_string(),
447            imported_from: sites,
448        }
449    }
450
451    fn import_site(path: &str) -> ImportSite {
452        ImportSite {
453            path: PathBuf::from(path),
454            line: 1,
455            col: 0,
456        }
457    }
458
459    // ── 1. Empty results ──────────────────────────────────────────
460
461    #[test]
462    fn empty_results_returns_empty_vec() {
463        let results = AnalysisResults::default();
464        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
465        assert!(groups.is_empty());
466    }
467
468    // ── 2. Single group ──────────────────────────────────────────
469
470    #[test]
471    fn single_group_all_same_directory() {
472        let mut results = AnalysisResults::default();
473        results.unused_files.push(unused_file("/root/src/a.ts"));
474        results.unused_files.push(unused_file("/root/src/b.ts"));
475        results
476            .unused_exports
477            .push(unused_export("/root/src/c.ts", "foo"));
478
479        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
480
481        assert_eq!(groups.len(), 1);
482        assert_eq!(groups[0].key, "src");
483        assert_eq!(groups[0].results.unused_files.len(), 2);
484        assert_eq!(groups[0].results.unused_exports.len(), 1);
485        assert_eq!(groups[0].results.total_issues(), 3);
486    }
487
488    // ── 3. Multiple groups ───────────────────────────────────────
489
490    #[test]
491    fn multiple_groups_split_by_directory() {
492        let mut results = AnalysisResults::default();
493        results.unused_files.push(unused_file("/root/src/a.ts"));
494        results.unused_files.push(unused_file("/root/lib/b.ts"));
495        results
496            .unused_exports
497            .push(unused_export("/root/src/c.ts", "bar"));
498
499        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
500
501        assert_eq!(groups.len(), 2);
502
503        let src_group = groups.iter().find(|g| g.key == "src").unwrap();
504        let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
505
506        assert_eq!(src_group.results.total_issues(), 2);
507        assert_eq!(lib_group.results.total_issues(), 1);
508    }
509
510    // ── 4. Sort order: most issues first ─────────────────────────
511
512    #[test]
513    fn sort_order_descending_by_total_issues() {
514        let mut results = AnalysisResults::default();
515        // lib: 1 issue
516        results.unused_files.push(unused_file("/root/lib/a.ts"));
517        // src: 3 issues
518        results.unused_files.push(unused_file("/root/src/a.ts"));
519        results.unused_files.push(unused_file("/root/src/b.ts"));
520        results
521            .unused_exports
522            .push(unused_export("/root/src/c.ts", "x"));
523        // test: 2 issues
524        results.unused_files.push(unused_file("/root/test/a.ts"));
525        results.unused_files.push(unused_file("/root/test/b.ts"));
526
527        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
528
529        assert_eq!(groups.len(), 3);
530        assert_eq!(groups[0].key, "src");
531        assert_eq!(groups[0].results.total_issues(), 3);
532        assert_eq!(groups[1].key, "test");
533        assert_eq!(groups[1].results.total_issues(), 2);
534        assert_eq!(groups[2].key, "lib");
535        assert_eq!(groups[2].results.total_issues(), 1);
536    }
537
538    #[test]
539    fn sort_order_alphabetical_tiebreaker() {
540        let mut results = AnalysisResults::default();
541        results.unused_files.push(unused_file("/root/beta/a.ts"));
542        results.unused_files.push(unused_file("/root/alpha/a.ts"));
543
544        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
545
546        assert_eq!(groups.len(), 2);
547        // Same issue count (1 each) -> alphabetical
548        assert_eq!(groups[0].key, "alpha");
549        assert_eq!(groups[1].key, "beta");
550    }
551
552    // ── 5. Unowned always last ───────────────────────────────────
553
554    #[test]
555    fn unowned_sorts_last_regardless_of_count() {
556        let mut results = AnalysisResults::default();
557        // src: 1 issue
558        results.unused_files.push(unused_file("/root/src/a.ts"));
559        // unlisted dep with empty imported_from -> goes to (unowned)
560        results
561            .unlisted_dependencies
562            .push(unlisted_dep("pkg-a", vec![]));
563        results
564            .unlisted_dependencies
565            .push(unlisted_dep("pkg-b", vec![]));
566        results
567            .unlisted_dependencies
568            .push(unlisted_dep("pkg-c", vec![]));
569
570        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
571
572        assert_eq!(groups.len(), 2);
573        // (unowned) has 3 issues vs src's 1, but must still be last
574        assert_eq!(groups[0].key, "src");
575        assert_eq!(groups[1].key, UNOWNED_LABEL);
576        assert_eq!(groups[1].results.total_issues(), 3);
577    }
578
579    // ── 6. Multi-location fallback ───────────────────────────────
580
581    #[test]
582    fn unlisted_dep_empty_imported_from_goes_to_unowned() {
583        let mut results = AnalysisResults::default();
584        results
585            .unlisted_dependencies
586            .push(unlisted_dep("missing-pkg", vec![]));
587
588        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
589
590        assert_eq!(groups.len(), 1);
591        assert_eq!(groups[0].key, UNOWNED_LABEL);
592        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
593    }
594
595    #[test]
596    fn unlisted_dep_with_import_site_goes_to_directory() {
597        let mut results = AnalysisResults::default();
598        results.unlisted_dependencies.push(unlisted_dep(
599            "lodash",
600            vec![import_site("/root/src/util.ts")],
601        ));
602
603        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
604
605        assert_eq!(groups.len(), 1);
606        assert_eq!(groups[0].key, "src");
607        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
608    }
609
610    // ── 7. Directory mode ────────────────────────────────────────
611
612    #[test]
613    fn directory_mode_groups_by_first_path_component() {
614        let mut results = AnalysisResults::default();
615        results
616            .unused_files
617            .push(unused_file("/root/packages/ui/Button.ts"));
618        results
619            .unused_files
620            .push(unused_file("/root/packages/auth/login.ts"));
621        results
622            .unused_exports
623            .push(unused_export("/root/apps/web/index.ts", "main"));
624
625        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
626
627        assert_eq!(groups.len(), 2);
628
629        let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
630        let apps = groups.iter().find(|g| g.key == "apps").unwrap();
631
632        assert_eq!(pkgs.results.total_issues(), 2);
633        assert_eq!(apps.results.total_issues(), 1);
634    }
635
636    // ── 8. Owner mode ────────────────────────────────────────────
637
638    #[test]
639    fn owner_mode_groups_by_codeowners_owner() {
640        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
641        let resolver = OwnershipResolver::Owner(co);
642
643        let mut results = AnalysisResults::default();
644        results.unused_files.push(unused_file("/root/src/app.ts"));
645        results.unused_files.push(unused_file("/root/README.md"));
646
647        let groups = group_analysis_results(&results, &root(), &resolver);
648
649        assert_eq!(groups.len(), 2);
650
651        let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
652        let default = groups.iter().find(|g| g.key == "@default").unwrap();
653
654        assert_eq!(frontend.results.unused_files.len(), 1);
655        assert_eq!(default.results.unused_files.len(), 1);
656    }
657
658    #[test]
659    fn owner_mode_unmatched_goes_to_unowned() {
660        // No catch-all rule -- files outside /src/ have no owner
661        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
662        let resolver = OwnershipResolver::Owner(co);
663
664        let mut results = AnalysisResults::default();
665        results.unused_files.push(unused_file("/root/README.md"));
666
667        let groups = group_analysis_results(&results, &root(), &resolver);
668
669        assert_eq!(groups.len(), 1);
670        assert_eq!(groups[0].key, UNOWNED_LABEL);
671    }
672
673    // ── Boundary violations ──────────────────────────────────────
674
675    #[test]
676    fn boundary_violations_grouped_by_from_path() {
677        let mut results = AnalysisResults::default();
678        results.boundary_violations.push(BoundaryViolation {
679            from_path: PathBuf::from("/root/src/bad.ts"),
680            to_path: PathBuf::from("/root/lib/secret.ts"),
681            from_zone: "src".to_string(),
682            to_zone: "lib".to_string(),
683            import_specifier: "../lib/secret".to_string(),
684            line: 1,
685            col: 0,
686        });
687
688        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
689
690        assert_eq!(groups.len(), 1);
691        assert_eq!(groups[0].key, "src");
692        assert_eq!(groups[0].results.boundary_violations.len(), 1);
693    }
694
695    // ── Circular dependencies ────────────────────────────────────
696
697    #[test]
698    fn circular_dep_empty_files_goes_to_unowned() {
699        let mut results = AnalysisResults::default();
700        results.circular_dependencies.push(CircularDependency {
701            files: vec![],
702            length: 0,
703            line: 0,
704            col: 0,
705            is_cross_package: false,
706        });
707
708        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
709
710        assert_eq!(groups.len(), 1);
711        assert_eq!(groups[0].key, UNOWNED_LABEL);
712    }
713
714    #[test]
715    fn circular_dep_uses_first_file() {
716        let mut results = AnalysisResults::default();
717        results.circular_dependencies.push(CircularDependency {
718            files: vec![
719                PathBuf::from("/root/src/a.ts"),
720                PathBuf::from("/root/lib/b.ts"),
721            ],
722            length: 2,
723            line: 1,
724            col: 0,
725            is_cross_package: false,
726        });
727
728        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
729
730        assert_eq!(groups.len(), 1);
731        assert_eq!(groups[0].key, "src");
732    }
733
734    // ── Duplicate exports ────────────────────────────────────────
735
736    #[test]
737    fn duplicate_exports_empty_locations_goes_to_unowned() {
738        let mut results = AnalysisResults::default();
739        results.duplicate_exports.push(DuplicateExport {
740            export_name: "dup".to_string(),
741            locations: vec![],
742        });
743
744        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
745
746        assert_eq!(groups.len(), 1);
747        assert_eq!(groups[0].key, UNOWNED_LABEL);
748    }
749
750    // ── resolve_owner ────────────────────────────────────────────
751
752    #[test]
753    fn resolve_owner_returns_directory() {
754        let owner = resolve_owner(
755            Path::new("/root/src/file.ts"),
756            &root(),
757            &OwnershipResolver::Directory,
758        );
759        assert_eq!(owner, "src");
760    }
761
762    #[test]
763    fn resolve_owner_returns_codeowner() {
764        let co = CodeOwners::parse("/src/ @team\n").unwrap();
765        let resolver = OwnershipResolver::Owner(co);
766        let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
767        assert_eq!(owner, "@team");
768    }
769
770    // ── mode_label ───────────────────────────────────────────────
771
772    #[test]
773    fn mode_label_owner() {
774        let co = CodeOwners::parse("").unwrap();
775        let resolver = OwnershipResolver::Owner(co);
776        assert_eq!(resolver.mode_label(), "owner");
777    }
778
779    #[test]
780    fn mode_label_directory() {
781        assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
782    }
783
784    #[test]
785    fn mode_label_package() {
786        let pr = PackageResolver { workspaces: vec![] };
787        assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
788    }
789
790    #[test]
791    fn mode_label_section() {
792        let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
793        assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
794    }
795
796    // ── Section mode ────────────────────────────────────────────
797
798    #[test]
799    fn section_mode_groups_distinct_sections_with_shared_owners() {
800        // Issue #133 reproduction: billing and notifications share the lead
801        // owner but are separate sections, so they must produce 2 groups.
802        let content = "\
803            [billing] @core-reviewers @alice @bob\n\
804            src/billing/\n\
805            [notifications] @core-reviewers @alice @bob\n\
806            src/notifications/\n\
807        ";
808        let co = CodeOwners::parse(content).unwrap();
809        let resolver = OwnershipResolver::Section(co);
810
811        let mut results = AnalysisResults::default();
812        results
813            .unused_files
814            .push(unused_file("/root/src/billing/a.ts"));
815        results
816            .unused_files
817            .push(unused_file("/root/src/billing/b.ts"));
818        results
819            .unused_files
820            .push(unused_file("/root/src/notifications/c.ts"));
821
822        let groups = group_analysis_results(&results, &root(), &resolver);
823
824        assert_eq!(groups.len(), 2);
825        let billing = groups.iter().find(|g| g.key == "billing").unwrap();
826        let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
827        assert_eq!(billing.results.total_issues(), 2);
828        assert_eq!(notifications.results.total_issues(), 1);
829        assert_eq!(
830            billing.owners.as_deref(),
831            Some(
832                [
833                    "@core-reviewers".to_string(),
834                    "@alice".to_string(),
835                    "@bob".to_string()
836                ]
837                .as_slice()
838            )
839        );
840        assert_eq!(
841            notifications.owners.as_deref(),
842            Some(
843                [
844                    "@core-reviewers".to_string(),
845                    "@alice".to_string(),
846                    "@bob".to_string()
847                ]
848                .as_slice()
849            )
850        );
851    }
852
853    #[test]
854    fn section_mode_pre_section_rule_goes_to_no_section() {
855        let content = "\
856            * @default\n\
857            [Utilities] @utils\n\
858            src/utils/\n\
859        ";
860        let co = CodeOwners::parse(content).unwrap();
861        let resolver = OwnershipResolver::Section(co);
862
863        let mut results = AnalysisResults::default();
864        results.unused_files.push(unused_file("/root/README.md"));
865        results
866            .unused_files
867            .push(unused_file("/root/src/utils/greet.ts"));
868
869        let groups = group_analysis_results(&results, &root(), &resolver);
870
871        assert_eq!(groups.len(), 2);
872        let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
873        let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
874        assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
875        assert_eq!(
876            utils.owners.as_deref(),
877            Some(["@utils".to_string()].as_slice())
878        );
879    }
880
881    #[test]
882    fn section_mode_unmatched_goes_to_unowned() {
883        let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
884        let resolver = OwnershipResolver::Section(co);
885
886        let mut results = AnalysisResults::default();
887        results.unused_files.push(unused_file("/root/README.md"));
888
889        let groups = group_analysis_results(&results, &root(), &resolver);
890
891        assert_eq!(groups.len(), 1);
892        assert_eq!(groups[0].key, UNOWNED_LABEL);
893        assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
894    }
895
896    #[test]
897    fn directory_mode_groups_have_no_owners_metadata() {
898        let mut results = AnalysisResults::default();
899        results.unused_files.push(unused_file("/root/src/a.ts"));
900        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
901        assert_eq!(groups[0].owners, None);
902    }
903
904    // ── PackageResolver ─────────────────────────────────────────
905
906    #[test]
907    fn package_resolver_matches_longest_prefix() {
908        let ws = vec![
909            fallow_config::WorkspaceInfo {
910                name: "packages/ui".to_string(),
911                root: PathBuf::from("/root/packages/ui"),
912                is_internal_dependency: false,
913            },
914            fallow_config::WorkspaceInfo {
915                name: "packages".to_string(),
916                root: PathBuf::from("/root/packages"),
917                is_internal_dependency: false,
918            },
919        ];
920        let pr = PackageResolver::new(Path::new("/root"), &ws);
921        // A file in packages/ui/ should match the more specific workspace
922        assert_eq!(
923            pr.resolve(Path::new("packages/ui/Button.ts")),
924            "packages/ui"
925        );
926    }
927
928    #[test]
929    fn package_resolver_root_fallback() {
930        let ws = vec![fallow_config::WorkspaceInfo {
931            name: "packages/ui".to_string(),
932            root: PathBuf::from("/root/packages/ui"),
933            is_internal_dependency: false,
934        }];
935        let pr = PackageResolver::new(Path::new("/root"), &ws);
936        // A file outside any workspace returns (root)
937        assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
938    }
939
940    #[test]
941    fn package_mode_groups_by_workspace() {
942        let ws = vec![
943            fallow_config::WorkspaceInfo {
944                name: "ui".to_string(),
945                root: PathBuf::from("/root/packages/ui"),
946                is_internal_dependency: false,
947            },
948            fallow_config::WorkspaceInfo {
949                name: "auth".to_string(),
950                root: PathBuf::from("/root/packages/auth"),
951                is_internal_dependency: false,
952            },
953        ];
954        let pr = PackageResolver::new(Path::new("/root"), &ws);
955        let resolver = OwnershipResolver::Package(pr);
956
957        let mut results = AnalysisResults::default();
958        results
959            .unused_files
960            .push(unused_file("/root/packages/ui/Button.ts"));
961        results
962            .unused_files
963            .push(unused_file("/root/packages/auth/login.ts"));
964        results.unused_files.push(unused_file("/root/src/main.ts"));
965
966        let groups = group_analysis_results(&results, &root(), &resolver);
967        assert_eq!(groups.len(), 3);
968
969        let ui_group = groups.iter().find(|g| g.key == "ui");
970        let auth_group = groups.iter().find(|g| g.key == "auth");
971        let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
972
973        assert!(ui_group.is_some());
974        assert!(auth_group.is_some());
975        assert!(root_group.is_some());
976    }
977
978    // ── resolve_with_rule ───────────────────────────────────────
979
980    #[test]
981    fn resolve_with_rule_directory_mode_no_rule() {
982        let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
983        assert_eq!(key, "src");
984        assert!(rule.is_none());
985    }
986
987    #[test]
988    fn resolve_with_rule_owner_mode_with_match() {
989        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
990        let resolver = OwnershipResolver::Owner(co);
991        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
992        assert_eq!(key, "@frontend");
993        assert!(rule.is_some());
994        assert!(rule.unwrap().contains("src"));
995    }
996
997    #[test]
998    fn resolve_with_rule_owner_mode_no_match() {
999        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
1000        let resolver = OwnershipResolver::Owner(co);
1001        let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
1002        assert_eq!(key, UNOWNED_LABEL);
1003        assert!(rule.is_none());
1004    }
1005
1006    #[test]
1007    fn resolve_with_rule_package_mode_no_rule() {
1008        let pr = PackageResolver { workspaces: vec![] };
1009        let resolver = OwnershipResolver::Package(pr);
1010        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
1011        assert_eq!(key, ROOT_PACKAGE_LABEL);
1012        assert!(rule.is_none());
1013    }
1014
1015    // ── Missing issue type groupings ────────────────────────────
1016
1017    #[test]
1018    fn group_unused_optional_deps() {
1019        let mut results = AnalysisResults::default();
1020        results.unused_optional_dependencies.push(UnusedDependency {
1021            package_name: "fsevents".to_string(),
1022            location: fallow_core::results::DependencyLocation::OptionalDependencies,
1023            path: PathBuf::from("/root/package.json"),
1024            line: 5,
1025            used_in_workspaces: Vec::new(),
1026        });
1027
1028        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1029        assert_eq!(groups.len(), 1);
1030        assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
1031    }
1032
1033    #[test]
1034    fn group_type_only_deps() {
1035        let mut results = AnalysisResults::default();
1036        results
1037            .type_only_dependencies
1038            .push(fallow_core::results::TypeOnlyDependency {
1039                package_name: "zod".to_string(),
1040                path: PathBuf::from("/root/package.json"),
1041                line: 8,
1042            });
1043
1044        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1045        assert_eq!(groups.len(), 1);
1046        assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
1047    }
1048
1049    #[test]
1050    fn group_test_only_deps() {
1051        let mut results = AnalysisResults::default();
1052        results
1053            .test_only_dependencies
1054            .push(fallow_core::results::TestOnlyDependency {
1055                package_name: "vitest".to_string(),
1056                path: PathBuf::from("/root/package.json"),
1057                line: 10,
1058            });
1059
1060        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1061        assert_eq!(groups.len(), 1);
1062        assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1063    }
1064
1065    #[test]
1066    fn group_unused_enum_members() {
1067        let mut results = AnalysisResults::default();
1068        results
1069            .unused_enum_members
1070            .push(fallow_core::results::UnusedMember {
1071                path: PathBuf::from("/root/src/types.ts"),
1072                parent_name: "Status".to_string(),
1073                member_name: "Deprecated".to_string(),
1074                kind: fallow_core::extract::MemberKind::EnumMember,
1075                line: 5,
1076                col: 0,
1077            });
1078
1079        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1080        assert_eq!(groups.len(), 1);
1081        assert_eq!(groups[0].key, "src");
1082        assert_eq!(groups[0].results.unused_enum_members.len(), 1);
1083    }
1084
1085    #[test]
1086    fn group_unused_class_members() {
1087        let mut results = AnalysisResults::default();
1088        results
1089            .unused_class_members
1090            .push(fallow_core::results::UnusedMember {
1091                path: PathBuf::from("/root/lib/service.ts"),
1092                parent_name: "UserService".to_string(),
1093                member_name: "legacyMethod".to_string(),
1094                kind: fallow_core::extract::MemberKind::ClassMethod,
1095                line: 42,
1096                col: 0,
1097            });
1098
1099        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1100        assert_eq!(groups.len(), 1);
1101        assert_eq!(groups[0].key, "lib");
1102        assert_eq!(groups[0].results.unused_class_members.len(), 1);
1103    }
1104
1105    #[test]
1106    fn group_unresolved_imports() {
1107        let mut results = AnalysisResults::default();
1108        results
1109            .unresolved_imports
1110            .push(fallow_core::results::UnresolvedImport {
1111                path: PathBuf::from("/root/src/app.ts"),
1112                specifier: "./missing".to_string(),
1113                line: 1,
1114                col: 0,
1115                specifier_col: 0,
1116            });
1117
1118        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1119        assert_eq!(groups.len(), 1);
1120        assert_eq!(groups[0].key, "src");
1121        assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1122    }
1123}