Skip to content

Commit 2276640

Browse files
committed
chore(internals): add spdx eslint rule
Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 52c89f0 commit 2276640

9 files changed

Lines changed: 465 additions & 10 deletions

File tree

projects/core/build/icons.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ function writeIconRegistry(icons) {
6666
return new Promise(r => {
6767
fs.writeFile(
6868
`${outputPath}/icons.ts`,
69-
`
69+
`// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
70+
// SPDX-License-Identifier: Apache-2.0
71+
7072
// This is an auto-generated file. DO NOT EDIT
7173
export interface IconSVG {
7274
svg: () => Promise<string> | string;
@@ -101,7 +103,9 @@ function writeSSRIconRegistry(icons) {
101103
return new Promise(r => {
102104
fs.writeFile(
103105
`${outputPath}/server.ts`,
104-
`
106+
`// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
107+
// SPDX-License-Identifier: Apache-2.0
108+
105109
// This is an auto-generated file. DO NOT EDIT
106110
// eslint-disable
107111
//

projects/core/src/icon/icons.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
13

24
// This is an auto-generated file. DO NOT EDIT
35
export interface IconSVG {

projects/core/src/icon/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
13

24
// This is an auto-generated file. DO NOT EDIT
35
// eslint-disable

projects/internals/ci/notice/index.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
22
import path from 'path';
33
import * as url from 'url';
4+
import prettier from 'prettier';
45
import { renderProjectNotice, renderRootNotice } from './template.js';
56

67
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
@@ -185,18 +186,30 @@ function aggregateForRoot(resolved) {
185186
return [...byKey.values()];
186187
}
187188

188-
function writeFile(filePath, content) {
189+
async function formatMarkdown(filePath, content) {
190+
const config = await prettier.resolveConfig(filePath);
191+
return prettier.format(content, { ...config, filepath: filePath });
192+
}
193+
194+
function writeFileIfChanged(filePath, content) {
195+
const relative = path.relative(REPO_ROOT, filePath);
196+
if (existsSync(filePath) && readFileSync(filePath, 'utf-8') === content) {
197+
console.log(`unchanged ${relative}`);
198+
return;
199+
}
189200
writeFileSync(filePath, content, 'utf-8');
190-
console.log(`wrote ${path.relative(REPO_ROOT, filePath)}`);
201+
console.log(`wrote ${relative}`);
191202
}
192203

193204
const projects = discoverProjects();
194205
const resolved = projects.map(resolveProject);
195206

196207
for (const { projectDir, deps, assets } of resolved) {
197-
const content = renderProjectNotice({ deps, assets });
198-
writeFile(path.join(projectDir, 'NOTICE.md'), content);
208+
const filePath = path.join(projectDir, 'NOTICE.md');
209+
const content = await formatMarkdown(filePath, renderProjectNotice({ deps, assets }));
210+
writeFileIfChanged(filePath, content);
199211
}
200212

201213
const rootEntries = aggregateForRoot(resolved);
202-
writeFile(path.join(REPO_ROOT, 'NOTICE.md'), renderRootNotice(rootEntries));
214+
const rootPath = path.join(REPO_ROOT, 'NOTICE.md');
215+
writeFileIfChanged(rootPath, await formatMarkdown(rootPath, renderRootNotice(rootEntries)));

projects/internals/eslint/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Applied to `src/**/*.ts`, `src/**/*.tsx`, test files, and `*.examples.ts`.
7474
**Source hygiene**
7575

7676
- **`no-dead-code`**. Flags commented-out imports, exports, declarations, control-flow, and test blocks. The project currently sets this to `warn` during cleanup.
77+
- **`require-spdx-header`**. Every source file must start with the two-line SPDX header (`SPDX-FileCopyrightText` copyright + `SPDX-License-Identifier: Apache-2.0`). The rule accepts any 4-digit year; auto-fix preserves an existing year and falls back to the current year only when inserting a header from scratch.
7778

7879
### Example rules (plugin `local-typescript`, files `**/*.examples.ts`)
7980

projects/internals/eslint/src/configs/css.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import noHostMargin from '../local/no-host-margin.js';
2+
import requireSpdxHeader from '../local/require-spdx-header.js';
23

34
const source = ['src/**/*.css'];
45
const ignores = [
@@ -25,12 +26,14 @@ export const cssConfig = [
2526
plugins: {
2627
'local-css': {
2728
rules: {
28-
'no-host-margin': noHostMargin
29+
'no-host-margin': noHostMargin,
30+
'require-spdx-header': requireSpdxHeader
2931
}
3032
}
3133
},
3234
rules: {
33-
'local-css/no-host-margin': ['warn']
35+
'local-css/no-host-margin': ['warn'],
36+
'local-css/require-spdx-header': ['error']
3437
}
3538
}
3639
];

projects/internals/eslint/src/configs/typescript.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import requireElementStable from '../local/require-element-stable.js';
1313
import requireListenerCleanup from '../local/require-listener-cleanup.js';
1414
import requireObserverCleanup from '../local/require-observer-cleanup.js';
1515
import requireTimerCleanup from '../local/require-timer-cleanup.js';
16+
import requireSpdxHeader from '../local/require-spdx-header.js';
1617

1718
const source = ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.d.ts'];
1819
const tests = [
@@ -56,7 +57,8 @@ const config = {
5657
'require-element-stable': requireElementStable,
5758
'require-listener-cleanup': requireListenerCleanup,
5859
'require-observer-cleanup': requireObserverCleanup,
59-
'require-timer-cleanup': requireTimerCleanup
60+
'require-timer-cleanup': requireTimerCleanup,
61+
'require-spdx-header': requireSpdxHeader
6062
}
6163
}
6264
},
@@ -98,6 +100,7 @@ const config = {
98100
'local-typescript/require-listener-cleanup': ['error'],
99101
'local-typescript/require-observer-cleanup': ['error'],
100102
'local-typescript/require-timer-cleanup': ['error'],
103+
'local-typescript/require-spdx-header': ['error'],
101104

102105
// todo: enable these rules incrementally as the codebase is cleaned up
103106
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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

Comments
 (0)