codeowners-audit
Advanced tools
+1
-1
| { | ||
| "name": "codeowners-audit", | ||
| "version": "2.3.0", | ||
| "version": "2.4.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", |
+8
-3
@@ -30,4 +30,5 @@ <p align="center"> | ||
| - Team ownership explorer with quick team chips and owned-file filtering | ||
| - Supports multiple `CODEOWNERS` files in nested directories | ||
| - Matches GitHub `CODEOWNERS` discovery precedence: `.github/`, repository root, then `docs/` | ||
| - Detects CODEOWNERS patterns that match no repository paths | ||
| - Warns when extra or unsupported `CODEOWNERS` files will be ignored by GitHub | ||
| - Optional upload to [zenbin.org](https://zenbin.org) for easy sharing | ||
@@ -98,2 +99,3 @@ | ||
| | `--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 | | ||
@@ -200,3 +202,5 @@ | `--verbose` | Enable verbose progress output | | ||
| - Within a single `CODEOWNERS` file, the **last matching rule wins**. | ||
| - If multiple `CODEOWNERS` files exist, they are applied from broader scope to narrower scope (nested files can override broader files). | ||
| - 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/`. | ||
@@ -221,3 +225,4 @@ - `CODEOWNERS` negation patterns (`!pattern`) are ignored. | ||
| - team ownership explorer for filtering files by `@org/team` | ||
| - detected `CODEOWNERS` files and rule counts | ||
| - 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 | ||
@@ -224,0 +229,0 @@ |
+219
-126
@@ -25,2 +25,4 @@ #!/usr/bin/env node | ||
| const FILE_ANALYSIS_PROGRESS_INTERVAL = 20000 | ||
| const SUPPORTED_CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'] | ||
| const SUPPORTED_CODEOWNERS_PATHS_LABEL = SUPPORTED_CODEOWNERS_PATHS.join(', ') | ||
| const EXIT_CODE_UNCOVERED = 1 | ||
@@ -91,3 +93,3 @@ const EXIT_CODE_RUNTIME_ERROR = 2 | ||
| console.log('Full repository clone required for --suggest-teams (this may take longer for large repositories).') | ||
| if (interactiveStdin) { | ||
| if (interactiveStdin && !options.yes) { | ||
| const confirmed = await promptForFullClone(cloneUrl) | ||
@@ -129,12 +131,11 @@ if (!confirmed) { | ||
| const allRepoFiles = listRepoFiles(options.includeUntracked, repoRoot) | ||
| const codeownersFilePaths = allRepoFiles.filter(isCodeownersFile) | ||
| if (codeownersFilePaths.length === 0) { | ||
| throw new Error('No CODEOWNERS files found in this repository.') | ||
| const discoveredCodeownersPaths = listDiscoveredCodeownersPaths(allRepoFiles) | ||
| const codeownersPath = resolveActiveCodeownersPath(discoveredCodeownersPaths) | ||
| if (!codeownersPath) { | ||
| throw new Error(buildMissingSupportedCodeownersError(discoveredCodeownersPaths)) | ||
| } | ||
| const codeownersDescriptors = codeownersFilePaths | ||
| .map(codeownersPath => loadCodeownersDescriptor(repoRoot, codeownersPath)) | ||
| .sort(compareCodeownersDescriptor) | ||
| const missingPathWarnings = collectMissingCodeownersPathWarnings(codeownersDescriptors, allRepoFiles) | ||
| const codeownersDescriptor = loadCodeownersDescriptor(repoRoot, codeownersPath) | ||
| const discoveryWarnings = collectCodeownersDiscoveryWarnings(discoveredCodeownersPaths, codeownersPath) | ||
| const missingPathWarnings = collectMissingCodeownersPathWarnings(codeownersDescriptor, allRepoFiles) | ||
@@ -152,4 +153,6 @@ const scopeFilteredFiles = filterFilesByCliGlobs(allRepoFiles, options.checkGlobs) | ||
| progress('Scanning %d files against CODEOWNERS rules...', filesToAnalyze.length) | ||
| const report = buildReport(repoRoot, filesToAnalyze, codeownersDescriptors, options, progress) | ||
| const report = buildReport(repoRoot, filesToAnalyze, codeownersDescriptor, options, progress) | ||
| report.codeownersValidationMeta = { | ||
| discoveryWarnings, | ||
| discoveryWarningCount: discoveryWarnings.length, | ||
| missingPathWarnings, | ||
@@ -203,3 +206,3 @@ missingPathWarningCount: missingPathWarnings.length, | ||
| if (options.open) { | ||
| const shouldOpen = await promptForReportOpen(reportLocation) | ||
| const shouldOpen = options.yes ? true : await promptForReportOpen(reportLocation) | ||
| if (shouldOpen) { | ||
@@ -254,2 +257,3 @@ try { | ||
| * upload: boolean, | ||
| * yes: boolean, | ||
| * open: boolean, | ||
@@ -287,2 +291,3 @@ * verbose: boolean, | ||
| let upload = false | ||
| let yes = false | ||
| let open = true | ||
@@ -458,2 +463,7 @@ let verbose = false | ||
| if (arg === '--yes' || arg === '-y') { | ||
| yes = true | ||
| continue | ||
| } | ||
| if (arg === '--no-open') { | ||
@@ -561,2 +571,3 @@ open = false | ||
| upload, | ||
| yes, | ||
| open, | ||
@@ -751,2 +762,3 @@ verbose, | ||
| ['--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'], | ||
@@ -935,5 +947,10 @@ ['--verbose', 'Enable verbose progress output'], | ||
| * codeownersValidationMeta?: { | ||
| * discoveryWarnings?: { | ||
| * path: string, | ||
| * type: 'unused-supported-location'|'unsupported-location', | ||
| * referencePath?: string, | ||
| * message: string | ||
| * }[], | ||
| * missingPathWarnings?: { | ||
| * codeownersPath: string, | ||
| * scopedDir: string, | ||
| * pattern: string, | ||
@@ -958,2 +975,6 @@ * owners: string[] | ||
| : JSON.stringify(options.checkGlobs) | ||
| const discoveryWarnings = Array.isArray(report.codeownersValidationMeta?.discoveryWarnings) | ||
| ? report.codeownersValidationMeta.discoveryWarnings | ||
| : [] | ||
| const locationWarningCount = discoveryWarnings.length | ||
| const missingPathWarnings = Array.isArray(report.codeownersValidationMeta?.missingPathWarnings) | ||
@@ -995,2 +1016,16 @@ ? report.codeownersValidationMeta.missingPathWarnings | ||
| if (options.noReport && locationWarningCount > 0) { | ||
| console.error( | ||
| colorizeCliText( | ||
| `CODEOWNERS location warnings (${locationWarningCount}):`, | ||
| [ANSI_BOLD, ANSI_YELLOW], | ||
| colorStderr | ||
| ) | ||
| ) | ||
| for (const warning of discoveryWarnings) { | ||
| console.error('%s', formatCodeownersDiscoveryWarningForCli(warning, colorStderr)) | ||
| } | ||
| console.error('') | ||
| } | ||
| if (options.showCoverageSummary !== false) { | ||
@@ -1004,2 +1039,3 @@ console.log( | ||
| `${colorizeCliText('missing path warnings:', [ANSI_DIM], colorStdout)} ${colorizeCliText(String(missingPathWarningCount), missingPathWarningCount > 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)}`, | ||
| ].join('\n') | ||
@@ -1049,3 +1085,3 @@ ) | ||
| const matchers = patterns.map(pattern => createPatternMatcher(pattern)) | ||
| return (filePath) => matchers.some(matches => matches(filePath, filePath)) | ||
| return (filePath) => matchers.some(matches => matches(filePath)) | ||
| } | ||
@@ -1196,3 +1232,34 @@ | ||
| /** | ||
| * Determine if a path points to a CODEOWNERS file. | ||
| * Format a CODEOWNERS discovery warning for CLI output. | ||
| * @param {{ | ||
| * path: string, | ||
| * type: 'unused-supported-location'|'unsupported-location', | ||
| * referencePath?: string, | ||
| * message: string | ||
| * }} warning | ||
| * @param {boolean} useColor | ||
| * @returns {string} | ||
| */ | ||
| function formatCodeownersDiscoveryWarningForCli (warning, useColor) { | ||
| const bullet = colorizeCliText('- ', [ANSI_DIM], useColor) | ||
| const warningPath = colorizeCliText(warning.path, [ANSI_YELLOW], useColor) | ||
| const warningText = colorizeCliText( | ||
| warning.type === 'unused-supported-location' | ||
| ? ' is unused because GitHub selects ' | ||
| : ' is in an unsupported location and is ignored by GitHub.', | ||
| [ANSI_DIM], | ||
| useColor | ||
| ) | ||
| if (warning.type === 'unused-supported-location' && warning.referencePath) { | ||
| const referencePath = colorizeCliText(warning.referencePath, [ANSI_CYAN], useColor) | ||
| const trailingText = colorizeCliText(' first.', [ANSI_DIM], useColor) | ||
| return bullet + warningPath + warningText + referencePath + trailingText | ||
| } | ||
| return bullet + warningPath + warningText | ||
| } | ||
| /** | ||
| * Determine if a path points to any CODEOWNERS file. | ||
| * @param {string} filePath | ||
@@ -1206,19 +1273,50 @@ * @returns {boolean} | ||
| /** | ||
| * Resolve the scope base for a CODEOWNERS file. | ||
| * GitHub treats top-level CODEOWNERS files in root, .github/, and docs/ | ||
| * as repository-wide files. | ||
| * @param {string} codeownersPath | ||
| * Determine if a path points to a supported GitHub CODEOWNERS location. | ||
| * @param {string} filePath | ||
| * @returns {boolean} | ||
| */ | ||
| function isSupportedCodeownersFile (filePath) { | ||
| return SUPPORTED_CODEOWNERS_PATHS.includes(filePath) | ||
| } | ||
| /** | ||
| * List all discovered CODEOWNERS file paths in the repository. | ||
| * @param {string[]} repoFiles | ||
| * @returns {string[]} | ||
| */ | ||
| function listDiscoveredCodeownersPaths (repoFiles) { | ||
| return repoFiles.filter(isCodeownersFile) | ||
| } | ||
| /** | ||
| * Resolve the active CODEOWNERS file using GitHub's precedence rules. | ||
| * GitHub only considers top-level CODEOWNERS files in `.github/`, the | ||
| * repository root, and `docs/`, using the first file it finds in that order. | ||
| * @param {string[]} discoveredCodeownersPaths | ||
| * @returns {string|undefined} | ||
| */ | ||
| function resolveActiveCodeownersPath (discoveredCodeownersPaths) { | ||
| return SUPPORTED_CODEOWNERS_PATHS.find(codeownersPath => discoveredCodeownersPaths.includes(codeownersPath)) | ||
| } | ||
| /** | ||
| * Build a clear error when no supported CODEOWNERS file is available. | ||
| * @param {string[]} discoveredCodeownersPaths | ||
| * @returns {string} | ||
| */ | ||
| function resolveCodeownersScopeBase (codeownersPath) { | ||
| if ( | ||
| codeownersPath === 'CODEOWNERS' || | ||
| codeownersPath === '.github/CODEOWNERS' || | ||
| codeownersPath === 'docs/CODEOWNERS' | ||
| ) { | ||
| return '' | ||
| function buildMissingSupportedCodeownersError (discoveredCodeownersPaths) { | ||
| if (discoveredCodeownersPaths.length === 0) { | ||
| return 'No CODEOWNERS files found in this repository.' | ||
| } | ||
| const codeownersDir = path.posix.dirname(codeownersPath) | ||
| return codeownersDir === '.' ? '' : codeownersDir | ||
| const unsupportedPaths = discoveredCodeownersPaths.filter((filePath) => !isSupportedCodeownersFile(filePath)) | ||
| if (unsupportedPaths.length === discoveredCodeownersPaths.length) { | ||
| return [ | ||
| 'No supported CODEOWNERS files found in this repository.', | ||
| `GitHub only supports ${SUPPORTED_CODEOWNERS_PATHS_LABEL}.`, | ||
| `Unsupported CODEOWNERS files were found at: ${unsupportedPaths.join(', ')}.`, | ||
| ].join(' ') | ||
| } | ||
| return 'No CODEOWNERS files found in this repository.' | ||
| } | ||
@@ -1241,7 +1339,6 @@ | ||
| * path: string, | ||
| * dir: string, | ||
| * rules: { | ||
| * pattern: string, | ||
| * owners: string[], | ||
| * matches: (scopePath: string, repoPath: string) => boolean | ||
| * matches: (repoPath: string) => boolean | ||
| * }[] | ||
@@ -1251,3 +1348,2 @@ * }} | ||
| function loadCodeownersDescriptor (repoRoot, codeownersPath) { | ||
| const descriptorDir = resolveCodeownersScopeBase(codeownersPath) | ||
| const fileContent = readFileSync(path.join(repoRoot, codeownersPath), 'utf8') | ||
@@ -1258,3 +1354,2 @@ const rules = parseCodeowners(fileContent) | ||
| path: codeownersPath, | ||
| dir: descriptorDir, | ||
| rules, | ||
@@ -1267,3 +1362,3 @@ } | ||
| * @param {string} fileContent | ||
| * @returns {{ pattern: string, owners: string[], matches: (scopePath: string, repoPath: string) => boolean }[]} | ||
| * @returns {{ pattern: string, owners: string[], matches: (repoPath: string) => boolean }[]} | ||
| */ | ||
@@ -1338,3 +1433,3 @@ function parseCodeowners (fileContent) { | ||
| * @param {string} rawPattern | ||
| * @returns {(scopePath: string, repoPath: string) => boolean} | ||
| * @returns {(repoPath: string) => boolean} | ||
| */ | ||
@@ -1356,7 +1451,7 @@ function createPatternMatcher (rawPattern, options = {}) { | ||
| const anchoredRegex = new RegExp(`^${patternSource}${descendantSuffix}$`) | ||
| return (scopePath) => anchoredRegex.test(scopePath) | ||
| return (repoPath) => anchoredRegex.test(repoPath) | ||
| } | ||
| const unanchoredRegex = new RegExp(`(?:^|/)${patternSource}${descendantSuffix}$`) | ||
| return (scopePath, repoPath) => unanchoredRegex.test(scopePath) || unanchoredRegex.test(repoPath) | ||
| return (repoPath) => unanchoredRegex.test(repoPath) | ||
| } | ||
@@ -1403,29 +1498,14 @@ | ||
| /** | ||
| * Sort CODEOWNERS files from broadest to narrowest scope. | ||
| * @param {{ dir: string, path: string }} first | ||
| * @param {{ dir: string, path: string }} second | ||
| * @returns {number} | ||
| */ | ||
| function compareCodeownersDescriptor (first, second) { | ||
| const firstDepth = first.dir ? first.dir.split('/').length : 0 | ||
| const secondDepth = second.dir ? second.dir.split('/').length : 0 | ||
| if (firstDepth !== secondDepth) return firstDepth - secondDepth | ||
| return first.path.localeCompare(second.path) | ||
| } | ||
| /** | ||
| * Build missing-path warnings for CODEOWNERS rules that match no repository files. | ||
| * @param {{ | ||
| * path: string, | ||
| * dir: string, | ||
| * rules: { | ||
| * pattern: string, | ||
| * owners: string[], | ||
| * matches: (scopePath: string, repoPath: string) => boolean | ||
| * matches: (repoPath: string) => boolean | ||
| * }[] | ||
| * }[]} codeownersDescriptors | ||
| * }} codeownersDescriptor | ||
| * @param {string[]} repoFiles | ||
| * @returns {{ | ||
| * codeownersPath: string, | ||
| * scopedDir: string, | ||
| * pattern: string, | ||
@@ -1435,6 +1515,5 @@ * owners: string[] | ||
| */ | ||
| function collectMissingCodeownersPathWarnings (codeownersDescriptors, repoFiles) { | ||
| function collectMissingCodeownersPathWarnings (codeownersDescriptor, repoFiles) { | ||
| /** @type {{ | ||
| * codeownersPath: string, | ||
| * scopedDir: string, | ||
| * pattern: string, | ||
@@ -1445,23 +1524,10 @@ * owners: string[] | ||
| for (const descriptor of codeownersDescriptors) { | ||
| const scopedFiles = descriptor.dir | ||
| ? repoFiles.filter((filePath) => pathIsInside(filePath, descriptor.dir)) | ||
| : repoFiles | ||
| for (const rule of descriptor.rules) { | ||
| const hasMatch = scopedFiles.some((filePath) => { | ||
| const scopePath = descriptor.dir | ||
| ? filePath.slice(descriptor.dir.length + 1) | ||
| : filePath | ||
| return rule.matches(scopePath, filePath) | ||
| for (const rule of codeownersDescriptor.rules) { | ||
| const hasMatch = repoFiles.some((filePath) => rule.matches(filePath)) | ||
| if (!hasMatch) { | ||
| warnings.push({ | ||
| codeownersPath: codeownersDescriptor.path, | ||
| pattern: rule.pattern, | ||
| owners: rule.owners, | ||
| }) | ||
| if (!hasMatch) { | ||
| warnings.push({ | ||
| codeownersPath: descriptor.path, | ||
| scopedDir: descriptor.dir || '.', | ||
| pattern: rule.pattern, | ||
| owners: rule.owners, | ||
| }) | ||
| } | ||
| } | ||
@@ -1479,2 +1545,46 @@ } | ||
| /** | ||
| * Build discovery warnings for extra or unsupported CODEOWNERS files. | ||
| * @param {string[]} discoveredCodeownersPaths | ||
| * @param {string} activeCodeownersPath | ||
| * @returns {{ | ||
| * path: string, | ||
| * type: 'unused-supported-location'|'unsupported-location', | ||
| * referencePath?: string, | ||
| * message: string | ||
| * }[]} | ||
| */ | ||
| function collectCodeownersDiscoveryWarnings (discoveredCodeownersPaths, activeCodeownersPath) { | ||
| /** @type {{ | ||
| * path: string, | ||
| * type: 'unused-supported-location'|'unsupported-location', | ||
| * referencePath?: string, | ||
| * message: string | ||
| * }[]} */ | ||
| const warnings = [] | ||
| for (const codeownersPath of discoveredCodeownersPaths) { | ||
| if (codeownersPath === activeCodeownersPath) continue | ||
| if (isSupportedCodeownersFile(codeownersPath)) { | ||
| warnings.push({ | ||
| path: codeownersPath, | ||
| type: 'unused-supported-location', | ||
| referencePath: activeCodeownersPath, | ||
| message: `${codeownersPath} is unused because GitHub selects ${activeCodeownersPath} first.`, | ||
| }) | ||
| continue | ||
| } | ||
| warnings.push({ | ||
| path: codeownersPath, | ||
| type: 'unsupported-location', | ||
| message: `${codeownersPath} is in an unsupported location and is ignored by GitHub.`, | ||
| }) | ||
| } | ||
| warnings.sort((first, second) => first.path.localeCompare(second.path)) | ||
| return warnings | ||
| } | ||
| /** | ||
| * Build the report payload consumed by the HTML page. | ||
@@ -1485,9 +1595,8 @@ * @param {string} repoRoot | ||
| * path: string, | ||
| * dir: string, | ||
| * rules: { | ||
| * pattern: string, | ||
| * owners: string[], | ||
| * matches: (scopePath: string, repoPath: string) => boolean | ||
| * matches: (repoPath: string) => boolean | ||
| * }[] | ||
| * }[]} codeownersDescriptors | ||
| * }} codeownersDescriptor | ||
| * @param {{ | ||
@@ -1506,3 +1615,3 @@ * includeUntracked: boolean, | ||
| * totals: { files: number, owned: number, unowned: number, coverage: number }, | ||
| * codeownersFiles: { path: string, dir: string, rules: number }[], | ||
| * codeownersFiles: { path: string, rules: number }[], | ||
| * directories: { path: string, total: number, owned: number, unowned: number, coverage: number }[], | ||
@@ -1512,5 +1621,11 @@ * unownedFiles: string[], | ||
| * codeownersValidationMeta: { | ||
| * discoveryWarnings: { | ||
| * path: string, | ||
| * type: 'unused-supported-location'|'unsupported-location', | ||
| * referencePath?: string, | ||
| * message: string | ||
| * }[], | ||
| * discoveryWarningCount: number, | ||
| * missingPathWarnings: { | ||
| * codeownersPath: string, | ||
| * scopedDir: string, | ||
| * pattern: string, | ||
@@ -1542,3 +1657,3 @@ * owners: string[] | ||
| */ | ||
| function buildReport (repoRoot, files, codeownersDescriptors, options, progress = () => {}) { | ||
| function buildReport (repoRoot, files, codeownersDescriptor, options, progress = () => {}) { | ||
| /** @type {Map<string, { total: number, owned: number, unowned: number }>} */ | ||
@@ -1556,3 +1671,3 @@ const directoryStats = new Map() | ||
| const filePath = files[fileIndex] | ||
| const owners = resolveOwners(filePath, codeownersDescriptors) | ||
| const owners = resolveOwners(filePath, codeownersDescriptor.rules) | ||
| const isOwned = Array.isArray(owners) && owners.length > 0 | ||
@@ -1631,9 +1746,6 @@ const teamOwners = collectTeamOwners(owners) | ||
| totals, | ||
| codeownersFiles: codeownersDescriptors.map((descriptor) => { | ||
| return { | ||
| path: descriptor.path, | ||
| dir: descriptor.dir || '.', | ||
| rules: descriptor.rules.length, | ||
| } | ||
| }), | ||
| codeownersFiles: [{ | ||
| path: codeownersDescriptor.path, | ||
| rules: codeownersDescriptor.rules.length, | ||
| }], | ||
| directories, | ||
@@ -1643,2 +1755,4 @@ unownedFiles, | ||
| codeownersValidationMeta: { | ||
| discoveryWarnings: [], | ||
| discoveryWarningCount: 0, | ||
| missingPathWarnings: [], | ||
@@ -1690,52 +1804,25 @@ missingPathWarningCount: 0, | ||
| /** | ||
| * Resolve matching owners for a file by applying CODEOWNERS files from broad to narrow. | ||
| * Resolve matching owners for a file using the active CODEOWNERS rules. | ||
| * @param {string} filePath | ||
| * @param {{ | ||
| * dir: string, | ||
| * rules: { | ||
| * owners: string[], | ||
| * matches: (scopePath: string, repoPath: string) => boolean | ||
| * }[] | ||
| * }[]} codeownersDescriptors | ||
| * owners: string[], | ||
| * matches: (repoPath: string) => boolean | ||
| * }[]} codeownersRules | ||
| * @returns {string[]|undefined} | ||
| */ | ||
| function resolveOwners (filePath, codeownersDescriptors) { | ||
| /** @type {string[]|undefined} */ | ||
| let owners | ||
| for (const descriptor of codeownersDescriptors) { | ||
| if (descriptor.dir && !pathIsInside(filePath, descriptor.dir)) continue | ||
| const scopePath = descriptor.dir ? filePath.slice(descriptor.dir.length + 1) : filePath | ||
| const matchedOwners = findMatchingOwners(scopePath, filePath, descriptor.rules) | ||
| if (matchedOwners) { | ||
| owners = matchedOwners | ||
| } | ||
| } | ||
| return owners | ||
| function resolveOwners (filePath, codeownersRules) { | ||
| return findMatchingOwners(filePath, codeownersRules) | ||
| } | ||
| /** | ||
| * Check whether filePath is inside dirPath (POSIX relative paths). | ||
| * @param {string} filePath | ||
| * @param {string} dirPath | ||
| * @returns {boolean} | ||
| */ | ||
| function pathIsInside (filePath, dirPath) { | ||
| return filePath === dirPath || filePath.startsWith(`${dirPath}/`) | ||
| } | ||
| /** | ||
| * Find the last matching owners in a ruleset. | ||
| * @param {string} scopePath | ||
| * @param {string} repoPath | ||
| * @param {{ owners: string[], matches: (scopePath: string, repoPath: string) => boolean }[]} rules | ||
| * @param {{ owners: string[], matches: (repoPath: string) => boolean }[]} rules | ||
| * @returns {string[]|undefined} | ||
| */ | ||
| function findMatchingOwners (scopePath, repoPath, rules) { | ||
| function findMatchingOwners (repoPath, rules) { | ||
| /** @type {string[]|undefined} */ | ||
| let owners | ||
| for (const rule of rules) { | ||
| if (rule.matches(scopePath, repoPath)) { | ||
| if (rule.matches(repoPath)) { | ||
| owners = rule.owners | ||
@@ -1814,3 +1901,3 @@ } | ||
| * totals: { files: number, owned: number, unowned: number, coverage: number }, | ||
| * codeownersFiles: { path: string, dir: string, rules: number }[], | ||
| * codeownersFiles: { path: string, rules: number }[], | ||
| * directories: { path: string, total: number, owned: number, unowned: number, coverage: number }[], | ||
@@ -1820,5 +1907,11 @@ * unownedFiles: string[], | ||
| * codeownersValidationMeta: { | ||
| * discoveryWarnings: { | ||
| * path: string, | ||
| * type: 'unused-supported-location'|'unsupported-location', | ||
| * referencePath?: string, | ||
| * message: string | ||
| * }[], | ||
| * discoveryWarningCount: number, | ||
| * missingPathWarnings: { | ||
| * codeownersPath: string, | ||
| * scopedDir: string, | ||
| * pattern: string, | ||
@@ -1825,0 +1918,0 @@ * owners: string[] |
+94
-12
@@ -246,2 +246,13 @@ <!doctype html> | ||
| } | ||
| .warning-path { | ||
| color: #fbbf24; | ||
| font-weight: 600; | ||
| } | ||
| .warning-text { | ||
| color: var(--muted); | ||
| } | ||
| .warning-reference { | ||
| color: #67e8f9; | ||
| font-weight: 600; | ||
| } | ||
@@ -457,3 +468,3 @@ table { | ||
| <div class="header"> | ||
| <h2>Detected CODEOWNERS Files</h2> | ||
| <h2>Active CODEOWNERS File</h2> | ||
| </div> | ||
@@ -464,3 +475,2 @@ <table> | ||
| <th>Path</th> | ||
| <th>Scope Base</th> | ||
| <th>Rules</th> | ||
@@ -472,6 +482,9 @@ </tr> | ||
| <div id="missing-path-warnings" class="validation-warnings" hidden> | ||
| <h3>Patterns With No Matching Repository Paths</h3> | ||
| <p class="muted" id="missing-path-warnings-summary"></p> | ||
| <h3 id="missing-path-warnings-heading">Patterns With No Matching Repository Paths</h3> | ||
| <ul id="missing-path-warnings-list"></ul> | ||
| </div> | ||
| <div id="codeowners-discovery-warnings" class="validation-warnings" hidden> | ||
| <h3 id="codeowners-discovery-warnings-heading">CODEOWNERS Location Warnings</h3> | ||
| <ul id="codeowners-discovery-warnings-list"></ul> | ||
| </div> | ||
| </section> | ||
@@ -501,2 +514,4 @@ </div> | ||
| const codeownersValidationMeta = report.codeownersValidationMeta || { | ||
| discoveryWarnings: [], | ||
| discoveryWarningCount: 0, | ||
| missingPathWarnings: [], | ||
@@ -555,2 +570,3 @@ missingPathWarningCount: 0, | ||
| renderMissingPathWarnings(codeownersValidationMeta) | ||
| renderCodeownersDiscoveryWarnings(codeownersValidationMeta) | ||
| directoryController = setupDirectoryTable( | ||
@@ -579,8 +595,6 @@ report.directories, | ||
| '<td class="path"></td>', | ||
| '<td class="path"></td>', | ||
| '<td></td>' | ||
| ].join('') | ||
| tr.children[0].textContent = row.path | ||
| tr.children[1].textContent = row.dir | ||
| tr.children[2].textContent = fmt.format(row.rules) | ||
| tr.children[1].textContent = fmt.format(row.rules) | ||
| body.appendChild(tr) | ||
@@ -592,3 +606,3 @@ } | ||
| const container = document.getElementById('missing-path-warnings') | ||
| const summary = document.getElementById('missing-path-warnings-summary') | ||
| const heading = document.getElementById('missing-path-warnings-heading') | ||
| const list = document.getElementById('missing-path-warnings-list') | ||
@@ -606,9 +620,26 @@ const warningRows = Array.isArray(validationMeta && validationMeta.missingPathWarnings) | ||
| const warningCount = Number(validationMeta && validationMeta.missingPathWarningCount) || warningRows.length | ||
| summary.textContent = fmt.format(warningCount) + | ||
| ' CODEOWNERS pattern(s) do not match any repository file in the scanned repository scope.' | ||
| heading.textContent = 'Patterns With No Matching Repository Paths (' + fmt.format(warningCount) + ')' | ||
| for (const warning of warningRows) { | ||
| const item = document.createElement('li') | ||
| item.textContent = | ||
| warning.pattern + ' (from ' + warning.codeownersPath + ')' | ||
| 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 = ' (from ' | ||
| item.appendChild(textSpan) | ||
| const sourceSpan = document.createElement('span') | ||
| sourceSpan.className = 'warning-reference' | ||
| sourceSpan.textContent = warning.codeownersPath | ||
| item.appendChild(sourceSpan) | ||
| const trailingSpan = document.createElement('span') | ||
| trailingSpan.className = 'warning-text' | ||
| trailingSpan.textContent = ')' | ||
| item.appendChild(trailingSpan) | ||
| list.appendChild(item) | ||
@@ -620,2 +651,53 @@ } | ||
| function renderCodeownersDiscoveryWarnings (validationMeta) { | ||
| const container = document.getElementById('codeowners-discovery-warnings') | ||
| const heading = document.getElementById('codeowners-discovery-warnings-heading') | ||
| const list = document.getElementById('codeowners-discovery-warnings-list') | ||
| const warningRows = Array.isArray(validationMeta && validationMeta.discoveryWarnings) | ||
| ? validationMeta.discoveryWarnings | ||
| : [] | ||
| list.innerHTML = '' | ||
| if (warningRows.length === 0) { | ||
| container.hidden = true | ||
| return | ||
| } | ||
| const warningCount = Number(validationMeta && validationMeta.discoveryWarningCount) || warningRows.length | ||
| heading.textContent = 'CODEOWNERS Location Warnings (' + 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.path | ||
| item.appendChild(pathSpan) | ||
| const textSpan = document.createElement('span') | ||
| textSpan.className = 'warning-text' | ||
| if (warning.type === 'unused-supported-location' && warning.referencePath) { | ||
| textSpan.textContent = ' is unused because GitHub selects ' | ||
| item.appendChild(textSpan) | ||
| const referenceSpan = document.createElement('span') | ||
| referenceSpan.className = 'warning-reference' | ||
| referenceSpan.textContent = warning.referencePath | ||
| item.appendChild(referenceSpan) | ||
| const trailingSpan = document.createElement('span') | ||
| trailingSpan.className = 'warning-text' | ||
| trailingSpan.textContent = ' first.' | ||
| item.appendChild(trailingSpan) | ||
| } else { | ||
| textSpan.textContent = ' is in an unsupported location and is ignored by GitHub.' | ||
| item.appendChild(textSpan) | ||
| } | ||
| list.appendChild(item) | ||
| } | ||
| container.hidden = false | ||
| } | ||
| function setupDirectoryTable (allRows, suggestionLookup, suggestionContext, getScope, onScopeChange, getScopeDirectStats) { | ||
@@ -622,0 +704,0 @@ const body = document.getElementById('directory-table-body') |
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
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
142749
5.34%2810
3.2%231
2.21%17
54.55%