Skip to content

Commit 4fc6f5c

Browse files
committed
feat(cli): handle oversized CODEOWNERS files
GitHub ignores active CODEOWNERS files larger than 3 MB, which can make audits report ownership that GitHub would skip. Surface the warning in report output and add a dedicated fail flag so CI can enforce the parity check directly. Made-with: Cursor
1 parent b4982ba commit 4fc6f5c

File tree

9 files changed

+304
-4
lines changed

9 files changed

+304
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ In interactive mode, `--no-report` implies `--list-unowned` so output still stay
8686
| `--no-report` | Skip HTML report generation (implies `--list-unowned`) |
8787
| `--list-unowned` | Print unowned file paths to stdout |
8888
| `--fail-on-unowned` | Exit non-zero when one or more files are unowned |
89+
| `--fail-on-oversized-codeowners` | Exit non-zero when the active `CODEOWNERS` file exceeds GitHub's 3 MB limit |
8990
| `--fail-on-missing-paths` | Exit non-zero when one or more CODEOWNERS paths match no repository files |
9091
| `--validate-github-owners` | Validate `@username` and `@org/team` owners against GitHub and use only validated owners for coverage |
9192
| `--fail-on-invalid-owners` | Exit non-zero when one or more CODEOWNERS rules contain invalid GitHub owners |

lib/audit.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { validateGithubOwners } from './github-owner-validation.js'
1616

1717
const SUPPORTED_CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']
1818
const SUPPORTED_CODEOWNERS_PATHS_LABEL = SUPPORTED_CODEOWNERS_PATHS.join(', ')
19+
const GITHUB_CODEOWNERS_MAX_BYTES = 3 * 1024 * 1024
1920

