codeowners-audit
Advanced tools
| 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) { |
+164
-0
@@ -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 |
+112
-22
@@ -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, | ||
| } |
+24
-14
@@ -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 @@ |
+268
-51
@@ -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 @@ } |
+58
-5
@@ -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[], |
+1
-1
| { | ||
| "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 |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
213681
28.38%22
4.76%4139
32.62%272
15.74%2
-33.33%