New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details
Socket
Book a DemoSign in
Socket

codeowners-audit

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

codeowners-audit - npm Package Compare versions

Comparing version
2.8.0
to
2.9.0
+598
lib/github-owner-validation.js
import { fetchGithubApiJson, fetchGithubApiPaginatedArray, requestGithubApi } from './github-api.js'
import { runGitCommand as defaultRunGitCommand } from './git.js'
import { resolveGithubRepoIdentity, resolveGithubToken } from './github-identity.js'
const USER_WRITE_PERMISSIONS = new Set(['admin', 'write'])
const TEAM_WRITE_PERMISSIONS = new Set(['admin', 'maintain', 'push', 'write'])
/**
* Validate GitHub-style CODEOWNERS entries and attach effective owners to rules.
* Non-GitHub owner forms such as email addresses are preserved unchanged.
* @param {string} repoRoot
* @param {import('./types.js').CodeownersDescriptor} codeownersDescriptor
* @param {{ githubToken?: string, githubApiBaseUrl: string, progress?: (message: string, ...values: any[]) => void }} options
* @param {{ runGitCommand?: (args: string[], cwd?: string) => string }} [context]
* @returns {Promise<{
* invalidOwnerWarnings: import('./types.js').InvalidOwnerWarning[],
* warnings: string[]
* }>}
*/
export async function validateGithubOwners (repoRoot, codeownersDescriptor, options, context = {}) {
const progress = typeof options.progress === 'function' ? options.progress : () => {}
const runGitCommand = typeof context.runGitCommand === 'function' ? context.runGitCommand : defaultRunGitCommand
const tokenResolution = resolveGithubToken(options.githubToken)
if (!tokenResolution.token) {
throw new Error(
'GitHub owner validation requires a GitHub token. ' +
'Provide --github-token or set GITHUB_TOKEN (or GH_TOKEN).'
)
}
const repoIdentity = resolveGithubRepoIdentity(repoRoot, runGitCommand)
if (!repoIdentity) {
throw new Error(
'GitHub owner validation requires a repository origin remote so owner/name can be inferred.'
)
}
const apiContext = {
token: tokenResolution.token,
baseUrl: options.githubApiBaseUrl,
}
const githubOwners = collectUniqueGithubOwners(codeownersDescriptor.rules)
progress('GitHub owner validation: validating %d CODEOWNERS owner token(s)...', githubOwners.length)
/** @type {Map<string, OwnerValidationResult>} */
const validationByOwner = new Map()
/** @type {Promise<Map<string, string>>|undefined} */
let repoTeamsBySlugPromise
const getRepoTeamsBySlug = async () => {
if (!repoTeamsBySlugPromise) {
repoTeamsBySlugPromise = fetchRepoTeamsBySlug(apiContext, repoIdentity)
}
return await repoTeamsBySlugPromise
}
/** @type {Set<string>} */
const warnings = new Set()
for (const owner of githubOwners) {
const validation = await validateGithubOwnerToken(owner, apiContext, repoIdentity, getRepoTeamsBySlug)
validationByOwner.set(owner.toLowerCase(), validation)
if (validation.warning) {
warnings.add(validation.warning)
}
}
/** @type {import('./types.js').InvalidOwnerWarning[]} */
const invalidOwnerWarnings = []
for (const rule of codeownersDescriptor.rules) {
/** @type {string[]} */
const effectiveOwners = []
/** @type {import('./types.js').InvalidOwnerEntry[]} */
const invalidOwners = []
for (const owner of rule.owners) {
const normalizedOwner = normalizeOwnerToken(owner)
if (!normalizedOwner || !normalizedOwner.startsWith('@')) {
effectiveOwners.push(owner)
continue
}
const validation = validationByOwner.get(normalizedOwner.toLowerCase())
if (!validation || validation.valid) {
effectiveOwners.push(owner)
continue
}
invalidOwners.push({
owner: normalizedOwner,
ownerType: validation.ownerType,
reason: validation.reason,
})
}
rule.effectiveOwners = effectiveOwners
if (invalidOwners.length > 0) {
invalidOwnerWarnings.push({
codeownersPath: codeownersDescriptor.path,
pattern: rule.pattern,
owners: rule.owners.slice(),
effectiveOwners: effectiveOwners.slice(),
invalidOwners,
})
}
}
invalidOwnerWarnings.sort((first, second) => {
const byPath = first.codeownersPath.localeCompare(second.codeownersPath)
if (byPath !== 0) return byPath
return first.pattern.localeCompare(second.pattern)
})
progress(
'GitHub owner validation complete: %d invalid owner warning(s).',
invalidOwnerWarnings.length
)
return {
invalidOwnerWarnings,
warnings: Array.from(warnings.values()).sort((first, second) => first.localeCompare(second)),
}
}
/**
* @typedef {{
* owner: string,
* ownerType: 'user'|'team'|'github-owner',
* valid: boolean,
* reason: string,
* warning?: string
* }} OwnerValidationResult
*/
/**
* Collect unique `@...` owner tokens in insertion order.
* @param {import('./types.js').CodeownersRule[]} rules
* @returns {string[]}
*/
function collectUniqueGithubOwners (rules) {
/** @type {Map<string, string>} */
const owners = new Map()
for (const rule of rules) {
for (const owner of rule.owners) {
const normalized = normalizeOwnerToken(owner)
if (!normalized || !normalized.startsWith('@')) continue
owners.set(normalized.toLowerCase(), normalized)
}
}
return Array.from(owners.values())
}
/**
* Normalize a raw owner token.
* @param {unknown} owner
* @returns {string}
*/
function normalizeOwnerToken (owner) {
return typeof owner === 'string' ? owner.trim() : ''
}
/**
* Validate a GitHub owner token.
* @param {string} owner
* @param {{ token: string, baseUrl: string }} apiContext
* @param {{ owner: string, repo: string }} repoIdentity
* @param {() => Promise<Map<string, string>>} getRepoTeamsBySlug
* @returns {Promise<OwnerValidationResult>}
*/
async function validateGithubOwnerToken (owner, apiContext, repoIdentity, getRepoTeamsBySlug) {
const parsedOwner = parseGithubOwner(owner)
if (!parsedOwner.valid) {
return {
owner,
ownerType: 'github-owner',
valid: false,
reason: 'is not a valid GitHub owner token; use @username or @org/team.',
}
}
if (parsedOwner.ownerType === 'user') {
return await validateUserOwner(parsedOwner, apiContext, repoIdentity)
}
return await validateTeamOwner(parsedOwner, apiContext, repoIdentity, getRepoTeamsBySlug)
}
/**
* Parse a GitHub owner token into user/team components.
* @param {string} owner
* @returns {{
* valid: true,
* ownerType: 'user',
* owner: string,
* login: string
* }|{
* valid: true,
* ownerType: 'team',
* owner: string,
* org: string,
* slug: string
* }|{
* valid: false
* }}
*/
function parseGithubOwner (owner) {
const normalized = normalizeOwnerToken(owner)
if (!normalized.startsWith('@')) return { valid: false }
const body = normalized.slice(1)
if (!body) return { valid: false }
const parts = body.split('/')
if (parts.some(part => !part)) return { valid: false }
if (parts.length === 1) {
return {
valid: true,
ownerType: 'user',
owner: normalized,
login: parts[0],
}
}
if (parts.length === 2) {
return {
valid: true,
ownerType: 'team',
owner: normalized,
org: parts[0],
slug: parts[1],
}
}
return { valid: false }
}
/**
* Validate an `@username` owner.
* @param {{ owner: string, login: string }} parsedOwner
* @param {{ token: string, baseUrl: string }} apiContext
* @param {{ owner: string, repo: string }} repoIdentity
* @returns {Promise<OwnerValidationResult>}
*/
async function validateUserOwner (parsedOwner, apiContext, repoIdentity) {
try {
const permissionInfo = await fetchGithubApiJson(
apiContext,
`/repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.repo)}` +
`/collaborators/${encodeURIComponent(parsedOwner.login)}/permission`
)
if (USER_WRITE_PERMISSIONS.has(String(permissionInfo && permissionInfo.permission))) {
return {
owner: parsedOwner.owner,
ownerType: 'user',
valid: true,
reason: '',
}
}
return {
owner: parsedOwner.owner,
ownerType: 'user',
valid: false,
reason: 'does not have write access to the repository.',
}
} catch (error) {
const status = getStatusCode(error)
if (status !== 404 && status !== 401 && status !== 403) throw error
const userExists = await getGithubUserExistence(apiContext, parsedOwner.login)
if (status === 401 || status === 403) {
if (userExists === 'missing') {
return {
owner: parsedOwner.owner,
ownerType: 'user',
valid: false,
reason: 'was not found on GitHub.',
}
}
return {
owner: parsedOwner.owner,
ownerType: 'user',
valid: true,
reason: '',
warning: buildUserAccessWarning(parsedOwner.owner, status),
}
}
return {
owner: parsedOwner.owner,
ownerType: 'user',
valid: false,
reason: userExists === 'exists'
? 'does not have write access to the repository.'
: 'was not found on GitHub.',
}
}
}
/**
* Validate an `@org/team` owner.
* @param {{ owner: string, org: string, slug: string }} parsedOwner
* @param {{ token: string, baseUrl: string }} apiContext
* @param {{ owner: string, repo: string }} repoIdentity
* @param {() => Promise<Map<string, string>>} getRepoTeamsBySlug
* @returns {Promise<OwnerValidationResult>}
*/
async function validateTeamOwner (parsedOwner, apiContext, repoIdentity, getRepoTeamsBySlug) {
/** @type {number} */
let repoTeamLookupStatus = 0
if (parsedOwner.org.toLowerCase() === repoIdentity.owner.toLowerCase()) {
try {
const repoTeamsBySlug = await getRepoTeamsBySlug()
const repoPermission = repoTeamsBySlug.get(parsedOwner.slug.toLowerCase())
if (repoPermission) {
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: TEAM_WRITE_PERMISSIONS.has(repoPermission),
reason: TEAM_WRITE_PERMISSIONS.has(repoPermission)
? ''
: 'does not have write access to the repository.',
}
}
} catch (error) {
repoTeamLookupStatus = getStatusCode(error)
if (repoTeamLookupStatus !== 401 && repoTeamLookupStatus !== 403) throw error
}
}
const directRepoAccess = await getGithubTeamRepositoryAccess(apiContext, parsedOwner, repoIdentity)
if (directRepoAccess.status === 'permission') {
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: TEAM_WRITE_PERMISSIONS.has(directRepoAccess.permission),
reason: TEAM_WRITE_PERMISSIONS.has(directRepoAccess.permission)
? ''
: 'does not have write access to the repository.',
}
}
const teamExists = await getGithubTeamExistence(apiContext, parsedOwner.org, parsedOwner.slug)
if (repoTeamLookupStatus === 401 || repoTeamLookupStatus === 403) {
if (teamExists === 'missing') {
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: false,
reason: 'was not found on GitHub or is not visible.',
}
}
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: true,
reason: '',
warning: buildTeamAccessWarning(parsedOwner.owner, repoTeamLookupStatus),
}
}
if (directRepoAccess.status === 'unknown-access') {
if (teamExists === 'missing') {
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: false,
reason: 'was not found on GitHub or is not visible.',
}
}
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: true,
reason: '',
warning: buildTeamInconclusiveAccessWarning(parsedOwner.owner),
}
}
if (directRepoAccess.status === 'unknown-permission') {
if (teamExists === 'missing') {
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: false,
reason: 'was not found on GitHub or is not visible.',
}
}
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: true,
reason: '',
warning: buildTeamPermissionUnknownWarning(parsedOwner.owner),
}
}
return {
owner: parsedOwner.owner,
ownerType: 'team',
valid: false,
reason: teamExists === 'exists'
? 'does not have write access to the repository.'
: 'was not found on GitHub or is not visible.',
}
}
/**
* Fetch repository teams keyed by slug.
* @param {{ token: string, baseUrl: string }} apiContext
* @param {{ owner: string, repo: string }} repoIdentity
* @returns {Promise<Map<string, string>>}
*/
async function fetchRepoTeamsBySlug (apiContext, repoIdentity) {
const teams = await fetchGithubApiPaginatedArray(
apiContext,
`/repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.repo)}/teams`
)
/** @type {Map<string, string>} */
const teamsBySlug = new Map()
for (const team of teams) {
if (!team || typeof team.slug !== 'string') continue
const permission = normalizeTeamPermission(team.permission)
teamsBySlug.set(team.slug.toLowerCase(), permission)
}
return teamsBySlug
}
/**
* @param {{ token: string, baseUrl: string }} apiContext
* @param {string} username
* @returns {Promise<'exists'|'missing'|'unknown'>}
*/
async function getGithubUserExistence (apiContext, username) {
try {
await fetchGithubApiJson(apiContext, `/users/${encodeURIComponent(username)}`)
return 'exists'
} catch (error) {
const status = getStatusCode(error)
if (status === 404) return 'missing'
if (status === 401 || status === 403) return 'unknown'
throw error
}
}
/**
* @param {{ token: string, baseUrl: string }} apiContext
* @param {string} org
* @param {string} teamSlug
* @returns {Promise<'exists'|'missing'|'unknown'>}
*/
async function getGithubTeamExistence (apiContext, org, teamSlug) {
try {
await fetchGithubApiJson(
apiContext,
`/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`
)
return 'exists'
} catch (error) {
const status = getStatusCode(error)
if (status === 404) return 'missing'
if (status === 401 || status === 403) return 'unknown'
throw error
}
}
/**
* Query GitHub for a team's repository access using the direct team-repository endpoint.
* @param {{ token: string, baseUrl: string }} apiContext
* @param {{ org: string, slug: string }} parsedOwner
* @param {{ owner: string, repo: string }} repoIdentity
* @returns {Promise<
* { status: 'permission', permission: string } |
* { status: 'unknown-access' } |
* { status: 'unknown-permission' }
* >}
*/
async function getGithubTeamRepositoryAccess (apiContext, parsedOwner, repoIdentity) {
try {
const response = await requestGithubApi(
apiContext,
`/orgs/${encodeURIComponent(parsedOwner.org)}/teams/${encodeURIComponent(parsedOwner.slug)}` +
`/repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.repo)}`
)
const permission = extractTeamRepositoryPermission(response.body)
if (permission) {
return {
status: 'permission',
permission,
}
}
return { status: 'unknown-permission' }
} catch (error) {
const status = getStatusCode(error)
if (status === 401 || status === 403 || status === 404) {
return { status: 'unknown-access' }
}
throw error
}
}
/**
* Extract an effective permission string from a direct team-repository response.
* @param {any} body
* @returns {string}
*/
function extractTeamRepositoryPermission (body) {
if (!body || typeof body !== 'object') return ''
if (typeof body.permission === 'string') {
return String(body.permission).toLowerCase()
}
if (!body.permissions || typeof body.permissions !== 'object') {
return ''
}
if (body.permissions.admin) return 'admin'
if (body.permissions.maintain) return 'maintain'
if (body.permissions.push || body.permissions.write) return 'push'
if (body.permissions.triage) return 'triage'
if (body.permissions.pull || body.permissions.read) return 'pull'
return ''
}
/**
* Build a warning when repo-level user access checks are unavailable.
* @param {string} owner
* @param {number} status
* @returns {string}
*/
function buildUserAccessWarning (owner, status) {
return (
`Could not verify repository write access for ${owner} because the current GitHub token ` +
`could not access collaborator permission checks (HTTP ${status}). ` +
'Only GitHub account existence was checked, so this owner was preserved.'
)
}
/**
* Build a warning when repo-level team access checks are unavailable.
* @param {string} owner
* @param {number} status
* @returns {string}
*/
function buildTeamAccessWarning (owner, status) {
return (
`Could not verify repository write access for ${owner} because the current GitHub token ` +
`could not access repository team checks (HTTP ${status}). ` +
'Only team existence was checked when possible, so this owner was preserved.'
)
}
/**
* Build a warning when GitHub does not expose enough team access information.
* @param {string} owner
* @returns {string}
*/
function buildTeamInconclusiveAccessWarning (owner) {
return (
`Could not conclusively verify repository access for ${owner} because GitHub did not expose ` +
'team access through the repository team APIs for the current token or team visibility. ' +
'The team exists, so this owner was preserved.'
)
}
/**
* Build a warning when GitHub confirms repository access but omits permission details.
* @param {string} owner
* @returns {string}
*/
function buildTeamPermissionUnknownWarning (owner) {
return (
`GitHub confirmed repository access for ${owner} without returning enough permission detail ` +
'to distinguish pull from write access. This owner was preserved.'
)
}
/**
* Normalize a repository team permission string.
* @param {unknown} permission
* @returns {string}
*/
function normalizeTeamPermission (permission) {
return typeof permission === 'string' ? permission.toLowerCase() : 'none'
}
/**
* Extract an HTTP-ish status code from an error object.
* @param {unknown} error
* @returns {number}
*/
function getStatusCode (error) {
if (!error || typeof error !== 'object' || !('status' in error)) return 0
return Number(error.status) || 0
}
+126
-2

