Problem
--group-by owner groups by the first owner on each CODEOWNERS rule. In GitLab repos where every section shares a common approval group as the first owner, all findings collapse into one group.
[billing] @core-reviewers @alice @bob
src/billing/
[notifications] @core-reviewers @alice @bob
src/notifications/
[search] @core-reviewers @charlie @dave
src/search/
These are 3 distinct code areas, but --group-by owner produces 1 group (@core-reviewers). --group-by directory doesn't help either — real-world sections often span multiple directory trees.
Root cause: parse_section_header() in crates/cli/src/codeowners.rs (line ~221) computes the section name but never stores it. The CodeOwners struct only tracks owners, so the section name is lost.
Reproduction
Click to expand full repro steps
mkdir -p /tmp/fallow-section-repro/src/{billing,notifications,search,admin}
mkdir -p /tmp/fallow-section-repro/.gitlab
cat > /tmp/fallow-section-repro/package.json << 'EOF'
{ "name": "section-repro", "private": true }
EOF
cat > /tmp/fallow-section-repro/tsconfig.json << 'EOF'
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "outDir": "dist" }, "include": ["src"] }
EOF
cat > /tmp/fallow-section-repro/src/billing/invoice.ts << 'EOF'
export const currency = "USD";
export function unusedGenerateReceipt() { return "receipt"; }
export function unusedApplyDiscount() { return "discounted"; }
EOF
cat > /tmp/fallow-section-repro/src/notifications/email.ts << 'EOF'
export const defaultSender = "noreply@example.com";
export function unusedSendSMS() { return "sent"; }
EOF
cat > /tmp/fallow-section-repro/src/search/indexer.ts << 'EOF'
export const indexName = "products";
export function unusedReindex() { return "reindexed"; }
export function unusedPurgeIndex() { return "purged"; }
EOF
cat > /tmp/fallow-section-repro/src/admin/dashboard.ts << 'EOF'
export const adminPath = "/admin";
export function unusedExportCSV() { return "exported"; }
EOF
cat > /tmp/fallow-section-repro/src/index.ts << 'EOF'
import { currency } from "./billing/invoice";
import { defaultSender } from "./notifications/email";
import { indexName } from "./search/indexer";
import { adminPath } from "./admin/dashboard";
console.log(currency, defaultSender, indexName, adminPath);
EOF
cat > /tmp/fallow-section-repro/.gitlab/CODEOWNERS << 'EOF'
[billing] @core-reviewers @alice @bob
src/billing/
[notifications] @core-reviewers @alice @bob
src/notifications/
[search] @core-reviewers @charlie @dave
src/search/
[admin] @core-reviewers @eve
src/admin/
EOF
cd /tmp/fallow-section-repro
fallow dead-code --group-by owner --format json
Result: 1 group (@core-reviewers, 6 issues) instead of 4 groups.
Desired result — 4 groups, one per section:
{
"schema_version": 4,
"grouped_by": "section",
"total_issues": 6,
"groups": [
{
"key": "billing",
"owners": ["@core-reviewers", "@alice", "@bob"],
"total_issues": 2,
"unused_exports": [
{ "path": "src/billing/invoice.ts", "export_name": "unusedGenerateReceipt", "line": 2 },
{ "path": "src/billing/invoice.ts", "export_name": "unusedApplyDiscount", "line": 3 }
]
},
{
"key": "notifications",
"owners": ["@core-reviewers", "@alice", "@bob"],
"total_issues": 1,
"unused_exports": [
{ "path": "src/notifications/email.ts", "export_name": "unusedSendSMS", "line": 2 }
]
},
{
"key": "search",
"owners": ["@core-reviewers", "@charlie", "@dave"],
"total_issues": 2,
"unused_exports": [
{ "path": "src/search/indexer.ts", "export_name": "unusedReindex", "line": 2 },
{ "path": "src/search/indexer.ts", "export_name": "unusedPurgeIndex", "line": 3 }
]
},
{
"key": "admin",
"owners": ["@core-reviewers", "@eve"],
"total_issues": 1,
"unused_exports": [
{ "path": "src/admin/dashboard.ts", "export_name": "unusedExportCSV", "line": 2 }
]
}
]
}
Note: billing and notifications have the same owners but are separate groups — the section name is the key, owners are metadata.
Proposed solution
Group by section name ([SectionName] from GitLab CODEOWNERS headers). Section names are better grouping keys than owners because they're stable (don't change when reviewers rotate), produce a manageable number of groups, and represent intentional ownership boundaries.
parse_section_header() already computes the name but doesn't return it. Storing it alongside each rule would make it available for grouping. The API design (--group-by section, enhancement to --group-by owner, etc.) is your call.
Edge cases
- Last-match-wins: Section determined by the last matching rule, same as owner today
- Rules before first section:
(no section) group
- Duplicate section names: Merge or disambiguate — your call
^[optional] sections: ^ only affects approval, not grouping
- No sections (plain GitHub CODEOWNERS): Error or fallback to owner grouping
Alternatives considered
--group-by directory: Breaks when sections span multiple directory trees
- Post-processing JSON: Works but duplicates CODEOWNERS parsing
--group-by rule: Loses human-readable section names, doesn't handle multi-pattern sections
Problem
--group-by ownergroups by the first owner on each CODEOWNERS rule. In GitLab repos where every section shares a common approval group as the first owner, all findings collapse into one group.These are 3 distinct code areas, but
--group-by ownerproduces 1 group (@core-reviewers).--group-by directorydoesn't help either — real-world sections often span multiple directory trees.Root cause:
parse_section_header()incrates/cli/src/codeowners.rs(line ~221) computes the section name but never stores it. TheCodeOwnersstruct only tracks owners, so the section name is lost.Reproduction
Click to expand full repro steps
mkdir -p /tmp/fallow-section-repro/src/{billing,notifications,search,admin} mkdir -p /tmp/fallow-section-repro/.gitlab cat > /tmp/fallow-section-repro/package.json << 'EOF' { "name": "section-repro", "private": true } EOF cat > /tmp/fallow-section-repro/tsconfig.json << 'EOF' { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "outDir": "dist" }, "include": ["src"] } EOF cat > /tmp/fallow-section-repro/src/billing/invoice.ts << 'EOF' export const currency = "USD"; export function unusedGenerateReceipt() { return "receipt"; } export function unusedApplyDiscount() { return "discounted"; } EOF cat > /tmp/fallow-section-repro/src/notifications/email.ts << 'EOF' export const defaultSender = "noreply@example.com"; export function unusedSendSMS() { return "sent"; } EOF cat > /tmp/fallow-section-repro/src/search/indexer.ts << 'EOF' export const indexName = "products"; export function unusedReindex() { return "reindexed"; } export function unusedPurgeIndex() { return "purged"; } EOF cat > /tmp/fallow-section-repro/src/admin/dashboard.ts << 'EOF' export const adminPath = "/admin"; export function unusedExportCSV() { return "exported"; } EOF cat > /tmp/fallow-section-repro/src/index.ts << 'EOF' import { currency } from "./billing/invoice"; import { defaultSender } from "./notifications/email"; import { indexName } from "./search/indexer"; import { adminPath } from "./admin/dashboard"; console.log(currency, defaultSender, indexName, adminPath); EOF cat > /tmp/fallow-section-repro/.gitlab/CODEOWNERS << 'EOF' [billing] @core-reviewers @alice @bob src/billing/ [notifications] @core-reviewers @alice @bob src/notifications/ [search] @core-reviewers @charlie @dave src/search/ [admin] @core-reviewers @eve src/admin/ EOF cd /tmp/fallow-section-repro fallow dead-code --group-by owner --format jsonResult: 1 group (
@core-reviewers, 6 issues) instead of 4 groups.Desired result — 4 groups, one per section:
{ "schema_version": 4, "grouped_by": "section", "total_issues": 6, "groups": [ { "key": "billing", "owners": ["@core-reviewers", "@alice", "@bob"], "total_issues": 2, "unused_exports": [ { "path": "src/billing/invoice.ts", "export_name": "unusedGenerateReceipt", "line": 2 }, { "path": "src/billing/invoice.ts", "export_name": "unusedApplyDiscount", "line": 3 } ] }, { "key": "notifications", "owners": ["@core-reviewers", "@alice", "@bob"], "total_issues": 1, "unused_exports": [ { "path": "src/notifications/email.ts", "export_name": "unusedSendSMS", "line": 2 } ] }, { "key": "search", "owners": ["@core-reviewers", "@charlie", "@dave"], "total_issues": 2, "unused_exports": [ { "path": "src/search/indexer.ts", "export_name": "unusedReindex", "line": 2 }, { "path": "src/search/indexer.ts", "export_name": "unusedPurgeIndex", "line": 3 } ] }, { "key": "admin", "owners": ["@core-reviewers", "@eve"], "total_issues": 1, "unused_exports": [ { "path": "src/admin/dashboard.ts", "export_name": "unusedExportCSV", "line": 2 } ] } ] }Note:
billingandnotificationshave the same owners but are separate groups — the section name is the key, owners are metadata.Proposed solution
Group by section name (
[SectionName]from GitLab CODEOWNERS headers). Section names are better grouping keys than owners because they're stable (don't change when reviewers rotate), produce a manageable number of groups, and represent intentional ownership boundaries.parse_section_header()already computes the name but doesn't return it. Storing it alongside each rule would make it available for grouping. The API design (--group-by section, enhancement to--group-by owner, etc.) is your call.Edge cases
(no section)group^[optional]sections:^only affects approval, not groupingAlternatives considered
--group-by directory: Breaks when sections span multiple directory trees--group-by rule: Loses human-readable section names, doesn't handle multi-pattern sections