forked from misskey-dev/misskey
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck-spdx.mjs
More file actions
341 lines (299 loc) · 11.4 KB
/
Copy pathcheck-spdx.mjs
File metadata and controls
341 lines (299 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// @ts-check
/**
* Misskey SPDX ヘッダーの検査 / 自動修正。
*
* 使い方:
* node scripts/check-spdx.mjs ローカル検査 (repo 全体 + HTML コメント形式)
* node scripts/check-spdx.mjs --check 同上 (明示形)
* node scripts/check-spdx.mjs --ci CI 互換検査 (現行の OR 判定を維持)
* node scripts/check-spdx.mjs --fix [--base REF] このブランチの欠落だけ自動修正して全体を再検査
*
* 終了コード:
* 0 = 選択したモードの検査に合格
* 1 = SPDX 欠落またはローカル検査でのコメント形式違反
* 2 = 引数、Git ref、ファイル列挙・読取などの理由で検査不能
*
* CI 互換判定は、現行 CI と同じく copyright 行または AGPL license 行のどちらか
* 一方が全文のどこかにあれば pass する。この判定はライセンス表記の完全性や正当性を
* 保証しない。ローカル検査は、加えて .vue / .html のコメント形式を検査する。
*
* --check / --ci の合否はブランチの所有権で絞らない。--fix だけが統合先との差分を
* 使い、自分の変更に含まれる欠落へ修正対象を限定する。修正後は必ず全体を再検査する。
*/
import { lstatSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, extname } from 'node:path';
import { DEFAULT_INTEGRATION_REFS, findClosestMergeBase, gitLines, gitMergeBase, gitPaths } from './lib/git.mjs';
/** CI の対象ディレクトリ。この配列を CI とローカル検査の両方が使う。 */
const TARGET_DIRECTORIES = [
'packages/backend/migration',
'packages/backend/src',
'packages/backend/test',
'packages/frontend-shared/@types',
'packages/frontend-shared/js',
'packages/frontend-builder',
'packages/frontend/.storybook',
'packages/frontend/@types',
'packages/frontend/lib',
'packages/frontend/public',
'packages/frontend/src',
'packages/frontend/test',
'packages/frontend-embed/@types',
'packages/frontend-embed/src',
'packages/icons-subsetter/src',
'packages/misskey-bubble-game/src',
'packages/misskey-reversi/src',
'packages/sw/src',
'scripts',
];
const TARGET_EXTENSIONS = new Set(['.cjs', '.html', '.js', '.mjs', '.scss', '.ts', '.vue']);
const EXCLUDED_CONFIG_EXTENSIONS = new Set(['.cjs', '.js', '.mjs', '.ts']);
const HTML_COMMENT_EXTENSIONS = new Set(['.vue', '.html']);
const COPYRIGHT_LINE = 'SPDX-FileCopyrightText: syuilo and misskey-project';
const LICENSE_LINE = 'SPDX-License-Identifier: AGPL-3.0-only';
const HTML_HEADER = `<!--\n${COPYRIGHT_LINE}\n${LICENSE_LINE}\n-->\n\n`;
const BLOCK_HEADER = `/*\n * ${COPYRIGHT_LINE}\n * ${LICENSE_LINE}\n */\n\n`;
/** @typedef {'check' | 'fix' | 'ci'} Mode */
/** @typedef {'missing' | 'wrong-form' | 'ok'} Verdict */
class OperationalError extends Error {}
/**
* パスが存在する通常ファイルかを判定する。
*
* @param {string} file
* @returns {boolean}
*/
function isRegularFile(file) {
try {
return lstatSync(file).isFile();
} catch (error) {
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return false;
throw new OperationalError(`${file}: ファイル種別を確認できない`);
}
}
/**
* パスが SPDX 検査の対象条件を満たすかを判定する。
*
* @param {string} file
* @returns {boolean}
*/
function isTargetPath(file) {
const ext = extname(file);
if (!TARGET_EXTENSIONS.has(ext)) return false;
const name = basename(file);
if (name.includes('eslint')) return false;
if (EXCLUDED_CONFIG_EXTENSIONS.has(ext) && name.endsWith(`.config${ext}`)) return false;
return isRegularFile(file);
}
/**
* Git の index と必要に応じて untracked から SPDX 対象ファイルを列挙する。
*
* @param {boolean} includeUntracked
* @returns {string[]}
*/
function listTargetFiles(includeUntracked) {
const args = ['ls-files', '--cached'];
if (includeUntracked) args.push('--others', '--exclude-standard');
args.push('-z', '--', ...TARGET_DIRECTORIES);
return [...new Set(gitPaths(args))].filter(isTargetPath);
}
/**
* 明示 ref または利用可能な統合先から merge-base を選ぶ。
*
* @param {string | null} explicitRef
* @returns {string}
*/
function resolveMergeBase(explicitRef) {
if (explicitRef !== null) return gitMergeBase(explicitRef);
try {
return findClosestMergeBase(DEFAULT_INTEGRATION_REFS);
} catch (error) {
const detail = error instanceof Error ? ` — ${error.message}` : '';
throw new OperationalError(`統合先の merge-base を解決できない。--base <ref> を指定すること${detail}`);
}
}
/**
* merge-base 以降の commit 済み・未commit・untracked の変更を集める。
*
* @param {string | null} explicitRef
* @returns {Set<string>}
*/
function listLocalChanges(explicitRef) {
const base = resolveMergeBase(explicitRef);
return new Set([
...gitPaths(['diff', '--name-only', '--diff-filter=d', '-z', `${base}...HEAD`]),
...gitPaths(['diff', '--name-only', '--diff-filter=d', '-z', 'HEAD']),
...gitPaths(['ls-files', '--others', '--exclude-standard', '-z']),
]);
}
/**
* 指定位置が閉じた HTML コメントの内側かを判定する。
*
* @param {string} text
* @param {number} index
* @returns {boolean}
*/
function isInsideHtmlComment(text, index) {
const before = text.slice(0, index);
const lastOpen = before.lastIndexOf('<!--');
if (lastOpen === -1) return false;
const close = text.indexOf('-->', lastOpen + '<!--'.length);
return close !== -1 && index < close;
}
/**
* ファイルの SPDX 行と HTML コメント形式を判定する。
*
* @param {string} file
* @returns {Verdict}
*/
function judge(file) {
let text;
try {
text = readFileSync(file, 'utf8');
} catch {
throw new OperationalError(`${file}: 内容を読み取れない`);
}
const positions = [text.indexOf(COPYRIGHT_LINE), text.indexOf(LICENSE_LINE)].filter((index) => index >= 0);
if (positions.length === 0) return 'missing';
if (!HTML_COMMENT_EXTENSIONS.has(extname(file))) return 'ok';
return positions.every((position) => isInsideHtmlComment(text, position)) ? 'ok' : 'wrong-form';
}
/**
* ファイル種別と先頭指示行に合わせ、SPDX ヘッダーを適切な位置へ挿入する。
*
* @param {string} file
* @returns {void}
*/
function insertHeader(file) {
const original = readFileSync(file, 'utf8');
const bom = original.charCodeAt(0) === 0xfeff ? '' : '';
const body = bom === '' ? original : original.slice(1);
const eol = body.includes('\r\n') ? '\r\n' : '\n';
/** ファイルの改行コードに合わせてヘッダーを変換する。 @param {string} header */
const withEol = (header) => (eol === '\n' ? header : header.replaceAll('\n', eol));
if (HTML_COMMENT_EXTENSIONS.has(extname(file))) {
writeFileSync(file, bom + withEol(HTML_HEADER) + body);
return;
}
const newlineIndex = body.indexOf('\n');
const firstLine = (newlineIndex === -1 ? body : body.slice(0, newlineIndex)).replace(/\r$/, '');
if (firstLine.startsWith('#!') || firstLine.startsWith('@charset')) {
const rest = newlineIndex === -1 ? '' : body.slice(newlineIndex + 1);
writeFileSync(file, bom + firstLine + eol + eol + withEol(BLOCK_HEADER) + rest);
return;
}
writeFileSync(file, bom + withEol(BLOCK_HEADER) + body);
}
/**
* CLI 引数を検証し、実行モードと基準 ref を取り出す。
*
* @param {string[]} args
* @returns {{ mode: Mode, baseRef: string | null } | null}
*/
function parseArgs(args) {
/** @type {Mode} */ let mode = 'check';
let explicitMode = false;
/** @type {string | null} */ let baseRef = null;
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === '--check' || arg === '--fix' || arg === '--ci') {
if (explicitMode) return null;
mode = arg === '--check' ? 'check' : arg === '--fix' ? 'fix' : 'ci';
explicitMode = true;
continue;
}
if (arg === '--base') {
if (baseRef !== null || args[index + 1] === undefined || args[index + 1].startsWith('--')) return null;
baseRef = args[++index];
continue;
}
return null;
}
if (baseRef !== null && mode !== 'fix') return null;
return { mode, baseRef };
}
/**
* 対象ファイルを検査し、違反種別ごとの一覧にまとめる。
*
* @param {string[]} targets
* @returns {{ missing: string[], wrongForm: string[] }}
*/
function inspectTargets(targets) {
/** @type {string[]} */ const missing = [];
/** @type {string[]} */ const wrongForm = [];
for (const file of targets) {
const verdict = judge(file);
if (verdict === 'missing') missing.push(file);
if (verdict === 'wrong-form') wrongForm.push(file);
}
return { missing, wrongForm };
}
/**
* SPDX の検査・修正を実行し、集約した終了コードを返す。
*
* @returns {number}
*/
function main() {
const options = parseArgs(process.argv.slice(2));
if (options === null) {
console.error('usage: node scripts/check-spdx.mjs [--check | --ci | --fix [--base <ref>]]');
return 2;
}
try {
const root = gitLines(['rev-parse', '--show-toplevel'])[0];
if (root === undefined) throw new OperationalError('git リポジトリの外で実行された');
process.chdir(root);
const includeUntracked = options.mode !== 'ci';
let targets = listTargetFiles(includeUntracked);
/** @type {string[]} */ const fixed = [];
if (options.mode === 'fix') {
const localChanges = listLocalChanges(options.baseRef);
const initial = inspectTargets(targets);
for (const file of initial.missing) {
if (!localChanges.has(file)) continue;
insertHeader(file);
fixed.push(file);
}
targets = listTargetFiles(true);
}
const result = inspectTargets(targets);
for (const file of fixed) console.log(`fixed: ${file}`);
for (const file of result.missing) console.log(`missing: ${file}`);
if (options.mode !== 'ci') {
for (const file of result.wrongForm) console.log(`wrong-form: ${file}`);
}
if (fixed.length > 0 || result.missing.length > 0 || (options.mode !== 'ci' && result.wrongForm.length > 0)) {
console.log('');
}
const failed = result.missing.length > 0 || (options.mode !== 'ci' && result.wrongForm.length > 0);
if (!failed) {
if (options.mode === 'ci') {
console.log(`SPDX CI: OK — 現行 OR 判定を対象 ${targets.length} ファイルが通過した。`);
} else {
const prefix = fixed.length > 0 ? `${fixed.length} ファイルを修正し、` : '';
console.log(`SPDX: OK — ${prefix}CI 判定と HTML コメント形式を対象 ${targets.length} ファイルが通過した。追加確認は不要。`);
}
return 0;
}
if (result.missing.length > 0) {
console.log(`SPDX: CI 判定上の欠落 ${result.missing.length} 件。`);
if (options.mode === 'check') {
console.log("SPDX: 統合先との差分に含まれる欠落は '--fix' で自動修正できる。");
} else if (options.mode === 'fix') {
console.log('SPDX: --fix の対象外を含む欠落が残っているため、CI は通らない。');
}
}
if (options.mode !== 'ci' && result.wrongForm.length > 0) {
console.log(`SPDX: HTML コメント形式違反 ${result.wrongForm.length} 件。`);
}
return 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`SPDX: 検査不能 — ${message}`);
return 2;
}
}
// process.exit() だと stdout が pipe のとき未フラッシュの出力が切り捨てられる。
process.exitCode = main();