2021
/**
2122
* Run a complete CODEOWNERS audit on a repository.
@@ -57,6 +58,7 @@ async function audit (repoRoot, options) {
5758

5859
const codeownersDescriptor = loadCodeownersDescriptor(repoRoot, codeownersPath)
5960
const discoveryWarnings = collectCodeownersDiscoveryWarnings(discoveredCodeownersPaths, codeownersPath)
61+
const oversizedCodeownersWarnings = collectOversizedCodeownersWarnings(codeownersDescriptor)
6062
/** @type {import('./types.js').InvalidOwnerWarning[]} */
6163
let invalidOwnerWarnings = []
6264
/** @type {string[]} */
@@ -107,6 +109,8 @@ async function audit (repoRoot, options) {
107109
report.codeownersValidationMeta = {
108110
discoveryWarnings,
109111
discoveryWarningCount: discoveryWarnings.length,
112+
oversizedCodeownersWarnings,
113+
oversizedCodeownersWarningCount: oversizedCodeownersWarnings.length,
110114
missingPathWarnings,
111115
missingPathWarningCount: missingPathWarnings.length,
112116
invalidOwnerWarnings,
@@ -265,15 +269,38 @@ function buildMissingSupportedCodeownersError (discoveredCodeownersPaths) {
265269
* @returns {import('./types.js').CodeownersDescriptor}
266270
*/
267271
function loadCodeownersDescriptor (repoRoot, codeownersPath) {
268-
const fileContent = readFileSync(path.join(repoRoot, codeownersPath), 'utf8')
269-
const rules = parseCodeowners(fileContent)
272+
const fileBuffer = readFileSync(path.join(repoRoot, codeownersPath))
273+
const sizeBytes = fileBuffer.byteLength
274+
const oversized = sizeBytes > GITHUB_CODEOWNERS_MAX_BYTES
275+
const rules = oversized ? [] : parseCodeowners(fileBuffer.toString('utf8'))
270276

271277
return {
272278
path: codeownersPath,
273279
rules,
280+
sizeBytes,
281+
sizeLimitBytes: GITHUB_CODEOWNERS_MAX_BYTES,
282+
oversized,
274283
}
275284
}
276285

286+
/**
287+
* Build warnings for an active CODEOWNERS file that GitHub ignores due to size.
288+
* @param {import('./types.js').CodeownersDescriptor} codeownersDescriptor
289+
* @returns {import('./types.js').OversizedCodeownersWarning[]}
290+
*/
291+
function collectOversizedCodeownersWarnings (codeownersDescriptor) {
292+
if (!codeownersDescriptor.oversized) {
293+
return []
294+
}
295+
296+
return [{
297+
codeownersPath: codeownersDescriptor.path,
298+
sizeBytes: codeownersDescriptor.sizeBytes,
299+
sizeLimitBytes: codeownersDescriptor.sizeLimitBytes,
300+
message: `${codeownersDescriptor.path} is ignored because it is ${codeownersDescriptor.sizeBytes} bytes and exceeds GitHub's 3 MB CODEOWNERS limit of ${codeownersDescriptor.sizeLimitBytes} bytes.`,
301+
}]
302+
}
303+
277304
/**
278305
* Build missing-path warnings for CODEOWNERS rules that match no repository files.
279306
* @param {import('./types.js').CodeownersDescriptor} codeownersDescriptor
@@ -560,6 +587,7 @@ export {
560587
collectCodeownersDiscoveryWarnings,
561588
collectCodeownersPatternDiffChangeSet,
562589
collectCodeownersPatternHistory,
590+
collectOversizedCodeownersWarnings,
563591
collectMissingDirectorySlashWarnings,
564592
collectMissingCodeownersPathWarnings,
565593
collectRepoDirectories,

lib/cli-args.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const OPTIONS_CONFIG = {
1919
'no-report': { type: 'boolean', default: false },
2020
'list-unowned': { type: 'boolean', default: false },
2121
'fail-on-unowned': { type: 'boolean', default: false },
22+
'fail-on-oversized-codeowners': { type: 'boolean', default: false },
2223
'fail-on-missing-paths': { type: 'boolean', default: false },
2324
'validate-github-owners': { type: 'boolean', default: false },
2425
'fail-on-invalid-owners': { type: 'boolean', default: false },
@@ -52,6 +53,7 @@ const OPTIONS_CONFIG = {
5253
* noReport: boolean,
5354
* listUnowned: boolean,
5455
* failOnUnowned: boolean,
56+
* failOnOversizedCodeowners: boolean,
5557
* failOnMissingPaths: boolean,
5658
* validateGithubOwners: boolean,
5759
* failOnInvalidOwners: boolean,
@@ -206,6 +208,7 @@ export function parseArgs (args) {
206208
noReport: /** @type {boolean} */ (values['no-report']),
207209
listUnowned: /** @type {boolean} */ (values['list-unowned']),
208210
failOnUnowned: /** @type {boolean} */ (values['fail-on-unowned']),
211+
failOnOversizedCodeowners: /** @type {boolean} */ (values['fail-on-oversized-codeowners']),
209212
failOnMissingPaths: /** @type {boolean} */ (values['fail-on-missing-paths']),
210213
validateGithubOwners: /** @type {boolean} */ (values['validate-github-owners']),
211214
failOnInvalidOwners: /** @type {boolean} */ (values['fail-on-invalid-owners']),
@@ -256,6 +259,7 @@ function helpResult (args) {
256259
noReport: false,
257260
listUnowned: false,
258261
failOnUnowned: false,
262+
failOnOversizedCodeowners: false,
259263
failOnMissingPaths: false,
260264
validateGithubOwners: false,
261265
failOnInvalidOwners: false,
@@ -292,6 +296,7 @@ export function printUsage () {
292296
['--no-report', 'Skip HTML report generation (implies --list-unowned)'],
293297
['--list-unowned', 'Print unowned file paths to stdout'],
294298
['--fail-on-unowned', 'Exit non-zero when one or more files are unowned'],
299+
['--fail-on-oversized-codeowners', 'Exit non-zero when the active CODEOWNERS file exceeds GitHub\'s 3 MB limit'],
295300
['--fail-on-missing-paths', 'Exit non-zero when CODEOWNERS paths match no files'],
296301
['--validate-github-owners', 'Validate @username and @org/team owners against GitHub and use that for coverage'],
297302
['--fail-on-invalid-owners', 'Exit non-zero when CODEOWNERS rules contain invalid GitHub owners'],

lib/cli-output.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ export function formatMissingPathWarningForCli (warning, useColor) {
7474
return bullet + warningPath + ownerLabel + ownerText
7575
}
7676

77+
/**
78+
* Format an oversized CODEOWNERS warning for CLI output.
79+
* @param {import('./types.js').OversizedCodeownersWarning} warning
80+
* @param {boolean} useColor
81+
* @returns {string}
82+
*/
83+
export function formatOversizedCodeownersWarningForCli (warning, useColor) {
84+
const bullet = colorizeCliText('- ', [ANSI_DIM], useColor)
85+
const warningPath = colorizeCliText(warning.codeownersPath, [ANSI_YELLOW], useColor)
86+
const detail = colorizeCliText(
87+
` is ignored because it is ${formatByteCount(warning.sizeBytes)} and exceeds GitHub's 3 MB limit (${formatByteCount(warning.sizeLimitBytes)}).`,
88+
[ANSI_DIM],
89+
useColor
90+
)
91+
return bullet + warningPath + detail
92+
}
93+
7794
/**
7895
* Format an invalid GitHub owner warning for CLI output.
7996
* @param {import('./types.js').InvalidOwnerWarning} warning
@@ -138,6 +155,15 @@ export function formatCodeownersOwnersList (owners) {
138155
return owners.join(', ')
139156
}
140157

158+
/**
159+
* Format a raw byte count for human-readable CLI output.
160+
* @param {number} value
161+
* @returns {string}
162+
*/
163+
export function formatByteCount (value) {
164+
return `${new Intl.NumberFormat('en-US').format(value)} bytes`
165+
}
166+
141167
/**
142168
* Format invalid owner reasons for human-readable output.
143169
* @param {import('./types.js').InvalidOwnerEntry[]|undefined} invalidOwners
@@ -158,6 +184,7 @@ export function formatInvalidOwnerReasons (invalidOwners) {
158184
* unownedFiles: string[],
159185
* codeownersValidationMeta?: {
160186
* discoveryWarnings?: import('./types.js').DiscoveryWarning[],
187+
* oversizedCodeownersWarnings?: import('./types.js').OversizedCodeownersWarning[],
161188
* missingPathWarnings?: import('./types.js').MissingPathWarning[],
162189
* invalidOwnerWarnings?: import('./types.js').InvalidOwnerWarning[],
163190
* ownerValidationWarnings?: string[],
@@ -169,6 +196,7 @@ export function formatInvalidOwnerReasons (invalidOwners) {
169196
* noReport: boolean,
170197
* listUnowned: boolean,
171198
* failOnUnowned: boolean,
199+
* failOnOversizedCodeowners: boolean,
172200
* failOnMissingPaths: boolean,
173201
* failOnInvalidOwners: boolean,
174202
* failOnMissingDirectorySlashes: boolean,
@@ -190,6 +218,10 @@ export function outputUnownedReportResults (report, options) {
190218
? report.codeownersValidationMeta.discoveryWarnings
191219
: []
192220
const locationWarningCount = discoveryWarnings.length
221+
const oversizedCodeownersWarnings = Array.isArray(report.codeownersValidationMeta?.oversizedCodeownersWarnings)
222+
? report.codeownersValidationMeta.oversizedCodeownersWarnings
223+
: []
224+
const oversizedCodeownersWarningCount = oversizedCodeownersWarnings.length
193225
const missingPathWarnings = Array.isArray(report.codeownersValidationMeta?.missingPathWarnings)
194226
? report.codeownersValidationMeta.missingPathWarnings
195227
: []
@@ -238,6 +270,20 @@ export function outputUnownedReportResults (report, options) {
238270
console.error('')
239271
}
240272

273+
if (options.noReport && oversizedCodeownersWarningCount > 0) {
274+
console.error(
275+
colorizeCliText(
276+
`Oversized CODEOWNERS files (${oversizedCodeownersWarningCount}):`,
277+
[ANSI_BOLD, ANSI_YELLOW],
278+
colorStderr
279+
)
280+
)
281+
for (const warning of oversizedCodeownersWarnings) {
282+
console.error('%s', formatOversizedCodeownersWarningForCli(warning, colorStderr))
283+
}
284+
console.error('')
285+
}
286+
241287
if (options.noReport && invalidOwnerWarningCount > 0) {
242288
console.error(
243289
colorizeCliText(
@@ -318,6 +364,7 @@ export function outputUnownedReportResults (report, options) {
318364
: []),
319365
`${colorizeCliText('analyzed files:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(report.totals.files), [ANSI_BOLD], colorStdout)}`,
320366
`${colorizeCliText('unknown files:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(report.totals.unowned), report.totals.unowned > 0 ? [ANSI_BOLD, ANSI_RED] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
367+
`${colorizeCliText('oversized CODEOWNERS warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(oversizedCodeownersWarningCount), oversizedCodeownersWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
321368
`${colorizeCliText('missing path warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(missingPathWarningCount), missingPathWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
322369
`${colorizeCliText('invalid owner warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(invalidOwnerWarningCount), invalidOwnerWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
323370
`${colorizeCliText('owner validation warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(ownerValidationWarningCount), ownerValidationWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
@@ -338,6 +385,10 @@ export function outputUnownedReportResults (report, options) {
338385
process.exitCode = EXIT_CODE_UNCOVERED
339386
}
340387

388+
if (options.failOnOversizedCodeowners && oversizedCodeownersWarningCount > 0) {
389+
process.exitCode = EXIT_CODE_UNCOVERED
390+
}
391+
341392
if (options.failOnMissingPaths && missingPathWarningCount > 0) {
342393
process.exitCode = EXIT_CODE_UNCOVERED
343394
}

lib/report-builder.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export function buildReport (repoRoot, files, codeownersDescriptor, options, pro
114114
codeownersValidationMeta: {
115115
discoveryWarnings: [],
116116
discoveryWarningCount: 0,
117+
oversizedCodeownersWarnings: [],
118+
oversizedCodeownersWarningCount: 0,
117119
missingPathWarnings: [],
118120
missingPathWarningCount: 0,
119121
invalidOwnerWarnings: [],

lib/report.template.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,11 @@ <h2>Active CODEOWNERS File</h2>
508508
</thead>
509509
<tbody id="codeowners-table-body"></tbody>
510510
</table>
511+
<div id="oversized-codeowners-warnings" class="validation-warnings" hidden>
512+
<h3 id="oversized-codeowners-warnings-heading">Oversized CODEOWNERS Files</h3>
513+
<p class="warning-text" style="margin: 4px 0 0; font-size: 13px;">GitHub ignores `CODEOWNERS` files larger than 3 MB, so coverage from these files should not be trusted.</p>
514+
<ul id="oversized-codeowners-warnings-list"></ul>
515+
</div>
511516
<div id="missing-path-warnings" class="validation-warnings" hidden>
512517
<h3 id="missing-path-warnings-heading">Patterns With No Matching Repository Paths</h3>
513518
<ul id="missing-path-warnings-list"></ul>
@@ -560,6 +565,8 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
560565
const codeownersValidationMeta = report.codeownersValidationMeta || {
561566
discoveryWarnings: [],
562567
discoveryWarningCount: 0,
568+
oversizedCodeownersWarnings: [],
569+
oversizedCodeownersWarningCount: 0,
563570
missingPathWarnings: [],
564571
missingPathWarningCount: 0,
565572
invalidOwnerWarnings: [],
@@ -620,6 +627,7 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
620627
document.getElementById('coverage-unowned').style.width = clamp(100 - report.totals.coverage) + '%'
621628

622629
renderCodeownersFiles(report.codeownersFiles)
630+
renderOversizedCodeownersWarnings(codeownersValidationMeta)
623631
renderMissingPathWarnings(codeownersValidationMeta)
624632
renderInvalidOwnerWarnings(codeownersValidationMeta)
625633
renderOwnerValidationWarnings(codeownersValidationMeta)
@@ -657,6 +665,42 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
657665
}
658666
}
659667

668+
function renderOversizedCodeownersWarnings (validationMeta) {
669+
const container = document.getElementById('oversized-codeowners-warnings')
670+
const heading = document.getElementById('oversized-codeowners-warnings-heading')
671+
const list = document.getElementById('oversized-codeowners-warnings-list')
672+
const warningRows = Array.isArray(validationMeta && validationMeta.oversizedCodeownersWarnings)
673+
? validationMeta.oversizedCodeownersWarnings
674+
: []
675+
676+
list.innerHTML = ''
677+
if (warningRows.length === 0) {
678+
container.hidden = true
679+
return
680+
}
681+
682+
const warningCount = Number(validationMeta && validationMeta.oversizedCodeownersWarningCount) || warningRows.length
683+
heading.textContent = 'Oversized CODEOWNERS Files (' + fmt.format(warningCount) + ')'
684+
685+
for (const warning of warningRows) {
686+
const item = document.createElement('li')
687+
const pathSpan = document.createElement('span')
688+
pathSpan.className = 'warning-path'
689+
pathSpan.textContent = warning.codeownersPath
690+
item.appendChild(pathSpan)
691+
692+
const textSpan = document.createElement('span')
693+
textSpan.className = 'warning-text'
694+
textSpan.textContent = ' is ignored because it is ' + formatByteCount(warning.sizeBytes) +
695+
' and exceeds GitHub\'s 3 MB limit (' + formatByteCount(warning.sizeLimitBytes) + ').'
696+
item.appendChild(textSpan)
697+
698+
list.appendChild(item)
699+
}
700+
701+
container.hidden = false
702+
}
703+
660704
function renderMissingPathWarnings (validationMeta) {
661705
const container = document.getElementById('missing-path-warnings')
662706
const heading = document.getElementById('missing-path-warnings-heading')
@@ -911,6 +955,10 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverag
911955
return 'just now'
912956
}
913957

958+
function formatByteCount (value) {
959+
return fmt.format(value) + ' bytes'
960+
}
961+
914962
function renderCodeownersDiscoveryWarnings (validationMeta) {
915963
const container = document.getElementById('codeowners-discovery-warnings')
916964
const heading = document.getElementById('codeowners-discovery-warnings-heading')

lib/types.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
* A CODEOWNERS file descriptor with its path and parsed rules.
2222
* @typedef {{
2323
* path: string,
24-
* rules: CodeownersRule[]
24+
* rules: CodeownersRule[],
25+
* sizeBytes: number,
26+
* sizeLimitBytes: number,
27+
* oversized: boolean
2528
* }} CodeownersDescriptor
2629
*/
2730

@@ -64,6 +67,17 @@
6467
* }} MissingDirectorySlashWarning
6568
*/
6669

70+
/**
71+
* Warning about an active CODEOWNERS file that GitHub ignores because it
72+
* exceeds the documented 3 MB size limit.
73+
* @typedef {{
74+
* codeownersPath: string,
75+
* sizeBytes: number,
76+
* sizeLimitBytes: number,
77+
* message: string
78+
* }} OversizedCodeownersWarning
79+
*/
80+
6781
/**
6882
* A single invalid GitHub owner entry attached to a CODEOWNERS rule.
6983
* @typedef {{
@@ -99,6 +113,8 @@
99113
* @typedef {{
100114
* discoveryWarnings: DiscoveryWarning[],
101115
* discoveryWarningCount: number,
116+
* oversizedCodeownersWarnings: OversizedCodeownersWarning[],
117+
* oversizedCodeownersWarningCount: number,
102118
* missingPathWarnings: MissingPathWarning[],
103119
* missingPathWarningCount: number,
104120
* invalidOwnerWarnings: InvalidOwnerWarning[],

test/cli-args.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ test('parseArgs: returns defaults with no arguments', () => {
1717
assert.equal(result.noReport, false)
1818
assert.equal(result.listUnowned, false)
1919
assert.equal(result.failOnUnowned, false)
20+
assert.equal(result.failOnOversizedCodeowners, false)
2021
assert.equal(result.failOnMissingPaths, false)
2122
assert.equal(result.validateGithubOwners, false)
2223
assert.equal(result.failOnInvalidOwners, false)
@@ -64,6 +65,10 @@ test('parseArgs: --fail-on-unowned', () => {
6465
assert.equal(parseArgs(['--fail-on-unowned']).failOnUnowned, true)
6566
})
6667

68+
test('parseArgs: --fail-on-oversized-codeowners', () => {
69+
assert.equal(parseArgs(['--fail-on-oversized-codeowners']).failOnOversizedCodeowners, true)
70+
})
71+
6772
test('parseArgs: --fail-on-missing-paths', () => {
6873
assert.equal(parseArgs(['--fail-on-missing-paths']).failOnMissingPaths, true)
6974
})

0 commit comments

Comments
 (0)