|
| 1 | +const COPYRIGHT_HOLDER = 'NVIDIA CORPORATION & AFFILIATES. All rights reserved.'; |
| 2 | +const LICENSE = 'Apache-2.0'; |
| 3 | +const YEAR_PATTERN = /\b(\d{4})\b/; |
| 4 | + |
| 5 | +function patternsFor(filename) { |
| 6 | + const isCss = filename.endsWith('.css'); |
| 7 | + const open = isCss ? '/\\* ' : '// '; |
| 8 | + const close = isCss ? ' \\*/' : ''; |
| 9 | + const wrongOpen = isCss ? '// ' : '/\\* '; |
| 10 | + const wrongClose = isCss ? '' : ' \\*/'; |
| 11 | + const prefix = isCss ? '/* ' : '// '; |
| 12 | + const suffix = isCss ? ' */' : ''; |
| 13 | + return { |
| 14 | + prefix, |
| 15 | + suffix, |
| 16 | + expectedCopyright: new RegExp( |
| 17 | + `^${open}SPDX-FileCopyrightText: Copyright \\(c\\) (\\d{4}) NVIDIA CORPORATION & AFFILIATES\\. All rights reserved\\.${close}\\s*$` |
| 18 | + ), |
| 19 | + expectedIdentifier: new RegExp(`^${open}SPDX-License-Identifier: Apache-2\\.0${close}\\s*$`), |
| 20 | + wrongStyleCopyright: new RegExp( |
| 21 | + `^${wrongOpen}SPDX-FileCopyrightText: Copyright \\(c\\) \\d{4} NVIDIA CORPORATION & AFFILIATES\\. All rights reserved\\.${wrongClose}\\s*$` |
| 22 | + ), |
| 23 | + wrongStyleIdentifier: new RegExp(`^${wrongOpen}SPDX-License-Identifier: Apache-2\\.0${wrongClose}\\s*$`) |
| 24 | + }; |
| 25 | +} |
| 26 | + |
| 27 | +function analyzeLine(line, patterns) { |
| 28 | + if (line.includes('SPDX-FileCopyrightText:')) { |
| 29 | + return { |
| 30 | + kind: 'copyright', |
| 31 | + exact: patterns.expectedCopyright.test(line), |
| 32 | + wrongStyle: patterns.wrongStyleCopyright.test(line) |
| 33 | + }; |
| 34 | + } |
| 35 | + if (line.includes('SPDX-License-Identifier:')) { |
| 36 | + return { |
| 37 | + kind: 'identifier', |
| 38 | + exact: patterns.expectedIdentifier.test(line), |
| 39 | + wrongStyle: patterns.wrongStyleIdentifier.test(line) |
| 40 | + }; |
| 41 | + } |
| 42 | + return { kind: 'other' }; |
| 43 | +} |
| 44 | + |
| 45 | +function selectMessage(line0Info, line1Info) { |
| 46 | + const line0IsCopyright = line0Info.kind === 'copyright' && line0Info.exact; |
| 47 | + const line1IsIdentifier = line1Info.kind === 'identifier' && line1Info.exact; |
| 48 | + if (line0IsCopyright && line1IsIdentifier) return null; |
| 49 | + |
| 50 | + const anyWrongStyle = |
| 51 | + (line0Info.kind !== 'other' && line0Info.wrongStyle) || (line1Info.kind !== 'other' && line1Info.wrongStyle); |
| 52 | + if (anyWrongStyle) return 'wrong-comment-style'; |
| 53 | + |
| 54 | + const hasCopyright = line0Info.kind === 'copyright' || line1Info.kind === 'copyright'; |
| 55 | + const hasIdentifier = line0Info.kind === 'identifier' || line1Info.kind === 'identifier'; |
| 56 | + |
| 57 | + if (line0Info.kind === 'copyright' && !line0Info.exact) return 'invalid-copyright'; |
| 58 | + if (line1Info.kind === 'copyright' && !line1Info.exact) return 'invalid-copyright'; |
| 59 | + if (line0Info.kind === 'identifier' && !line0Info.exact) return 'invalid-identifier'; |
| 60 | + if (line1Info.kind === 'identifier' && !line1Info.exact) return 'invalid-identifier'; |
| 61 | + |
| 62 | + if (hasCopyright && !hasIdentifier) return 'missing-identifier'; |
| 63 | + if (hasIdentifier && !hasCopyright) return 'missing-copyright'; |
| 64 | + |
| 65 | + return 'missing-header'; |
| 66 | +} |
| 67 | + |
| 68 | +function findHeaderStart(lines) { |
| 69 | + let start = 0; |
| 70 | + if (lines[0]?.startsWith('#!')) { |
| 71 | + start = 1; |
| 72 | + if (lines[start]?.trim() === '') start++; |
| 73 | + } |
| 74 | + return start; |
| 75 | +} |
| 76 | + |
| 77 | +function buildFixer(sourceCode, patterns, existingYear, headerStart) { |
| 78 | + const lines = sourceCode.lines; |
| 79 | + let headerEnd = headerStart; |
| 80 | + while (headerEnd < lines.length && lines[headerEnd].includes('SPDX-')) { |
| 81 | + headerEnd++; |
| 82 | + } |
| 83 | + |
| 84 | + const text = sourceCode.getText(); |
| 85 | + let replaceStart = 0; |
| 86 | + for (let i = 0; i < headerStart; i++) { |
| 87 | + const nl = text.indexOf('\n', replaceStart); |
| 88 | + replaceStart = nl === -1 ? text.length : nl + 1; |
| 89 | + } |
| 90 | + let replaceEnd = replaceStart; |
| 91 | + for (let i = headerStart; i < headerEnd; i++) { |
| 92 | + const nl = text.indexOf('\n', replaceEnd); |
| 93 | + replaceEnd = nl === -1 ? text.length : nl + 1; |
| 94 | + } |
| 95 | + |
| 96 | + const year = existingYear ?? new Date().getFullYear().toString(); |
| 97 | + const copyrightLine = `${patterns.prefix}SPDX-FileCopyrightText: Copyright (c) ${year} ${COPYRIGHT_HOLDER}${patterns.suffix}`; |
| 98 | + const identifierLine = `${patterns.prefix}SPDX-License-Identifier: ${LICENSE}${patterns.suffix}`; |
| 99 | + let replacement = `${copyrightLine}\n${identifierLine}\n`; |
| 100 | + |
| 101 | + const nextLine = lines[headerEnd]; |
| 102 | + if (nextLine !== undefined && nextLine.trim() !== '') { |
| 103 | + replacement += '\n'; |
| 104 | + } |
| 105 | + if (headerStart > 0 && lines[headerStart - 1]?.trim() !== '') { |
| 106 | + replacement = `\n${replacement}`; |
| 107 | + } |
| 108 | + |
| 109 | + return fixer => fixer.replaceTextRange([replaceStart, replaceEnd], replacement); |
| 110 | +} |
| 111 | + |
| 112 | +function extractYear(lines) { |
| 113 | + for (const line of lines) { |
| 114 | + if (line.includes('SPDX-FileCopyrightText:')) { |
| 115 | + const match = line.match(YEAR_PATTERN); |
| 116 | + if (match) return match[1]; |
| 117 | + } |
| 118 | + } |
| 119 | + return null; |
| 120 | +} |
| 121 | + |
| 122 | +function check(context) { |
| 123 | + const sourceCode = context.sourceCode; |
| 124 | + const lines = sourceCode.lines ?? []; |
| 125 | + const headerStart = findHeaderStart(lines); |
| 126 | + const line0 = lines[headerStart] ?? ''; |
| 127 | + const line1 = lines[headerStart + 1] ?? ''; |
| 128 | + const patterns = patternsFor(context.filename); |
| 129 | + |
| 130 | + const line0Info = analyzeLine(line0, patterns); |
| 131 | + const line1Info = analyzeLine(line1, patterns); |
| 132 | + const messageId = selectMessage(line0Info, line1Info); |
| 133 | + if (messageId === null) return; |
| 134 | + |
| 135 | + const existingYear = extractYear(lines); |
| 136 | + const reportLine = headerStart + 1; |
| 137 | + |
| 138 | + context.report({ |
| 139 | + loc: { |
| 140 | + start: { line: reportLine, column: 0 }, |
| 141 | + end: { line: reportLine, column: Math.max(1, line0.length) } |
| 142 | + }, |
| 143 | + messageId, |
| 144 | + fix: buildFixer(sourceCode, patterns, existingYear, headerStart) |
| 145 | + }); |
| 146 | +} |
| 147 | + |
| 148 | +/** @type {import('eslint').Rule.RuleModule} */ |
| 149 | +export default { |
| 150 | + meta: { |
| 151 | + type: 'problem', |
| 152 | + name: 'require-spdx-header', |
| 153 | + docs: { |
| 154 | + description: 'Require the standard SPDX copyright and license header at the top of source files.', |
| 155 | + category: 'Best Practice', |
| 156 | + recommended: true |
| 157 | + }, |
| 158 | + schema: [], |
| 159 | + fixable: 'code', |
| 160 | + messages: { |
| 161 | + 'missing-header': 'File must start with the SPDX copyright and license header. Run `eslint --fix` to insert it.', |
| 162 | + 'missing-copyright': 'File is missing the `SPDX-FileCopyrightText` header line.', |
| 163 | + 'missing-identifier': 'File is missing the `SPDX-License-Identifier` header line.', |
| 164 | + 'invalid-copyright': `\`SPDX-FileCopyrightText\` line must read \`Copyright (c) YYYY ${COPYRIGHT_HOLDER}\`.`, |
| 165 | + 'invalid-identifier': `\`SPDX-License-Identifier\` line must read \`${LICENSE}\`.`, |
| 166 | + 'wrong-comment-style': |
| 167 | + 'SPDX header uses the wrong comment syntax for this file type. TypeScript files use `//`, CSS files use `/* … */`.' |
| 168 | + } |
| 169 | + }, |
| 170 | + create(context) { |
| 171 | + const handler = () => check(context); |
| 172 | + return { |
| 173 | + Program: handler, |
| 174 | + StyleSheet: handler |
| 175 | + }; |
| 176 | + } |
| 177 | +}; |
0 commit comments