@@ -10,2 +10,3 @@ import { readFileSync } from 'node:fs'

import { runGitCommand, toPosixPath, formatCommandError } from './git.js'
import { directoryAncestors } from './paths.js'
import { buildReport } from './report-builder.js'

@@ -15,5 +16,7 @@ import { renderHtml } from './report-renderer.js'

import { collectDirectoryTeamSuggestions } from './team-suggestions.js'
import { validateGithubOwners } from './github-owner-validation.js'
const SUPPORTED_CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']
const SUPPORTED_CODEOWNERS_PATHS_LABEL = SUPPORTED_CODEOWNERS_PATHS.join(', ')
const GITHUB_CODEOWNERS_MAX_BYTES = 3 * 1024 * 1024

@@ -34,2 +37,3 @@ /**

* noReport?: boolean,
* validateGithubOwners?: boolean,
* teamSuggestions?: boolean,

@@ -60,3 +64,19 @@ * verbose?: boolean,

const discoveryWarnings = collectCodeownersDiscoveryWarnings(discoveredCodeownersPaths, codeownersPath)
const oversizedCodeownersWarnings = collectOversizedCodeownersWarnings(codeownersDescriptor)
/** @type {import('./types.js').InvalidOwnerWarning[]} */
let invalidOwnerWarnings = []
/** @type {string[]} */
let ownerValidationWarnings = []
let missingPathWarnings = collectMissingCodeownersPathWarnings(codeownersDescriptor, allRepoFiles)
const missingDirectorySlashWarnings = collectMissingDirectorySlashWarnings(
codeownersDescriptor,
allRepoFiles
)
if (options.validateGithubOwners) {
const ownerValidation = await validateGithubOwners(repoRoot, codeownersDescriptor, options, {
runGitCommand,
})
invalidOwnerWarnings = ownerValidation.invalidOwnerWarnings
ownerValidationWarnings = ownerValidation.warnings
}
if (!options.noReport && missingPathWarnings.length > 0) {

@@ -96,4 +116,12 @@ let historyReady = false

discoveryWarningCount: discoveryWarnings.length,
oversizedCodeownersWarnings,
oversizedCodeownersWarningCount: oversizedCodeownersWarnings.length,
missingPathWarnings,
missingPathWarningCount: missingPathWarnings.length,
invalidOwnerWarnings,
invalidOwnerWarningCount: invalidOwnerWarnings.length,
ownerValidationWarnings,
ownerValidationWarningCount: ownerValidationWarnings.length,
missingDirectorySlashWarnings,
missingDirectorySlashWarningCount: missingDirectorySlashWarnings.length,
unprotectedDirectoryWarnings,

@@ -249,4 +277,6 @@ unprotectedDirectoryWarningCount: unprotectedDirectoryWarnings.length,

function loadCodeownersDescriptor (repoRoot, codeownersPath) {
const fileContent = readFileSync(path.join(repoRoot, codeownersPath), 'utf8')
const rules = parseCodeowners(fileContent)
const fileBuffer = readFileSync(path.join(repoRoot, codeownersPath))
const sizeBytes = fileBuffer.byteLength
const oversized = sizeBytes > GITHUB_CODEOWNERS_MAX_BYTES
const rules = oversized ? [] : parseCodeowners(fileBuffer.toString('utf8'))

@@ -256,2 +286,5 @@ return {

rules,
sizeBytes,
sizeLimitBytes: GITHUB_CODEOWNERS_MAX_BYTES,
oversized,
}

@@ -261,2 +294,20 @@ }

/**
* Build warnings for an active CODEOWNERS file that GitHub ignores due to size.
* @param {import('./types.js').CodeownersDescriptor} codeownersDescriptor
* @returns {import('./types.js').OversizedCodeownersWarning[]}
*/
function collectOversizedCodeownersWarnings (codeownersDescriptor) {
if (!codeownersDescriptor.oversized) {
return []
}
return [{
codeownersPath: codeownersDescriptor.path,
sizeBytes: codeownersDescriptor.sizeBytes,
sizeLimitBytes: codeownersDescriptor.sizeLimitBytes,
message: `${codeownersDescriptor.path} is ignored because it is ${codeownersDescriptor.sizeBytes} bytes and exceeds GitHub's 3 MB CODEOWNERS limit of ${codeownersDescriptor.sizeLimitBytes} bytes.`,
}]
}
/**
* Build missing-path warnings for CODEOWNERS rules that match no repository files.

@@ -298,2 +349,72 @@ * @param {import('./types.js').CodeownersDescriptor} codeownersDescriptor

/**
* Build warnings for directory patterns that omit a trailing slash.
* Only slashless, non-glob patterns that resolve to at least one repository
* directory are considered directory ownership entries.
* @param {import('./types.js').CodeownersDescriptor} codeownersDescriptor
* @param {string[]} repoFiles
* @returns {import('./types.js').MissingDirectorySlashWarning[]}
*/
function collectMissingDirectorySlashWarnings (codeownersDescriptor, repoFiles) {
/** @type {import('./types.js').MissingDirectorySlashWarning[]} */
const warnings = []
const repoDirectories = collectRepoDirectories(repoFiles)
for (const rule of codeownersDescriptor.rules) {
if (!isSlashlessDirectoryPattern(rule.pattern)) continue
const matchesDirectory = repoDirectories.some(rule.matches)
if (!matchesDirectory) continue
warnings.push({
codeownersPath: codeownersDescriptor.path,
pattern: rule.pattern,
suggestedPattern: `${rule.pattern}/`,
owners: rule.owners,
})
}
warnings.sort((first, second) => {
const byPath = first.codeownersPath.localeCompare(second.codeownersPath)
if (byPath !== 0) return byPath
return first.pattern.localeCompare(second.pattern)
})
return warnings
}
/**
* Collect all repository directory paths from tracked file paths.
* @param {string[]} repoFiles
* @returns {string[]}
*/
function collectRepoDirectories (repoFiles) {
/** @type {Set<string>} */
const directories = new Set()
for (const filePath of repoFiles) {
for (const directory of directoryAncestors(filePath)) {
directories.add(directory)
}
}
return Array.from(directories)
}
/**
* Determine whether a CODEOWNERS pattern is a slashless, non-glob candidate for
* directory ownership.
* @param {string} pattern
* @returns {boolean}
*/
function isSlashlessDirectoryPattern (pattern) {
if (pattern.endsWith('/')) return false
if (pattern.includes('*') || pattern.includes('?')) return false
const normalized = pattern.replace(/^\/+/, '').replace(/\/+$/, '')
if (!normalized) return false
const exactMatcher = createPatternMatcher(pattern, { includeDescendants: false })
return exactMatcher(normalized)
}
/**
* Replay CODEOWNERS file history to determine when each current pattern first

@@ -478,3 +599,6 @@ * appeared in its current continuous lifetime.

collectCodeownersPatternHistory,
collectOversizedCodeownersWarnings,
collectMissingDirectorySlashWarnings,
collectMissingCodeownersPathWarnings,
collectRepoDirectories,
collectUnprotectedDirectoryWarnings,

@@ -481,0 +605,0 @@ createGlobMatcher,

+108
-33

@@ -22,3 +22,7 @@ import { tmpdir } from 'node:os'

'fail-on-unowned': { type: 'boolean', default: false },
'fail-on-oversized-codeowners': { type: 'boolean', default: false },
'fail-on-missing-paths': { type: 'boolean', default: false },
'validate-github-owners': { type: 'boolean', default: false },
'fail-on-invalid-owners': { type: 'boolean', default: false },
'fail-on-missing-directory-slashes': { type: 'boolean', default: false },
'fail-on-location-warnings': { type: 'boolean', default: false },

@@ -53,3 +57,7 @@ 'fail-on-fragile-coverage': { type: 'boolean', default: false },

* failOnUnowned: boolean,
* failOnOversizedCodeowners: boolean,
* failOnMissingPaths: boolean,
* validateGithubOwners: boolean,
* failOnInvalidOwners: boolean,
* failOnMissingDirectorySlashes: boolean,
* failOnLocationWarnings: boolean,

@@ -205,3 +213,7 @@ * failOnFragileCoverage: boolean,

failOnUnowned: /** @type {boolean} */ (values['fail-on-unowned']),
failOnOversizedCodeowners: /** @type {boolean} */ (values['fail-on-oversized-codeowners']),
failOnMissingPaths: /** @type {boolean} */ (values['fail-on-missing-paths']),
validateGithubOwners: /** @type {boolean} */ (values['validate-github-owners']),
failOnInvalidOwners: /** @type {boolean} */ (values['fail-on-invalid-owners']),
failOnMissingDirectorySlashes: /** @type {boolean} */ (values['fail-on-missing-directory-slashes']),
failOnLocationWarnings: /** @type {boolean} */ (values['fail-on-location-warnings']),

@@ -253,3 +265,7 @@ failOnFragileCoverage: /** @type {boolean} */ (values['fail-on-fragile-coverage']),

failOnUnowned: false,
failOnOversizedCodeowners: false,
failOnMissingPaths: false,
validateGithubOwners: false,
failOnInvalidOwners: false,
failOnMissingDirectorySlashes: false,
failOnLocationWarnings: false,

@@ -279,29 +295,68 @@ failOnFragileCoverage: false,

/** @type {Array<[string, string]>} */
const optionRows = [
['-o, --output <path>', 'Output HTML file path'],
['--output-dir <dir>', 'Output directory for the generated HTML report'],
['--cwd <dir>', 'Run git commands from this directory'],
['--include-untracked', 'Include untracked files in the analysis'],
['--no-report', 'Skip HTML report generation (implies --list-unowned)'],
['--list-unowned', 'Print unowned file paths to stdout'],
['--fail-on-unowned', 'Exit non-zero when one or more files are unowned'],
['--fail-on-missing-paths', 'Exit non-zero when CODEOWNERS paths match no files'],
['--fail-on-location-warnings', 'Exit non-zero when extra or ignored CODEOWNERS files are found'],
['--fail-on-fragile-coverage', 'Exit non-zero when directories have fragile file-by-file coverage'],
['-g, --glob <pattern>', 'Repeatable file filter for report/check scope (default: **)'],
['--suggest-teams', 'Suggest @org/team for uncovered directories'],
['--suggest-window-days <days>', `Git history lookback window for suggestions (default: ${TEAM_SUGGESTIONS_DEFAULT_WINDOW_DAYS})`],
['--suggest-top <n>', `Top team suggestions to keep per directory (default: ${TEAM_SUGGESTIONS_DEFAULT_TOP})`],
['--suggest-ignore-teams <list>', 'Comma-separated team slugs or @org/slug entries to exclude from suggestions'],
['--github-org <org>', 'Override GitHub org for team lookups'],
['--github-token <token>', 'GitHub token for team lookups (falls back to GITHUB_TOKEN, then GH_TOKEN)'],
['--github-api-base-url <url>', `GitHub API base URL (default: ${GITHUB_API_BASE_URL})`],
['--upload', `Upload to ${UPLOAD_PROVIDER} and print a public URL`],
['-y, --yes', 'Automatically answer yes to interactive prompts'],
['--no-open', 'Do not prompt to open the report in your browser'],
['--verbose', 'Enable verbose progress output'],
['-h, --help', 'Show this help'],
['-v, --version', 'Show version'],
const argumentRows = [
['[repo-or-path]', 'Repository URL, GitHub shorthand (owner/repo), or path to a local directory (default: cwd)'],
]
/** @type {Array<{ heading: string, rows: Array<[string, string]> }>} */
const optionGroups = [
{
heading: 'Input and scope:',
rows: [
['--cwd <dir>', 'Run git commands from this directory'],
['--include-untracked', 'Include untracked files in the analysis'],
['-g, --glob <pattern>', 'Repeatable file filter for report and check scope (default: **)'],
],
},
{
heading: 'Report output:',
rows: [
['-o, --output <path>', 'Output HTML file path'],
['--output-dir <dir>', 'Output directory for the generated HTML report'],
['--no-report', 'Skip HTML report generation (implies --list-unowned)'],
['--upload', `Upload to ${UPLOAD_PROVIDER} and print a public URL`],
],
},
{
heading: 'Interaction and diagnostics:',
rows: [
['--no-open', 'Do not prompt to open the report in your browser'],
['-y, --yes', 'Automatically answer yes to interactive prompts'],
['--verbose', 'Enable verbose progress output'],
['-h, --help', 'Show this help'],
['-v, --version', 'Show version'],
],
},
{
heading: 'Core coverage checks:',
rows: [
['--list-unowned', 'Print unowned file paths to stdout'],
['--fail-on-unowned', 'Exit non-zero when one or more files are unowned'],
],
},
{
heading: 'GitHub validation and policy checks:',
rows: [
['--fail-on-oversized-codeowners', 'Exit non-zero when the active CODEOWNERS file exceeds GitHub\'s 3 MB limit'],
['--fail-on-missing-paths', 'Exit non-zero when CODEOWNERS paths match no files'],
['--validate-github-owners', 'Validate @username and @org/team owners against GitHub and use that for coverage'],
['--fail-on-invalid-owners', 'Exit non-zero when CODEOWNERS rules contain invalid GitHub owners'],
['--fail-on-missing-directory-slashes', 'Exit non-zero when directory CODEOWNERS paths omit a trailing slash'],
['--fail-on-location-warnings', 'Exit non-zero when extra or ignored CODEOWNERS files are found'],
['--fail-on-fragile-coverage', 'Exit non-zero when directories have fragile file-by-file coverage'],
],
},
{
heading: 'Team suggestions:',
rows: [
['--suggest-teams', 'Suggest @org/team for uncovered directories'],
['--suggest-window-days <days>', `Git history lookback window for suggestions (default: ${TEAM_SUGGESTIONS_DEFAULT_WINDOW_DAYS})`],
['--suggest-top <n>', `Top team suggestions to keep per directory (default: ${TEAM_SUGGESTIONS_DEFAULT_TOP})`],
['--suggest-ignore-teams <list>', 'Comma-separated team slugs or @org/slug entries to exclude from suggestions'],
['--github-org <org>', 'Override GitHub org for team lookups'],
['--github-token <token>', 'GitHub token for team lookups (falls back to GITHUB_TOKEN, then GH_TOKEN)'],
['--github-api-base-url <url>', `GitHub API base URL (default: ${GITHUB_API_BASE_URL})`],
],
},
]
console.log(

@@ -312,6 +367,6 @@ [

'Arguments:',
' [repo-or-path] Repository URL, GitHub shorthand (owner/repo), or path to a local directory (default: cwd)',
...formatUsageRows(argumentRows),
'',
'Options:',
...formatUsageOptions(optionRows),
...formatUsageGroups(optionGroups),
].join('\n')

@@ -322,14 +377,34 @@ )

/**
* Render CLI options into aligned help text rows.
* @param {Array<[string, string]>} optionRows
* Render grouped CLI options into aligned help text rows.
* @param {Array<{ heading: string, rows: Array<[string, string]> }>} optionGroups
* @returns {string[]}
*/
function formatUsageOptions (optionRows) {
function formatUsageGroups (optionGroups) {
const lines = []
for (let index = 0; index < optionGroups.length; index++) {
const group = optionGroups[index]
if (index > 0) {
lines.push('')
}
lines.push(` ${group.heading}`)
lines.push(...formatUsageRows(group.rows))
}
return lines
}
/**
* Render CLI rows into aligned help text.
* @param {Array<[string, string]>} rows
* @returns {string[]}
*/
function formatUsageRows (rows) {
const leftPadding = ' '
const descriptionColumn = 28
const descriptionColumn = 35
const descriptionPaddingWidth = descriptionColumn - 1
const lines = []
for (const [option, description] of optionRows) {
const optionLine = leftPadding + option
for (const [label, description] of rows) {
const optionLine = leftPadding + label

@@ -336,0 +411,0 @@ if (optionLine.length >= descriptionPaddingWidth) {

@@ -78,2 +78,52 @@ const EXIT_CODE_UNCOVERED = 1

/**
* Format an oversized CODEOWNERS warning for CLI output.
* @param {import('./types.js').OversizedCodeownersWarning} warning
* @param {boolean} useColor
* @returns {string}
*/
export function formatOversizedCodeownersWarningForCli (warning, useColor) {
const bullet = colorizeCliText('- ', [ANSI_DIM], useColor)
const warningPath = colorizeCliText(warning.codeownersPath, [ANSI_YELLOW], useColor)
const detail = colorizeCliText(
` is ignored because it is ${formatByteCount(warning.sizeBytes)} and exceeds GitHub's 3 MB limit (${formatByteCount(warning.sizeLimitBytes)}).`,
[ANSI_DIM],
useColor
)
return bullet + warningPath + detail
}
/**
* Format an invalid GitHub owner warning for CLI output.
* @param {import('./types.js').InvalidOwnerWarning} warning
* @param {boolean} useColor
* @returns {string}
*/
export function formatInvalidOwnerWarningForCli (warning, useColor) {
const bullet = colorizeCliText('- ', [ANSI_DIM], useColor)
const warningPath = colorizeCliText(warning.pattern, [ANSI_YELLOW], useColor)
const ownerLabel = colorizeCliText(' owners: ', [ANSI_DIM], useColor)
const ownerText = colorizeCliText(formatCodeownersOwnersList(warning.owners), [ANSI_CYAN], useColor)
const invalidLabel = colorizeCliText(' invalid: ', [ANSI_DIM], useColor)
const invalidText = colorizeCliText(formatInvalidOwnerReasons(warning.invalidOwners), [ANSI_YELLOW], useColor)
return bullet + warningPath + ownerLabel + ownerText + invalidLabel + invalidText
}
/**
* Format a missing directory slash warning for CLI output.
* @param {import('./types.js').MissingDirectorySlashWarning} warning
* @param {boolean} useColor
* @returns {string}
*/
export function formatMissingDirectorySlashWarningForCli (warning, useColor) {
const bullet = colorizeCliText('- ', [ANSI_DIM], useColor)
const warningPath = colorizeCliText(warning.pattern, [ANSI_YELLOW], useColor)
const detailLabel = colorizeCliText(' should end with "/" as ', [ANSI_DIM], useColor)
const suggestedPattern = colorizeCliText(warning.suggestedPattern, [ANSI_CYAN], useColor)
const ownerLabel = colorizeCliText(' owners: ', [ANSI_DIM], useColor)
const ownerList = formatCodeownersOwnersList(warning.owners)
const ownerText = colorizeCliText(ownerList, [ANSI_CYAN], useColor)
return bullet + warningPath + detailLabel + suggestedPattern + ownerLabel + ownerText
}
/**
* Format an unprotected directory warning for CLI output.

@@ -110,2 +160,21 @@ * @param {import('./types.js').UnprotectedDirectoryWarning} warning

/**
* Format a raw byte count for human-readable CLI output.
* @param {number} value
* @returns {string}
*/
export function formatByteCount (value) {
return `${new Intl.NumberFormat('en-US').format(value)} bytes`
}
/**
* Format invalid owner reasons for human-readable output.
* @param {import('./types.js').InvalidOwnerEntry[]|undefined} invalidOwners
* @returns {string}
*/
export function formatInvalidOwnerReasons (invalidOwners) {
if (!Array.isArray(invalidOwners) || invalidOwners.length === 0) return '(none)'
return invalidOwners.map((entry) => `${entry.owner} ${entry.reason}`).join('; ')
}
/**
* Emit CLI results for unowned file reporting and failure gating.

@@ -120,3 +189,7 @@ * Coverage summary is always printed.

* discoveryWarnings?: import('./types.js').DiscoveryWarning[],
* oversizedCodeownersWarnings?: import('./types.js').OversizedCodeownersWarning[],
* missingPathWarnings?: import('./types.js').MissingPathWarning[],
* invalidOwnerWarnings?: import('./types.js').InvalidOwnerWarning[],
* ownerValidationWarnings?: string[],
* missingDirectorySlashWarnings?: import('./types.js').MissingDirectorySlashWarning[],
* unprotectedDirectoryWarnings?: import('./types.js').UnprotectedDirectoryWarning[]

@@ -129,3 +202,6 @@ * }

* failOnUnowned: boolean,
* failOnOversizedCodeowners: boolean,
* failOnMissingPaths: boolean,
* failOnInvalidOwners: boolean,
* failOnMissingDirectorySlashes: boolean,
* failOnLocationWarnings: boolean,

@@ -149,2 +225,6 @@ * failOnFragileCoverage: boolean,

const locationWarningCount = discoveryWarnings.length
const oversizedCodeownersWarnings = Array.isArray(report.codeownersValidationMeta?.oversizedCodeownersWarnings)
? report.codeownersValidationMeta.oversizedCodeownersWarnings
: []
const oversizedCodeownersWarningCount = oversizedCodeownersWarnings.length
const missingPathWarnings = Array.isArray(report.codeownersValidationMeta?.missingPathWarnings)

@@ -154,2 +234,14 @@ ? report.codeownersValidationMeta.missingPathWarnings

const missingPathWarningCount = missingPathWarnings.length
const invalidOwnerWarnings = Array.isArray(report.codeownersValidationMeta?.invalidOwnerWarnings)
? report.codeownersValidationMeta.invalidOwnerWarnings
: []
const invalidOwnerWarningCount = invalidOwnerWarnings.length
const ownerValidationWarnings = Array.isArray(report.codeownersValidationMeta?.ownerValidationWarnings)
? report.codeownersValidationMeta.ownerValidationWarnings
: []
const ownerValidationWarningCount = ownerValidationWarnings.length
const missingDirectorySlashWarnings = Array.isArray(report.codeownersValidationMeta?.missingDirectorySlashWarnings)
? report.codeownersValidationMeta.missingDirectorySlashWarnings
: []
const missingDirectorySlashWarningCount = missingDirectorySlashWarnings.length
const unprotectedDirectoryWarnings = Array.isArray(report.codeownersValidationMeta?.unprotectedDirectoryWarnings)

@@ -187,2 +279,44 @@ ? report.codeownersValidationMeta.unprotectedDirectoryWarnings

if (options.noReport && oversizedCodeownersWarningCount > 0) {
console.error(
colorizeCliText(
`Oversized CODEOWNERS files (${oversizedCodeownersWarningCount}):`,
[ANSI_BOLD, ANSI_YELLOW],
colorStderr
)
)
for (const warning of oversizedCodeownersWarnings) {
console.error('%s', formatOversizedCodeownersWarningForCli(warning, colorStderr))
}
console.error('')
}
if (options.noReport && invalidOwnerWarningCount > 0) {
console.error(
colorizeCliText(
`Invalid GitHub owners (${invalidOwnerWarningCount}):`,
[ANSI_BOLD, ANSI_YELLOW],
colorStderr
)
)
for (const warning of invalidOwnerWarnings) {
console.error('%s', formatInvalidOwnerWarningForCli(warning, colorStderr))
}
console.error('')
}
if (options.noReport && ownerValidationWarningCount > 0) {
console.error(
colorizeCliText(
`GitHub owner validation warnings (${ownerValidationWarningCount}):`,
[ANSI_BOLD, ANSI_YELLOW],
colorStderr
)
)
for (const warning of ownerValidationWarnings) {
console.error('%s', colorizeCliText(`- ${warning}`, [ANSI_DIM], colorStderr))
}
console.error('')
}
if (options.noReport && locationWarningCount > 0) {

@@ -202,2 +336,16 @@ console.error(

if (options.noReport && missingDirectorySlashWarningCount > 0) {
console.error(
colorizeCliText(
`Directory CODEOWNERS paths missing trailing slash (${missingDirectorySlashWarningCount}):`,
[ANSI_BOLD, ANSI_YELLOW],
colorStderr
)
)
for (const warning of missingDirectorySlashWarnings) {
console.error('%s', formatMissingDirectorySlashWarningForCli(warning, colorStderr))
}
console.error('')
}
if (options.noReport && unprotectedDirectoryWarningCount > 0) {

@@ -227,4 +375,8 @@ console.error(

`${colorizeCliText('unknown files:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(report.totals.unowned), report.totals.unowned > 0 ? [ANSI_BOLD, ANSI_RED] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('oversized CODEOWNERS warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(oversizedCodeownersWarningCount), oversizedCodeownersWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('missing path warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(missingPathWarningCount), missingPathWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('invalid owner warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(invalidOwnerWarningCount), invalidOwnerWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('owner validation warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(ownerValidationWarningCount), ownerValidationWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('location warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(locationWarningCount), locationWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('directory slash warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(missingDirectorySlashWarningCount), missingDirectorySlashWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,
`${colorizeCliText('fragile coverage directories:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(unprotectedDirectoryWarningCount), unprotectedDirectoryWarningCount > 0 ? [ANSI_BOLD, ANSI_YELLOW] : [ANSI_BOLD, ANSI_GREEN], colorStdout)}`,

@@ -245,2 +397,6 @@ ].join('\n')

if (options.failOnOversizedCodeowners && oversizedCodeownersWarningCount > 0) {
process.exitCode = EXIT_CODE_UNCOVERED
}
if (options.failOnMissingPaths && missingPathWarningCount > 0) {

@@ -250,2 +406,10 @@ process.exitCode = EXIT_CODE_UNCOVERED

if (options.failOnInvalidOwners && invalidOwnerWarningCount > 0) {
process.exitCode = EXIT_CODE_UNCOVERED
}
if (options.failOnMissingDirectorySlashes && missingDirectorySlashWarningCount > 0) {
process.exitCode = EXIT_CODE_UNCOVERED
}
if (options.failOnLocationWarnings && locationWarningCount > 0) {

@@ -252,0 +416,0 @@ process.exitCode = EXIT_CODE_UNCOVERED

@@ -41,2 +41,50 @@ /**

/**
* Detect whether a pattern segment contains active wildcards.
* Escaped "\*" and "\?" should be treated as literal characters.
* @param {string} pattern
* @returns {boolean}
*/
function hasUnescapedWildcards (pattern) {
let escaped = false
for (const char of pattern) {
if ((char === '*' || char === '?') && !escaped) {
return true
}
escaped = char === '\\' && !escaped
if (char !== '\\') {
escaped = false
}
}
return false
}
/**
* Detect GitHub-invalid CODEOWNERS pattern syntax.
* GitHub documents that escaped leading "#" and bracket expressions do not work.
* @param {string} rawPattern
* @returns {boolean}
*/
function isGithubInvalidCodeownersPattern (rawPattern) {
if (rawPattern.startsWith('\\#')) {
return true
}
let escaped = false
for (const char of rawPattern) {
if (char === '[' && !escaped) {
return true
}
escaped = char === '\\' && !escaped
if (char !== '\\') {
escaped = false
}
}
return false
}
/**
* Escape regex-special characters.

@@ -59,2 +107,13 @@ * @param {string} char

const char = pattern[index]
if (char === '\\') {
const escapedChar = pattern[index + 1]
if (escapedChar === undefined) {
source += escapeRegexChar(char)
} else {
source += escapeRegexChar(escapedChar)
index++
}
continue
}
if (char === '*') {

@@ -97,3 +156,3 @@ if (pattern[index + 1] === '*') {

const lastSegment = pattern.split('/').at(-1) || ''
const lastSegmentHasWildcards = lastSegment.includes('*') || lastSegment.includes('?')
const lastSegmentHasWildcards = hasUnescapedWildcards(lastSegment)
const descendantSuffix = (directoryOnly || (includeDescendants && !lastSegmentHasWildcards)) ? '(?:/.*)?' : ''

@@ -111,25 +170,43 @@ if (anchored) {

* Parse a single CODEOWNERS rule line, ignoring blank lines, comments,
* malformed rows, and unsupported negation patterns.
* and unsupported negation patterns. Pattern-only rows are preserved so a
* later rule can intentionally clear ownership for a path.
* @param {string} line
* @returns {{ pattern: string, owners: string[] }|null}
* @returns {{ rawPattern: string, pattern: string, owners: string[] }|null}
*/
function parseCodeownersRuleLine (line) {
function parseCodeownersRuleParts (line) {
const withoutComment = stripInlineComment(line).trim()
if (!withoutComment) return null
const tokens = tokenizeCodeownersLine(withoutComment).map(unescapeToken)
if (tokens.length < 2) return null
const rawTokens = tokenizeCodeownersLine(withoutComment)
if (rawTokens.length === 0) return null
const rawPattern = rawTokens[0]
if (isGithubInvalidCodeownersPattern(rawPattern)) return null
const pattern = tokens[0]
const owners = tokens.slice(1).filter(Boolean)
if (!owners.length) return null
const pattern = unescapeToken(rawPattern)
if (pattern.startsWith('!')) return null // Negation is not supported in CODEOWNERS.
const owners = rawTokens.slice(1).map(unescapeToken).filter(Boolean)
return { pattern, owners }
return { rawPattern, pattern, owners }
}
/**
* Parse a single CODEOWNERS rule line, ignoring blank lines, comments,
* and unsupported negation patterns. Pattern-only rows are preserved so a
* later rule can intentionally clear ownership for a path.
* @param {string} line
* @returns {{ pattern: string, owners: string[] }|null}
*/
function parseCodeownersRuleLine (line) {
const parsedRule = parseCodeownersRuleParts(line)
if (!parsedRule) return null
return {
pattern: parsedRule.pattern,
owners: parsedRule.owners,
}
}
/**
* Parse CODEOWNERS content into rule matchers.
* @param {string} fileContent
* @returns {{ pattern: string, owners: string[], matches: (repoPath: string) => boolean }[]}
* @returns {{ pattern: string, owners: string[], matches: (repoPath: string) => boolean, effectiveOwners?: string[] }[]}
*/

@@ -141,3 +218,3 @@ function parseCodeowners (fileContent) {

for (const line of lines) {
const parsedRule = parseCodeownersRuleLine(line)
const parsedRule = parseCodeownersRuleParts(line)
if (!parsedRule) continue

@@ -148,3 +225,3 @@

owners: parsedRule.owners,
matches: createPatternMatcher(parsedRule.pattern, { includeDescendants: true }),
matches: createPatternMatcher(parsedRule.rawPattern, { includeDescendants: true }),
})

@@ -159,16 +236,28 @@ }

* @param {string} repoPath
* @param {{ owners: string[], matches: (repoPath: string) => boolean }[]} rules
* @returns {string[]|undefined}
* @param {{ owners: string[], effectiveOwners?: string[], matches: (repoPath: string) => boolean }[]} rules
* @returns {{ owners: string[], effectiveOwners?: string[], matches: (repoPath: string) => boolean }|undefined}
*/
function findMatchingOwners (repoPath, rules) {
/** @type {string[]|undefined} */
let owners
for (const rule of rules) {
if (rule.matches(repoPath)) {
owners = rule.owners
function findMatchingRule (repoPath, rules) {
/** @type {{ owners: string[], effectiveOwners?: string[], matches: (repoPath: string) => boolean }|undefined} */
let rule
for (const candidate of rules) {
if (candidate.matches(repoPath)) {
rule = candidate
}
}
return owners
return rule
}
/**
* Find the last matching owners in a ruleset.
* @param {string} repoPath
* @param {{ owners: string[], effectiveOwners?: string[], matches: (repoPath: string) => boolean }[]} rules
* @returns {string[]|undefined}
*/
function findMatchingOwners (repoPath, rules) {
const rule = findMatchingRule(repoPath, rules)
if (!rule) return undefined
return Array.isArray(rule.effectiveOwners) ? rule.effectiveOwners : rule.owners
}
export {

@@ -178,3 +267,4 @@ parseCodeowners,

createPatternMatcher,
findMatchingRule,
findMatchingOwners,
}

@@ -28,4 +28,4 @@ import { findMatchingOwners } from './codeowners-parser.js'

const unownedFiles = []
/** @type {Map<string, { team: string, files: string[] }>} */
const teamOwnership = new Map()
/** @type {Map<string, { owner: string, files: string[] }>} */
const ownerIndex = new Map()

@@ -44,7 +44,7 @@ let owned = 0

for (const owner of fileOwners) {
const ownerEntry = teamOwnership.get(owner.toLowerCase())
const ownerEntry = ownerIndex.get(owner.toLowerCase())
if (ownerEntry) {
ownerEntry.files.push(filePath)
} else {
teamOwnership.set(owner.toLowerCase(), { team: owner, files: [filePath] })
ownerIndex.set(owner.toLowerCase(), { owner, files: [filePath] })
}

@@ -87,7 +87,7 @@ }

unownedFiles.sort((first, second) => first.localeCompare(second))
const teamOwnershipRows = Array.from(teamOwnership.values())
const ownerIndexRows = Array.from(ownerIndex.values())
.map((entry) => {
entry.files.sort((first, second) => first.localeCompare(second))
return {
team: entry.team,
owner: entry.owner,
total: entry.files.length,

@@ -99,3 +99,3 @@ files: entry.files,

if (first.total !== second.total) return second.total - first.total
return first.team.localeCompare(second.team)
return first.owner.localeCompare(second.owner)
})

@@ -117,8 +117,16 @@

unownedFiles,
teamOwnership: teamOwnershipRows,
ownerIndex: ownerIndexRows,
codeownersValidationMeta: {
discoveryWarnings: [],
discoveryWarningCount: 0,
oversizedCodeownersWarnings: [],
oversizedCodeownersWarningCount: 0,
missingPathWarnings: [],
missingPathWarningCount: 0,
invalidOwnerWarnings: [],
invalidOwnerWarningCount: 0,
ownerValidationWarnings: [],
ownerValidationWarningCount: 0,
missingDirectorySlashWarnings: [],
missingDirectorySlashWarningCount: 0,
unprotectedDirectoryWarnings: [],

@@ -141,3 +149,4 @@ unprotectedDirectoryWarningCount: 0,

/**
* Collect unique CODEOWNERS owner entries (both @org/team teams and @username individuals).
* Collect unique CODEOWNERS owner entries for the report explorer/index.
* This includes GitHub owners (`@org/team`, `@username`) and email owners.
* @param {string[]|undefined} owners

@@ -152,3 +161,3 @@ * @returns {string[]}

for (const owner of owners) {
if (!looksLikeOwner(owner)) continue
if (!looksLikeIndexedOwner(owner)) continue
const normalized = owner.trim()

@@ -161,10 +170,11 @@ unique.set(normalized.toLowerCase(), normalized)

/**
* Determine whether a CODEOWNERS owner token is a recognized `@`-prefixed owner
* (either an `@org/team` or an `@username`).
* Determine whether a CODEOWNERS owner token should be surfaced in the report
* explorer/index.
* @param {unknown} owner
* @returns {boolean}
*/
function looksLikeOwner (owner) {
function looksLikeIndexedOwner (owner) {
if (typeof owner !== 'string') return false
return /^@[^\s]+$/.test(owner.trim())
const normalized = owner.trim()
return /^@[^\s]+$/.test(normalized) || /^[^\s@]+@[^\s@]+$/.test(normalized)
}

@@ -171,0 +181,0 @@

@@ -191,3 +191,3 @@ <!doctype html>

.suggestion-muted { color: var(--muted); }
.team-controls {
.owner-controls {
display: grid;

@@ -198,3 +198,3 @@ grid-template-columns: minmax(260px, 420px) minmax(280px, 1fr);

}
.team-chip-list {
.owner-chip-list {
display: flex;

@@ -205,3 +205,3 @@ flex-wrap: wrap;

}
.team-chip {
.owner-chip {
border: 1px solid rgba(139, 92, 246, 0.55);

@@ -215,6 +215,6 @@ background: rgba(139, 92, 246, 0.18);

}
.team-chip:hover {
.owner-chip:hover {
background: rgba(139, 92, 246, 0.3);
}
.team-chip.is-active {
.owner-chip.is-active {
border-color: #c4b5fd;

@@ -224,3 +224,3 @@ background: rgba(196, 181, 253, 0.34);

}
.team-empty {
.owner-empty {
border: 1px dashed var(--border);

@@ -362,3 +362,3 @@ border-radius: 10px;

}
#team-file-count {
#owner-file-count {
margin-top: 10px;

@@ -412,3 +412,3 @@ margin-bottom: 6px;

.controls select { min-width: 100%; }
.team-controls {
.owner-controls {
grid-template-columns: 1fr;

@@ -495,10 +495,10 @@ }

</div>
<div class="team-empty muted" id="team-empty-state"></div>
<div class="controls field-controls team-controls">
<select id="team-select" aria-label="Select owner"></select>
<input id="team-file-filter" type="text" placeholder="Filter selected owner's files..." />
<div class="owner-empty muted" id="owner-empty-state"></div>
<div class="controls field-controls owner-controls">
<select id="owner-select" aria-label="Select owner"></select>
<input id="owner-file-filter" type="text" placeholder="Filter selected owner's files..." />
</div>
<div class="team-chip-list" id="team-chip-list"></div>
<div class="file-list" id="team-file-list"></div>
<p class="muted" id="team-file-count"></p>
<div class="owner-chip-list" id="owner-chip-list"></div>
<div class="file-list" id="owner-file-list"></div>
<p class="muted" id="owner-file-count"></p>
</section>

@@ -519,2 +519,7 @@

</table>
<div id="oversized-codeowners-warnings" class="validation-warnings" hidden>
<h3 id="oversized-codeowners-warnings-heading">Oversized CODEOWNERS Files</h3>
<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>
<ul id="oversized-codeowners-warnings-list"></ul>
</div>
<div id="missing-path-warnings" class="validation-warnings" hidden>

@@ -524,2 +529,10 @@ <h3 id="missing-path-warnings-heading">Patterns With No Matching Repository Paths</h3>

</div>
<div id="invalid-owner-warnings" class="validation-warnings" hidden>
<h3 id="invalid-owner-warnings-heading">Invalid GitHub Owners</h3>
<ul id="invalid-owner-warnings-list"></ul>
</div>
<div id="owner-validation-warnings" class="validation-warnings" hidden>
<h3 id="owner-validation-warnings-heading">GitHub Owner Validation Warnings</h3>
<ul id="owner-validation-warnings-list"></ul>
</div>
<div id="codeowners-discovery-warnings" class="validation-warnings" hidden>

@@ -529,2 +542,7 @@ <h3 id="codeowners-discovery-warnings-heading">CODEOWNERS Location Warnings</h3>

</div>
<div id="missing-directory-slash-warnings" class="validation-warnings" hidden>
<h3 id="missing-directory-slash-warnings-heading">Directory Entries Missing Trailing Slash</h3>
<p class="warning-text" style="margin: 4px 0 0; font-size: 13px;">These rules currently work, but adding a trailing slash makes directory ownership explicit and avoids file-vs-directory ambiguity.</p>
<ul id="missing-directory-slash-warnings-list"></ul>
</div>
<div id="unprotected-directory-warnings" class="validation-warnings" hidden>

@@ -555,3 +573,3 @@ <h3 id="unprotected-directory-warnings-heading">Directories With Fragile Coverage</h3>

const directoryRows = report.directories.filter(row => row.path !== '(root)')
const teamOwnershipRows = Array.isArray(report.teamOwnership) ? report.teamOwnership : []
const ownerIndexRows = Array.isArray(report.ownerIndex) ? report.ownerIndex : []
const suggestionRows = Array.isArray(report.directoryTeamSuggestions) ? report.directoryTeamSuggestions : []

@@ -562,4 +580,14 @@ const suggestionMeta = report.directoryTeamSuggestionsMeta || { enabled: false, warnings: [] }

discoveryWarningCount: 0,
oversizedCodeownersWarnings: [],
oversizedCodeownersWarningCount: 0,
missingPathWarnings: [],
missingPathWarningCount: 0,
invalidOwnerWarnings: [],
invalidOwnerWarningCount: 0,
ownerValidationWarnings: [],
ownerValidationWarningCount: 0,
missingDirectorySlashWarnings: [],
missingDirectorySlashWarningCount: 0,
unprotectedDirectoryWarnings: [],
unprotectedDirectoryWarningCount: 0,
}

@@ -583,3 +611,3 @@ const suggestionByPath = new Map(suggestionRows.map((row) => [row.path, row]))

let unownedController
let teamController
let ownerController

@@ -594,3 +622,3 @@ function setScope (nextPath, options) {

if (unownedController) unownedController.render()
if (teamController) teamController.render()
if (ownerController) ownerController.render()

@@ -617,4 +645,8 @@ if (historyMode !== 'none') {

renderCodeownersFiles(report.codeownersFiles)
renderOversizedCodeownersWarnings(codeownersValidationMeta)
renderMissingPathWarnings(codeownersValidationMeta)
renderInvalidOwnerWarnings(codeownersValidationMeta)
renderOwnerValidationWarnings(codeownersValidationMeta)
renderCodeownersDiscoveryWarnings(codeownersValidationMeta)
renderMissingDirectorySlashWarnings(codeownersValidationMeta)
renderUnprotectedDirectoryWarnings(codeownersValidationMeta)

@@ -630,3 +662,3 @@ directoryController = setupDirectoryTable(

unownedController = setupUnownedFiles(report.unownedFiles, () => selectedPath)
teamController = setupTeamOwnershipExplorer(teamOwnershipRows, () => selectedPath)
ownerController = setupOwnerIndexExplorer(ownerIndexRows, () => selectedPath)
syncScopeToLocation(selectedPath, true)

@@ -653,2 +685,38 @@

function renderOversizedCodeownersWarnings (validationMeta) {
const container = document.getElementById('oversized-codeowners-warnings')
const heading = document.getElementById('oversized-codeowners-warnings-heading')
const list = document.getElementById('oversized-codeowners-warnings-list')
const warningRows = Array.isArray(validationMeta && validationMeta.oversizedCodeownersWarnings)
? validationMeta.oversizedCodeownersWarnings
: []
list.innerHTML = ''
if (warningRows.length === 0) {
container.hidden = true
return
}
const warningCount = Number(validationMeta && validationMeta.oversizedCodeownersWarningCount) || warningRows.length
heading.textContent = 'Oversized CODEOWNERS Files (' + fmt.format(warningCount) + ')'
for (const warning of warningRows) {
const item = document.createElement('li')
const pathSpan = document.createElement('span')
pathSpan.className = 'warning-path'
pathSpan.textContent = warning.codeownersPath
item.appendChild(pathSpan)
const textSpan = document.createElement('span')
textSpan.className = 'warning-text'
textSpan.textContent = ' is ignored because it is ' + formatByteCount(warning.sizeBytes) +
' and exceeds GitHub\'s 3 MB limit (' + formatByteCount(warning.sizeLimitBytes) + ').'
item.appendChild(textSpan)
list.appendChild(item)
}
container.hidden = false
}
function renderMissingPathWarnings (validationMeta) {

@@ -695,2 +763,81 @@ const container = document.getElementById('missing-path-warnings')

function renderInvalidOwnerWarnings (validationMeta) {
const container = document.getElementById('invalid-owner-warnings')
const heading = document.getElementById('invalid-owner-warnings-heading')
const list = document.getElementById('invalid-owner-warnings-list')
const warningRows = Array.isArray(validationMeta && validationMeta.invalidOwnerWarnings)
? validationMeta.invalidOwnerWarnings
: []
list.innerHTML = ''
if (warningRows.length === 0) {
container.hidden = true
return
}
const warningCount = Number(validationMeta && validationMeta.invalidOwnerWarningCount) || warningRows.length
heading.textContent = 'Invalid GitHub Owners (' + fmt.format(warningCount) + ')'
for (const warning of warningRows) {
const item = document.createElement('li')
const patternSpan = document.createElement('span')
patternSpan.className = 'warning-path'
patternSpan.textContent = warning.pattern
item.appendChild(patternSpan)
const ownerLabelSpan = document.createElement('span')
ownerLabelSpan.className = 'warning-text'
ownerLabelSpan.textContent = ' owners: '
item.appendChild(ownerLabelSpan)
const ownersSpan = document.createElement('span')
ownersSpan.className = 'warning-owners'
appendCodeownersOwnerList(ownersSpan, warning.owners)
item.appendChild(ownersSpan)
const invalidLabelSpan = document.createElement('span')
invalidLabelSpan.className = 'warning-text'
invalidLabelSpan.textContent = ' invalid: '
item.appendChild(invalidLabelSpan)
const invalidOwnersSpan = document.createElement('span')
invalidOwnersSpan.className = 'warning-owners'
appendInvalidOwnerList(invalidOwnersSpan, warning.invalidOwners)
item.appendChild(invalidOwnersSpan)
list.appendChild(item)
}
container.hidden = false
}
function renderOwnerValidationWarnings (validationMeta) {
const container = document.getElementById('owner-validation-warnings')
const heading = document.getElementById('owner-validation-warnings-heading')
const list = document.getElementById('owner-validation-warnings-list')
const warningRows = Array.isArray(validationMeta && validationMeta.ownerValidationWarnings)
? validationMeta.ownerValidationWarnings
: []
list.innerHTML = ''
if (warningRows.length === 0) {
container.hidden = true
return
}
const warningCount = Number(validationMeta && validationMeta.ownerValidationWarningCount) || warningRows.length
heading.textContent = 'GitHub Owner Validation Warnings (' + fmt.format(warningCount) + ')'
for (const warning of warningRows) {
const item = document.createElement('li')
const textSpan = document.createElement('span')
textSpan.className = 'warning-text'
textSpan.textContent = warning
item.appendChild(textSpan)
list.appendChild(item)
}
container.hidden = false
}
function appendMissingPathWarningHistory (target, warning) {

@@ -728,2 +875,18 @@ const history = warning && typeof warning.history === 'object' ? warning.history : null

function appendInvalidOwnerList (target, invalidOwners) {
target.textContent = ''
if (!Array.isArray(invalidOwners) || invalidOwners.length === 0) {
target.textContent = '(none)'
return
}
invalidOwners.forEach((entry, index) => {
if (index > 0) {
target.appendChild(document.createTextNode('; '))
}
target.appendChild(createCodeownersOwnerNode(entry.owner))
target.appendChild(document.createTextNode(' ' + entry.reason))
})
}
function createCodeownersOwnerNode (owner) {

@@ -815,2 +978,6 @@ const label = typeof owner === 'string' ? owner.trim() : ''

function formatByteCount (value) {
return fmt.format(value) + ' bytes'
}
function renderCodeownersDiscoveryWarnings (validationMeta) {

@@ -867,2 +1034,52 @@ const container = document.getElementById('codeowners-discovery-warnings')

function renderMissingDirectorySlashWarnings (validationMeta) {
const container = document.getElementById('missing-directory-slash-warnings')
const heading = document.getElementById('missing-directory-slash-warnings-heading')
const list = document.getElementById('missing-directory-slash-warnings-list')
const warningRows = Array.isArray(validationMeta && validationMeta.missingDirectorySlashWarnings)
? validationMeta.missingDirectorySlashWarnings
: []
list.innerHTML = ''
if (warningRows.length === 0) {
container.hidden = true
return
}
const warningCount = Number(validationMeta && validationMeta.missingDirectorySlashWarningCount) || warningRows.length
heading.textContent = 'Directory Entries Missing Trailing Slash (' + fmt.format(warningCount) + ')'
for (const warning of warningRows) {
const item = document.createElement('li')
const patternSpan = document.createElement('span')
patternSpan.className = 'warning-path'
patternSpan.textContent = warning.pattern
item.appendChild(patternSpan)
const textSpan = document.createElement('span')
textSpan.className = 'warning-text'
textSpan.textContent = ' should be written as '
item.appendChild(textSpan)
const suggestionSpan = document.createElement('span')
suggestionSpan.className = 'warning-reference'
suggestionSpan.textContent = warning.suggestedPattern
item.appendChild(suggestionSpan)
const ownerLabelSpan = document.createElement('span')
ownerLabelSpan.className = 'warning-text'
ownerLabelSpan.textContent = ' owners: '
item.appendChild(ownerLabelSpan)
const ownersSpan = document.createElement('span')
ownersSpan.className = 'warning-owners'
appendCodeownersOwnerList(ownersSpan, warning.owners)
item.appendChild(ownersSpan)
list.appendChild(item)
}
container.hidden = false
}
function renderUnprotectedDirectoryWarnings (validationMeta) {

@@ -1150,22 +1367,22 @@ const container = document.getElementById('unprotected-directory-warnings')

function setupTeamOwnershipExplorer (rows, getScope) {
const teamSelect = document.getElementById('team-select')
const fileFilter = document.getElementById('team-file-filter')
const chipList = document.getElementById('team-chip-list')
const list = document.getElementById('team-file-list')
const count = document.getElementById('team-file-count')
const emptyState = document.getElementById('team-empty-state')
function setupOwnerIndexExplorer (rows, getScope) {
const ownerSelect = document.getElementById('owner-select')
const fileFilter = document.getElementById('owner-file-filter')
const chipList = document.getElementById('owner-chip-list')
const list = document.getElementById('owner-file-list')
const count = document.getElementById('owner-file-count')
const emptyState = document.getElementById('owner-empty-state')
const safeRows = rows
.filter(row => row && typeof row.team === 'string' && Array.isArray(row.files))
.filter(row => row && typeof row.owner === 'string' && Array.isArray(row.files))
.map((row) => ({
team: row.team,
owner: row.owner,
total: Number(row.total) || row.files.length,
files: row.files,
}))
const rowByTeam = new Map(safeRows.map(row => [row.team, row]))
let selectedTeam = safeRows.length > 0 ? safeRows[0].team : ''
const rowByOwner = new Map(safeRows.map(row => [row.owner, row]))
let selectedOwner = safeRows.length > 0 ? safeRows[0].owner : ''
if (safeRows.length === 0) {
emptyState.textContent = 'No @-prefixed owners found in resolved CODEOWNERS matches. Ownership entries appear when rules use @org/team or @username.'
teamSelect.disabled = true
emptyState.textContent = 'No resolved owners found in CODEOWNERS matches. Ownership entries appear when rules use @org/team, @username, or email owners.'
ownerSelect.disabled = true
fileFilter.disabled = true

@@ -1181,17 +1398,17 @@ chipList.innerHTML = ''

const option = document.createElement('option')
option.value = row.team
option.textContent = row.team + ' \u2014 ' + fmt.format(row.total) + ' files'
teamSelect.appendChild(option)
option.value = row.owner
option.textContent = row.owner + ' \u2014 ' + fmt.format(row.total) + ' files'
ownerSelect.appendChild(option)
}
const quickTeams = safeRows.slice(0, 12)
for (const row of quickTeams) {
const quickOwners = safeRows.slice(0, 12)
for (const row of quickOwners) {
const chip = document.createElement('button')
chip.type = 'button'
chip.className = 'team-chip'
chip.textContent = row.team + ' (' + fmt.format(row.total) + ')'
chip.setAttribute('data-team', row.team)
chip.className = 'owner-chip'
chip.textContent = row.owner + ' (' + fmt.format(row.total) + ')'
chip.setAttribute('data-owner', row.owner)
chip.addEventListener('click', () => {
selectedTeam = row.team
teamSelect.value = selectedTeam
selectedOwner = row.owner
ownerSelect.value = selectedOwner
render()

@@ -1202,4 +1419,4 @@ })

teamSelect.addEventListener('change', () => {
selectedTeam = teamSelect.value
ownerSelect.addEventListener('change', () => {
selectedOwner = ownerSelect.value
render()

@@ -1213,3 +1430,3 @@ })

const query = fileFilter.value.trim().toLowerCase()
const selectedRow = rowByTeam.get(selectedTeam) || safeRows[0]
const selectedRow = rowByOwner.get(selectedOwner) || safeRows[0]
if (!selectedRow) {

@@ -1221,6 +1438,6 @@ list.textContent = '(none)'

if (teamSelect.value !== selectedRow.team) {
teamSelect.value = selectedRow.team
if (ownerSelect.value !== selectedRow.owner) {
ownerSelect.value = selectedRow.owner
}
selectedTeam = selectedRow.team
selectedOwner = selectedRow.owner
const scopedFiles = scope

@@ -1235,8 +1452,8 @@ ? selectedRow.files.filter(file => file === scope || file.startsWith(scope + '/'))

list.textContent = shown.join('\n') || '(none)'
count.textContent = 'Owner: ' + selectedRow.team + ' — scope: ' + (scope || '(root)') +
count.textContent = 'Owner: ' + selectedRow.owner + ' — scope: ' + (scope || '(root)') +
' — showing ' + fmt.format(shown.length) + ' of ' + fmt.format(filteredFiles.length) + ' files.'
const chips = chipList.querySelectorAll('.team-chip')
const chips = chipList.querySelectorAll('.owner-chip')
for (const chip of chips) {
const isActive = chip.getAttribute('data-team') === selectedRow.team
const isActive = chip.getAttribute('data-owner') === selectedRow.owner
chip.classList.toggle('is-active', isActive)

@@ -1243,0 +1460,0 @@ }

@@ -15,2 +15,3 @@ /**

* owners: string[],
* effectiveOwners?: string[],
* matches: (repoPath: string) => boolean

@@ -24,3 +25,6 @@ * }} CodeownersRule

* path: string,
* rules: CodeownersRule[]
* rules: CodeownersRule[],
* sizeBytes: number,
* sizeLimitBytes: number,
* oversized: boolean
* }} CodeownersDescriptor

@@ -59,2 +63,43 @@ */

/**
* Warning about a CODEOWNERS directory pattern that omits the trailing slash.
* @typedef {{
* codeownersPath: string,
* pattern: string,
* suggestedPattern: string,
* owners: string[]
* }} MissingDirectorySlashWarning
*/
/**
* Warning about an active CODEOWNERS file that GitHub ignores because it
* exceeds the documented 3 MB size limit.
* @typedef {{
* codeownersPath: string,
* sizeBytes: number,
* sizeLimitBytes: number,
* message: string
* }} OversizedCodeownersWarning
*/
/**
* A single invalid GitHub owner entry attached to a CODEOWNERS rule.
* @typedef {{
* owner: string,
* ownerType: 'user'|'team'|'github-owner',
* reason: string
* }} InvalidOwnerEntry
*/
/**
* Warning about a CODEOWNERS rule that contains one or more invalid GitHub owners.
* @typedef {{
* codeownersPath: string,
* pattern: string,
* owners: string[],
* effectiveOwners: string[],
* invalidOwners: InvalidOwnerEntry[]
* }} InvalidOwnerWarning
*/
/**
* Warning about a directory where all current files are owned but a

@@ -74,4 +119,12 @@ * hypothetical new file would not be, indicating coverage relies on

* discoveryWarningCount: number,
* oversizedCodeownersWarnings: OversizedCodeownersWarning[],
* oversizedCodeownersWarningCount: number,
* missingPathWarnings: MissingPathWarning[],
* missingPathWarningCount: number,
* invalidOwnerWarnings: InvalidOwnerWarning[],
* invalidOwnerWarningCount: number,
* ownerValidationWarnings: string[],
* ownerValidationWarningCount: number,
* missingDirectorySlashWarnings: MissingDirectorySlashWarning[],
* missingDirectorySlashWarningCount: number,
* unprotectedDirectoryWarnings: UnprotectedDirectoryWarning[],

@@ -104,8 +157,8 @@ * unprotectedDirectoryWarningCount: number

/**
* Ownership summary for a single team.
* Ownership summary for a single resolved CODEOWNERS owner.
* @typedef {{
* team: string,
* owner: string,
* total: number,
* files: string[]
* }} TeamOwnershipRow
* }} OwnerIndexRow
*/

@@ -161,3 +214,3 @@

* unownedFiles: string[],
* teamOwnership: TeamOwnershipRow[],
* ownerIndex: OwnerIndexRow[],
* codeownersValidationMeta: CodeownersValidationMeta,

@@ -164,0 +217,0 @@ * directoryTeamSuggestions: TeamSuggestionRow[],

{
"name": "codeowners-audit",
"version": "2.8.0",
"version": "2.9.0",
"description": "Generate an HTML report for CODEOWNERS ownership gaps and run in CI or from the CLI to fail when files are not covered.",

@@ -5,0 +5,0 @@ "type": "module",

+124
-87

@@ -5,7 +5,7 @@ <p align="center">

<p align="center">Generate a polished, interactive HTML report that shows which files in a Git repository are covered by CODEOWNERS rules and where ownership gaps exist. You can also run it in CI or from the command line to fail when files are not covered by CODEOWNERS.</p>
<p align="center">Generate a polished, interactive HTML report for CODEOWNERS coverage, ownership gaps, and GitHub-parity validation. Run it locally for investigation or in CI to fail when files are uncovered.</p>
<img width="1429" height="681" alt="image" src="https://github.com/user-attachments/assets/abcaddf1-4159-4278-b592-ce96a1235f8e" />
## Playground
## Live Example

@@ -21,21 +21,18 @@ See how ownership coverage looks in practice with [this interactive report](https://watson.github.io/codeowners-audit/example.html) for the `nodejs/node` repository.

- Which specific files have no matching owner rule?
- Which `CODEOWNERS` rules look valid in git, but are ignored or ineffective on GitHub?
`codeowners-audit` scans your repository, applies `CODEOWNERS` matching rules, and produces a single self-contained HTML report you can open locally or upload to a public link.
`codeowners-audit` scans a repository, applies practical `CODEOWNERS` resolution, and produces a single self-contained HTML report you can open locally, archive in CI, or upload to a public link.
## Highlights
- Interactive HTML report with no build step
- Coverage summary: total files, owned, unowned, and percentage
- Directory explorer with filtering, sorting, and drill-down
- Full unowned file list with scope and text filtering
- Ownership explorer with quick chips and file filtering for `@org/team` and `@username` owners
- Matches GitHub `CODEOWNERS` discovery precedence: `.github/`, repository root, then `docs/`
- Detects CODEOWNERS patterns that match no repository paths
- Detects directories with fragile coverage — 100% covered through individual file rules, but new files would lack owners
- Warns when extra or unsupported `CODEOWNERS` files will be ignored by GitHub
- Optional upload to [zenbin.org](https://zenbin.org) for easy sharing
- Self-contained interactive HTML report
- Coverage drill-down by repository, directory, file, and resolved owner
- CI-friendly defaults that can fail builds when files are uncovered
- GitHub-parity validation for ignored `CODEOWNERS` files, missing paths, invalid owners, oversized files, and fragile coverage
- Optional team suggestions for uncovered directories based on git history
- Works on local repositories or remote GitHub repositories with a single command
## Installation
## Quick Start
Run with `npx` (no install):
Run without installing:

@@ -46,9 +43,28 @@ ```bash

Or install globally:
For repeat use in a repository or CI, add it as a dev dependency:
```bash
npm install -g codeowners-audit
codeowners-audit
npm install --save-dev codeowners-audit
```
Common first runs:
Generate a report for the current repository:
```bash
npx codeowners-audit
```
Fail in CI without writing HTML:
```bash
npx codeowners-audit --no-report
```
Audit a remote GitHub repository:
```bash
npx codeowners-audit watson/codeowners-audit
```
## Usage

@@ -60,8 +76,11 @@

The first argument is optional and can be:
The executable name is `codeowners-audit`. If you are running it without installing it as a dependency, prefix commands with `npx`.
- A **remote repository URL** (e.g. `https://github.com/owner/repo`) or **GitHub shorthand** (`owner/repo`) — the repo will be cloned into a temp directory, audited, and the clone removed automatically.
- A **local directory path** (e.g. `~/code/my-repo`) — equivalent to `--cwd`.
- **Omitted** — the current working directory is used.
`[repo-or-path]` is optional and can be:
- A remote repository URL such as `https://github.com/owner/repo`
- GitHub shorthand such as `owner/repo`
- A local directory path such as `~/code/my-repo`
- Omitted, in which case the current working directory is used
By default, the tool:

@@ -73,4 +92,4 @@

When standard input is non-interactive (no TTY - e.g. a CI environemnt), the command automatically behaves as if
`--no-open --list-unowned --fail-on-unowned` were specified:
When standard input is non-interactive (no TTY, for example in CI), the command automatically behaves as if `--no-open --list-unowned --fail-on-unowned` were specified:
- it never prompts to open a browser

@@ -80,20 +99,56 @@ - it prints all unowned file paths to stdout

Use `--output` or `--output-dir` for deterministic artifact paths, or `--no-report` to skip writing HTML entirely.
In interactive mode, `--no-report` implies `--list-unowned` so output still stays useful.
Use `--output` or `--output-dir` for deterministic artifact paths, or `--no-report` to skip writing HTML entirely. In interactive mode, `--no-report` implies `--list-unowned` so the command still produces useful output.
### Options
## Options
### Input and Scope
| Option | Description |
| --- | --- |
| `--cwd <dir>` | Run git commands from this directory |
| `--include-untracked` | Include untracked files in the analysis |
| `-g, --glob <pattern>` | Repeatable file filter for report and check scope (default: `**`) |
### Report Output
| Option | Description |
| --- | --- |
| `-o, --output <path>` | Output HTML file path |
| `--output-dir <dir>` | Output directory for the generated HTML report |
| `--cwd <dir>` | Run git commands from this directory |
| `--include-untracked` | Include untracked files in the analysis |
| `--no-report` | Skip HTML report generation (implies `--list-unowned`) |
| `--upload` | Upload to zenbin and print a public URL |
### Interaction and Diagnostics
| Option | Description |
| --- | --- |
| `--no-open` | Do not prompt to open the report in your browser |
| `-y, --yes` | Automatically answer yes to interactive prompts |
| `--verbose` | Enable verbose progress output |
| `-h, --help` | Show this help |
| `-v, --version` | Show version |
### Core Coverage Checks
| Option | Description |
| --- | --- |
| `--list-unowned` | Print unowned file paths to stdout |
| `--fail-on-unowned` | Exit non-zero when one or more files are unowned |
| `--fail-on-missing-paths` | Exit non-zero when one or more CODEOWNERS paths match no repository files |
### GitHub Validation and Policy Checks
| Option | Description |
| --- | --- |
| `--fail-on-oversized-codeowners` | Exit non-zero when the active `CODEOWNERS` file exceeds GitHub's 3 MB limit |
| `--fail-on-missing-paths` | Exit non-zero when one or more `CODEOWNERS` paths match no repository files |
| `--validate-github-owners` | Validate `@username` and `@org/team` owners against GitHub and use only validated owners for coverage |
| `--fail-on-invalid-owners` | Exit non-zero when one or more `CODEOWNERS` rules contain invalid GitHub owners |
| `--fail-on-missing-directory-slashes` | Exit non-zero when directory `CODEOWNERS` paths do not follow the explicit trailing-slash style |
| `--fail-on-location-warnings` | Exit non-zero when extra or ignored `CODEOWNERS` files are found |
| `--fail-on-fragile-coverage` | Exit non-zero when directories have fragile file-by-file coverage |
| `-g, --glob <pattern>` | Repeatable file filter for report/check scope (default: `**`) |
### Team Suggestions
| Option | Description |
| --- | --- |
| `--suggest-teams` | Suggest `@org/team` for uncovered directories |

@@ -106,12 +161,6 @@ | `--suggest-window-days <days>` | Git history lookback window for suggestions (default: `365`) |

| `--github-api-base-url <url>` | GitHub API base URL (default: `https://api.github.com`) |
| `--upload` | Upload to zenbin and print a public URL |
| `-y, --yes` | Automatically answer yes to interactive prompts |
| `--no-open` | Do not prompt to open the report in your browser |
| `--verbose` | Enable verbose progress output |
| `-h, --help` | Show this help |
| `-v, --version` | Show version |
## Examples
Generate report and open it after pressing Enter:
Generate a report and open it after pressing Enter:

@@ -122,2 +171,14 @@ ```bash

Write a report to a known path without opening a browser:
```bash
codeowners-audit --output codeowners-gaps-report.html --no-open
```
Run against a repository from another directory:
```bash
codeowners-audit ~/code/my-repo
```
Audit a remote GitHub repository:

@@ -129,3 +190,3 @@

Upload report and open the shared URL after pressing Enter:
Upload a report and print the public URL:

@@ -136,12 +197,12 @@ ```bash

Write report to repository:
Validate only a subset of files:
```bash
codeowners-audit --output codeowners-gaps-report.html --no-open
codeowners-audit --glob "src/**/*.js" --glob "test/**/*.js"
```
Run against a repository from another directory:
Suggest teams for uncovered directories:
```bash
codeowners-audit ~/code/my-repo
codeowners-audit --suggest-teams
```

@@ -151,15 +212,16 @@

Most CI systems (including GitHub Actions) run in a non-interactive environment (no TTY on stdin).
In non-interactive environments, `codeowners-audit` automatically:
- disables browser prompts (`--no-open`)
- prints unowned files to stdout (`--list-unowned`)
- exits `1` when unowned files exist (`--fail-on-unowned`)
Most CI systems, including GitHub Actions, run in a non-interactive environment. In that mode, `codeowners-audit` automatically:
- disables browser prompts with `--no-open`
- prints unowned files to stdout with `--list-unowned`
- exits `1` when uncovered files exist with `--fail-on-unowned`
Exit code behavior:
- Exit code `0`: all matched files are covered by `CODEOWNERS`.
- Exit code `1`: one or more matched files are uncovered, `--fail-on-missing-paths` is enabled and one or more CODEOWNERS paths match no repository files, `--fail-on-location-warnings` is enabled and extra or ignored `CODEOWNERS` files are found, or `--fail-on-fragile-coverage` is enabled and directories rely on individual file rules instead of directory-level patterns.
- Exit code `2`: runtime/setup error (for example: not in a Git repository, missing `CODEOWNERS`, invalid arguments).
### Common CI commands
- Exit code `0`: all matched files are covered by `CODEOWNERS`, and no enabled validation policy failed
- Exit code `1`: at least one enforced policy failed, including uncovered files and any enabled `--fail-on-*` validation rule
- Exit code `2`: runtime or setup error, for example not being in a git repository, missing `CODEOWNERS`, or invalid arguments
### Common CI Commands
Validate all tracked files:

@@ -189,3 +251,3 @@

Validate only a subset (for example spec files):
Validate only a subset, for example spec files:

@@ -196,10 +258,4 @@ ```bash

Validate multiple subsets in one run (combined as a union):
### GitHub Actions Example
```bash
codeowners-audit --glob "src/**/*.js" --glob "test/**/*.js"
```
### GitHub Actions example
```yaml

@@ -210,14 +266,10 @@ - name: Verify CODEOWNERS coverage

## How matching works
## GitHub Compatibility Notes
The report follows practical `CODEOWNERS` resolution behavior:
- Follows GitHub `CODEOWNERS` discovery order: `.github/CODEOWNERS`, `CODEOWNERS`, then `docs/CODEOWNERS`
- Uses standard last-match-wins behavior, including ownerless overrides when the last matching rule has no owners
- Resolves patterns from the repository root, regardless of which supported `CODEOWNERS` location is active
- Optionally validates `@username` and `@org/team` owners against GitHub with `--validate-github-owners`
- Reports extra or ignored `CODEOWNERS` files, missing paths, fragile coverage, and unsupported syntax that GitHub would not honor
- A file is considered **owned** if at least one owner is resolved.
- Within a single `CODEOWNERS` file, the **last matching rule wins**.
- GitHub only considers `CODEOWNERS` at `.github/CODEOWNERS`, `CODEOWNERS`, and `docs/CODEOWNERS`, using the first file found in that order.
- Patterns are always resolved from the repository root, regardless of which supported `CODEOWNERS` location is active.
- Extra `CODEOWNERS` files in supported locations and any `CODEOWNERS` files outside those locations are reported as ignored by GitHub.
- Directory rules match descendant files whether they are written as `/path/to/dir` or `/path/to/dir/`.
- `CODEOWNERS` negation patterns (`!pattern`) are ignored.
## Requirements

@@ -227,23 +279,8 @@

## Upload size note
## Upload Size Note
ZenBin currently rejects request payloads around 1 MiB and larger. Very large repositories can produce HTML reports beyond that limit, in which case `--upload` will fail with a clear size error. Use the generated local HTML file directly when this happens.
zenbin currently rejects request payloads around 1 MiB and larger. Very large repositories can produce HTML reports beyond that limit, in which case `--upload` will fail with a clear size error. Use the generated local HTML file directly when this happens.
## Report contents
The generated page includes:
- repository-level ownership metrics and coverage bar
- scoped directory table with coverage bars
- searchable list of unowned files
- ownership explorer for filtering files by `@org/team` or `@username`
- active `CODEOWNERS` file and rule count
- warnings for extra or unsupported `CODEOWNERS` files that GitHub will ignore
- warnings for CODEOWNERS patterns that match no repository paths
- warnings for directories with fragile coverage (owned through individual file rules only)
The report is self-contained, so it can be opened directly from disk or shared after upload.
## License
MIT