sparkle-design-cli 2.4.1 → 2.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +51 -0
- package/README.md +44 -500
- package/bin/sparkle-design.js +59 -22
- package/docs/anti-patterns.md +92 -0
- package/docs/config.md +108 -0
- package/docs/manual-setup.md +117 -0
- package/docs/plugins.md +120 -0
- package/docs/theming.md +122 -0
- package/lib/anti-pattern-rules.js +427 -6
- package/lib/check.js +220 -15
- package/lib/plugin-api.js +17 -1
- package/lib/rules-report.js +181 -0
- package/lib/spacing-scale.js +228 -0
- package/lib/stop-hook.js +44 -6
- package/lib/token-migration.js +678 -0
- package/package.json +3 -1
- package/templates/sparkle-variables/.github/workflows/validate.yml +21 -0
- package/templates/sparkle-variables/README.md +151 -0
- package/templates/sparkle-variables/colors.json +87 -87
- package/templates/sparkle-variables/gray.json +70 -70
- package/templates/sparkle-variables/radius.csv +1 -1
- package/templates/sparkle-variables/scripts/validate-tokens.mjs +734 -0
- package/templates/sparkle-variables/sparkle-design.template.css +1113 -385
package/lib/check.js
CHANGED
|
@@ -2,8 +2,13 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
BLOCKING_SEVERITIES,
|
|
5
6
|
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
6
7
|
BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
8
|
+
DEFAULT_RULE_TARGETS,
|
|
9
|
+
DEFAULT_SEVERITY,
|
|
10
|
+
RULE_TARGET,
|
|
11
|
+
SEVERITY,
|
|
7
12
|
getCheckRules,
|
|
8
13
|
getManualReviewReminders,
|
|
9
14
|
} from './anti-pattern-rules.js';
|
|
@@ -19,6 +24,56 @@ const CSS_EXTENSIONS = new Set(['.css']);
|
|
|
19
24
|
// en: Synchronous built-in snapshot for backward-compatible export below.
|
|
20
25
|
const BUILTIN_RULES = getCheckRules(BUILTIN_ANTI_PATTERN_GROUPS);
|
|
21
26
|
|
|
27
|
+
const SEVERITY_ORDER = [SEVERITY.ERROR, SEVERITY.WARNING, SEVERITY.INFO];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* finding の severity を既知の値に丸める。
|
|
31
|
+
*
|
|
32
|
+
* `getCheckRules` を通れば正規化済みだが、`collectFindings` / `createCheckReport`
|
|
33
|
+
* は public export なので rule を直接渡す経路がある。そこを素通しにすると
|
|
34
|
+
* 「並び順は error 扱いなのに exit code 判定では非 error」という**判定ごとに
|
|
35
|
+
* 結論が変わる**状態になり、件数の内訳も合わなくなる。読み出し側を 1 つの
|
|
36
|
+
* 関数に集約して、どの判定でも同じ値を見るようにする。
|
|
37
|
+
* en: Coerce to a known severity at every read site so ordering, counting and
|
|
38
|
+
* the exit-code decision can never disagree about the same finding.
|
|
39
|
+
*/
|
|
40
|
+
function coerceSeverity(severity) {
|
|
41
|
+
return SEVERITY_ORDER.includes(severity) ? severity : DEFAULT_SEVERITY;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function severityRank(severity) {
|
|
45
|
+
return SEVERITY_ORDER.indexOf(coerceSeverity(severity));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `--strict` / stop-hook が失敗として扱う findings。
|
|
50
|
+
* `warning` / `info` は報告のみで exit code を変えない(issue #74)。
|
|
51
|
+
* en: Only blocking-severity findings affect the exit code.
|
|
52
|
+
*/
|
|
53
|
+
function blockingFindings(findings) {
|
|
54
|
+
return findings.filter((finding) => BLOCKING_SEVERITIES.has(coerceSeverity(finding.severity)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function countBySeverity(findings) {
|
|
58
|
+
const counts = Object.fromEntries(SEVERITY_ORDER.map((severity) => [severity, 0]));
|
|
59
|
+
for (const finding of findings) {
|
|
60
|
+
counts[coerceSeverity(finding.severity)] += 1;
|
|
61
|
+
}
|
|
62
|
+
return counts;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ruleAppliesTo(rule, fileKind) {
|
|
66
|
+
return (rule.targets ?? DEFAULT_RULE_TARGETS).includes(fileKind);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// NOTE: トークンを定義している CSS(CLI の生成物など)を使用側と取り違えない
|
|
70
|
+
// ための判定は、ここではなく `anti-pattern-rules.js` の `migrationMatcher` が
|
|
71
|
+
// 内容ベースで行う(同じファイルが宣言している変数への参照は報告しない)。
|
|
72
|
+
// ファイル名で除外していたときは `generate --scope`(`-o` 必須で出力名が任意)
|
|
73
|
+
// の生成物が素通りしていた。
|
|
74
|
+
// en: Definition-vs-usage is decided by content in migrationMatcher, not by
|
|
75
|
+
// filename — `generate --scope` output has an arbitrary name.
|
|
76
|
+
|
|
22
77
|
const SPARKLE_HEAD_JSX_PATTERN = /<SparkleHead\s*\/?\s*>/;
|
|
23
78
|
// CSP key が「実際の設定 key として記述されている」コンテキストに絞る。
|
|
24
79
|
// 以前は単純に `/content-security-policy|.../i` だったため、コメント行
|
|
@@ -140,23 +195,37 @@ function isSuppressed(ruleId, contentLines, lineNumber) {
|
|
|
140
195
|
return false;
|
|
141
196
|
}
|
|
142
197
|
|
|
143
|
-
function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
198
|
+
function collectFindings(filePath, content, rules = BUILTIN_RULES, options = {}) {
|
|
199
|
+
const fileKind = options.fileKind ?? RULE_TARGET.SOURCE;
|
|
200
|
+
const onRuleError = options.onRuleError;
|
|
144
201
|
const findings = [];
|
|
145
202
|
const contentLines = content.split(/\r?\n/);
|
|
146
|
-
const pushFinding = (rule, index, snippet) => {
|
|
203
|
+
const pushFinding = (rule, index, snippet, recommendationOverride) => {
|
|
147
204
|
const line = getLineNumber(content, index ?? 0);
|
|
148
205
|
if (isSuppressed(rule.id, contentLines, line)) return;
|
|
149
206
|
findings.push({
|
|
150
207
|
filePath,
|
|
151
208
|
id: rule.id,
|
|
209
|
+
// severity は getCheckRules で正規化済み。プラグイン rule が
|
|
210
|
+
// getCheckRules を経由せず直接渡されるテスト経路のために既定値も持たせる。
|
|
211
|
+
// en: Normalized in getCheckRules; the fallback covers rules injected
|
|
212
|
+
// directly in tests without going through it.
|
|
213
|
+
severity: coerceSeverity(rule.severity),
|
|
152
214
|
description: rule.description,
|
|
153
|
-
|
|
215
|
+
// 移行ルールのように「マッチ 1 件ごとに移行先が変わる」ものは
|
|
216
|
+
// rule.match が hit ごとの recommendation を返す。無ければルール既定を使う。
|
|
217
|
+
// en: Per-occurrence recommendation from rule.match wins over the
|
|
218
|
+
// rule-level default (migration targets differ per match).
|
|
219
|
+
recommendation: recommendationOverride ?? rule.recommendation,
|
|
154
220
|
line,
|
|
155
221
|
snippet,
|
|
156
222
|
});
|
|
157
223
|
};
|
|
158
224
|
|
|
159
225
|
for (const rule of rules) {
|
|
226
|
+
// ルールごとに対象ファイル種別が違う(既定は source のみ)。
|
|
227
|
+
if (!ruleAppliesTo(rule, fileKind)) continue;
|
|
228
|
+
|
|
160
229
|
// rule ごとに try/catch で隔離する。1 つのプラグイン rule の throw / malformed
|
|
161
230
|
// RegExp で sparkle-design-cli check 全体が落ちるのを防ぐ。loadAntiPatternPlugins の
|
|
162
231
|
// 「warn して skip」と同じ failure mode に揃える。
|
|
@@ -178,7 +247,7 @@ function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
|
178
247
|
throw new TypeError(`rule.match must return an array, got ${typeof hits}`);
|
|
179
248
|
}
|
|
180
249
|
for (const hit of hits) {
|
|
181
|
-
pushFinding(rule, hit?.index ?? 0, formatSnippet(hit?.text ?? ''));
|
|
250
|
+
pushFinding(rule, hit?.index ?? 0, formatSnippet(hit?.text ?? ''), hit?.recommendation);
|
|
182
251
|
}
|
|
183
252
|
continue;
|
|
184
253
|
}
|
|
@@ -193,9 +262,17 @@ function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
|
193
262
|
}
|
|
194
263
|
} catch (error) {
|
|
195
264
|
const message = error?.message ?? String(error);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
265
|
+
// ルールが落ちた = そのルールについては「違反ゼロ」ではなく「検査していない」。
|
|
266
|
+
// 呼び出し側に記録させてレポートに載せる(無ければ従来どおり warn するだけ)。
|
|
267
|
+
// en: A crashed rule means "not checked", not "clean" — hand it to the
|
|
268
|
+
// caller so it can surface in the report instead of only on stderr.
|
|
269
|
+
if (typeof onRuleError === 'function') {
|
|
270
|
+
onRuleError({ ruleId: rule.id ?? '(unknown)', filePath, message });
|
|
271
|
+
} else {
|
|
272
|
+
console.warn(
|
|
273
|
+
`⚠️ sparkle-design-cli: rule "${rule.id ?? '(unknown)'}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
199
276
|
}
|
|
200
277
|
}
|
|
201
278
|
|
|
@@ -214,6 +291,7 @@ function collectFontImportFindings(cssFiles) {
|
|
|
214
291
|
findings.push({
|
|
215
292
|
filePath,
|
|
216
293
|
id: 'font-import-in-css',
|
|
294
|
+
severity: SEVERITY.ERROR,
|
|
217
295
|
description:
|
|
218
296
|
'CSS にフォント @import が残っています。SparkleHead コンポーネントに移行してください。',
|
|
219
297
|
recommendation:
|
|
@@ -293,6 +371,7 @@ function collectNextjsCspFindings(cwd) {
|
|
|
293
371
|
findings.push({
|
|
294
372
|
filePath: configPath,
|
|
295
373
|
id: 'csp-font-block',
|
|
374
|
+
severity: SEVERITY.ERROR,
|
|
296
375
|
description: `CSP ヘッダーが設定されていますが、${missing} が許可されていない可能性があります。`,
|
|
297
376
|
recommendation: `style-src に ${FONT_DOMAINS.GOOGLEAPIS} を、font-src に ${FONT_DOMAINS.GSTATIC} を追加してください。`,
|
|
298
377
|
line: 1,
|
|
@@ -318,7 +397,7 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
318
397
|
collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited);
|
|
319
398
|
}
|
|
320
399
|
|
|
321
|
-
const checkedFiles = [...textFiles]
|
|
400
|
+
const checkedFiles = [...textFiles, ...cssFiles]
|
|
322
401
|
.map((filePath) => toRelativeReportPath(filePath))
|
|
323
402
|
.sort((left, right) => left.localeCompare(right));
|
|
324
403
|
|
|
@@ -328,20 +407,67 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
328
407
|
fileContents.set(filePath, fs.readFileSync(filePath, 'utf8'));
|
|
329
408
|
}
|
|
330
409
|
|
|
410
|
+
// 落ちたルールは rule ID 単位で 1 回だけ warn する。catch が (rule × file) 単位
|
|
411
|
+
// なので、素直に warn すると 500 ファイルのリポジトリで同じ行が 500 回出て
|
|
412
|
+
// 本当の findings がスクロールバックから押し出される。
|
|
413
|
+
// en: Deduplicate by rule ID — the catch is per (rule, file), so warning on
|
|
414
|
+
// every file would bury the real findings.
|
|
415
|
+
const skippedRuleMap = new Map();
|
|
416
|
+
const onRuleError = ({ ruleId, filePath, message }) => {
|
|
417
|
+
const existing = skippedRuleMap.get(ruleId);
|
|
418
|
+
if (existing) {
|
|
419
|
+
existing.fileCount += 1;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
skippedRuleMap.set(ruleId, {
|
|
423
|
+
ruleId,
|
|
424
|
+
message,
|
|
425
|
+
firstFile: toRelativeReportPath(filePath),
|
|
426
|
+
fileCount: 1,
|
|
427
|
+
});
|
|
428
|
+
console.warn(
|
|
429
|
+
`⚠️ sparkle-design-cli: rule "${ruleId}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
|
|
430
|
+
);
|
|
431
|
+
};
|
|
432
|
+
|
|
331
433
|
const findings = [...fileContents.entries()]
|
|
332
|
-
.flatMap(([filePath, content]) =>
|
|
434
|
+
.flatMap(([filePath, content]) =>
|
|
435
|
+
collectFindings(filePath, content, rules, { fileKind: RULE_TARGET.SOURCE, onRuleError })
|
|
436
|
+
)
|
|
333
437
|
.map((finding) => ({
|
|
334
438
|
...finding,
|
|
335
439
|
filePath: toRelativeReportPath(finding.filePath),
|
|
336
440
|
}));
|
|
337
441
|
|
|
442
|
+
// CSS も検査する。`--radius-halfModal` のような CSS 変数の直接参照は `.css` に
|
|
443
|
+
// こそ自然に書かれ、しかも消えてもビルドエラーにならず見た目だけ壊れる。
|
|
444
|
+
// 対象は `targets` に css を含むルールだけなので、既存ルールの挙動は変わらない。
|
|
445
|
+
// en: Scan CSS too — variable references live there and fail silently. Only
|
|
446
|
+
// rules that opted into `css` run, so existing rules are unaffected.
|
|
447
|
+
for (const filePath of cssFiles) {
|
|
448
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
449
|
+
findings.push(
|
|
450
|
+
...collectFindings(filePath, content, rules, {
|
|
451
|
+
fileKind: RULE_TARGET.CSS,
|
|
452
|
+
onRuleError,
|
|
453
|
+
}).map((finding) => ({ ...finding, filePath: toRelativeReportPath(finding.filePath) }))
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
338
457
|
const fontFindings = collectFontImportFindings(cssFiles);
|
|
339
458
|
findings.push(...fontFindings.map((f) => ({ ...f, filePath: toRelativeReportPath(f.filePath) })));
|
|
340
459
|
|
|
341
460
|
const cspFindings = collectNextjsCspFindings(process.cwd());
|
|
342
461
|
findings.push(...cspFindings.map((f) => ({ ...f, filePath: toRelativeReportPath(f.filePath) })));
|
|
343
462
|
|
|
463
|
+
// severity の高い順 → ファイル → 行 → ID。移行期は warning が大量に出るため、
|
|
464
|
+
// ファイル順だけで並べると本当に直すべき error が warning に埋もれる。
|
|
465
|
+
// 既存ルールはすべて error なので、error 同士の相対順は従来どおり保たれる。
|
|
466
|
+
// en: Sort by severity first so migration warnings can't bury errors. All
|
|
467
|
+
// pre-existing rules are errors, so their relative order is unchanged.
|
|
344
468
|
findings.sort((left, right) => {
|
|
469
|
+
const severityComparison = severityRank(left.severity) - severityRank(right.severity);
|
|
470
|
+
if (severityComparison !== 0) return severityComparison;
|
|
345
471
|
const fileComparison = left.filePath.localeCompare(right.filePath);
|
|
346
472
|
if (fileComparison !== 0) return fileComparison;
|
|
347
473
|
if (left.line !== right.line) return left.line - right.line;
|
|
@@ -361,6 +487,12 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
361
487
|
targets: resolvedTargets,
|
|
362
488
|
checkedFiles,
|
|
363
489
|
findings,
|
|
490
|
+
// 「ルールが落ちて検査されなかった」ことをレポートに載せる。stderr の warn
|
|
491
|
+
// だけだと JSON を読む CI / AI には見えず、findings 0 件・passed true を
|
|
492
|
+
// 「クリーン」と誤読する。
|
|
493
|
+
// en: Surface skipped rules in the report — a stderr-only warning is
|
|
494
|
+
// invisible to JSON consumers, who would read 0 findings as "clean".
|
|
495
|
+
skippedRules: [...skippedRuleMap.values()],
|
|
364
496
|
manualReviewReminders,
|
|
365
497
|
};
|
|
366
498
|
}
|
|
@@ -369,14 +501,29 @@ function printTextReport(report, options = {}) {
|
|
|
369
501
|
if (report.findings.length === 0) {
|
|
370
502
|
console.log('sparkle-design-cli check: no findings');
|
|
371
503
|
} else {
|
|
372
|
-
|
|
504
|
+
const counts = countBySeverity(report.findings);
|
|
505
|
+
const breakdown = SEVERITY_ORDER.filter((severity) => counts[severity] > 0)
|
|
506
|
+
.map((severity) => `${counts[severity]} ${severity}`)
|
|
507
|
+
.join(', ');
|
|
508
|
+
console.log(`sparkle-design-cli check: ${report.findings.length} finding(s) (${breakdown})\n`);
|
|
373
509
|
|
|
374
510
|
for (const finding of report.findings) {
|
|
375
|
-
|
|
511
|
+
const severity = coerceSeverity(finding.severity);
|
|
512
|
+
console.log(
|
|
513
|
+
`${finding.filePath}:${finding.line} [${severity}] [${finding.id}] ${finding.description}`
|
|
514
|
+
);
|
|
376
515
|
console.log(` Recommendation: ${finding.recommendation}`);
|
|
377
516
|
console.log(` Snippet: ${finding.snippet}`);
|
|
378
517
|
console.log('');
|
|
379
518
|
}
|
|
519
|
+
|
|
520
|
+
if (counts[SEVERITY.WARNING] > 0 || counts[SEVERITY.INFO] > 0) {
|
|
521
|
+
console.log(
|
|
522
|
+
'Note: warning / info は報告のみで exit code には影響しません(--strict でも失敗しません)。' +
|
|
523
|
+
' / warning and info findings are informational and never affect the exit code.'
|
|
524
|
+
);
|
|
525
|
+
console.log('');
|
|
526
|
+
}
|
|
380
527
|
}
|
|
381
528
|
|
|
382
529
|
const reminders = report.manualReviewReminders ?? [];
|
|
@@ -406,14 +553,43 @@ function printTextReport(report, options = {}) {
|
|
|
406
553
|
console.log('=========================================================================');
|
|
407
554
|
}
|
|
408
555
|
|
|
409
|
-
|
|
556
|
+
const skipped = report.skippedRules ?? [];
|
|
557
|
+
if (skipped.length > 0) {
|
|
558
|
+
console.error('');
|
|
559
|
+
console.error(
|
|
560
|
+
`⚠️ ${skipped.length} 件のルールが実行に失敗して検査されていません(違反ゼロではなく「未検査」です):`
|
|
561
|
+
);
|
|
562
|
+
for (const entry of skipped) {
|
|
563
|
+
console.error(
|
|
564
|
+
` - [${entry.ruleId}] ${entry.message}(${entry.firstFile} を含む計 ${entry.fileCount} ファイル)`
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
console.error('');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (options.strict && hasBlockingIssues(report)) {
|
|
410
571
|
console.error('sparkle-design-cli check: failed because --strict was specified');
|
|
411
572
|
}
|
|
412
573
|
}
|
|
413
574
|
|
|
575
|
+
/**
|
|
576
|
+
* `--strict` / stop-hook を失敗させるべきか。
|
|
577
|
+
*
|
|
578
|
+
* blocking severity の findings に加えて、**実行に失敗したルールがある場合も失敗**
|
|
579
|
+
* とする。ルールが落ちた状態は「違反ゼロ」ではなく「検査していない」であり、
|
|
580
|
+
* ここを通してしまうと検査が死んでいることに誰も気付けない。
|
|
581
|
+
* en: Also fail when a rule crashed — that state is "not checked", not "clean".
|
|
582
|
+
*/
|
|
583
|
+
function hasBlockingIssues(report) {
|
|
584
|
+
return blockingFindings(report.findings).length > 0 || (report.skippedRules ?? []).length > 0;
|
|
585
|
+
}
|
|
586
|
+
|
|
414
587
|
function printJsonReport(report, options = {}) {
|
|
415
588
|
const strictMode = Boolean(options.strict);
|
|
416
|
-
const
|
|
589
|
+
const counts = countBySeverity(report.findings);
|
|
590
|
+
const blockingCount = blockingFindings(report.findings).length;
|
|
591
|
+
const skippedCount = (report.skippedRules ?? []).length;
|
|
592
|
+
const passed = !(strictMode && hasBlockingIssues(report));
|
|
417
593
|
const reminders = report.manualReviewReminders ?? [];
|
|
418
594
|
|
|
419
595
|
console.log(
|
|
@@ -423,6 +599,15 @@ function printJsonReport(report, options = {}) {
|
|
|
423
599
|
targetCount: report.targets.length,
|
|
424
600
|
checkedFileCount: report.checkedFiles.length,
|
|
425
601
|
findingCount: report.findings.length,
|
|
602
|
+
// severity 別内訳。`blockingFindingCount` だけが exit code に効く。
|
|
603
|
+
// en: Only blockingFindingCount affects the exit code.
|
|
604
|
+
severityCounts: counts,
|
|
605
|
+
blockingFindingCount: blockingCount,
|
|
606
|
+
// 実行に失敗して検査されなかったルールの数。0 でないなら findings が
|
|
607
|
+
// 0 件でも「クリーン」とは言えない(--strict は失敗する)。
|
|
608
|
+
// en: Rules that crashed and therefore did not run. Non-zero means the
|
|
609
|
+
// report is incomplete, so --strict fails even with zero findings.
|
|
610
|
+
skippedRuleCount: skippedCount,
|
|
426
611
|
reminderCount: reminders.length,
|
|
427
612
|
// AI 向けの attention フラグ。reminder が 1 件でもあれば true になり、
|
|
428
613
|
// AI は最終 response で各 reminder ID を echo する必要がある。
|
|
@@ -447,7 +632,13 @@ function printJsonReport(report, options = {}) {
|
|
|
447
632
|
);
|
|
448
633
|
}
|
|
449
634
|
|
|
450
|
-
|
|
635
|
+
/**
|
|
636
|
+
* `check` 本体。レポートを出力し、レポートそのものと「失敗させるべきか」を返す。
|
|
637
|
+
* stop-hook のように findings の内訳まで見たい呼び出し側のために report を返す。
|
|
638
|
+
* en: Runs the check, prints the report, and returns both the report and the
|
|
639
|
+
* blocking decision (stop-hook needs the breakdown, not just the boolean).
|
|
640
|
+
*/
|
|
641
|
+
export async function runCheck(targets = [], options = {}) {
|
|
451
642
|
// Plugins are discovered from the consumer project's package.json deps. Errors are
|
|
452
643
|
// already warn-and-skip inside loadAntiPatternPlugins, so we just consume the result.
|
|
453
644
|
// en: Auto-discover plugins from cwd; loader handles its own failure reporting.
|
|
@@ -467,12 +658,26 @@ export async function checkProject(targets = [], options = {}) {
|
|
|
467
658
|
printTextReport(report, options);
|
|
468
659
|
}
|
|
469
660
|
|
|
470
|
-
return report
|
|
661
|
+
return { report, blocked: hasBlockingIssues(report) };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function checkProject(targets = [], options = {}) {
|
|
665
|
+
const { blocked } = await runCheck(targets, options);
|
|
666
|
+
// 戻り値は「exit code を 1 にすべきか」。severity 導入前は findings が
|
|
667
|
+
// 1 件でもあれば true だったが、既存ルールはすべて error なので既存プロジェクト
|
|
668
|
+
// での挙動は変わらない。beta 中の移行 warning で CI や stop-hook を止めない。
|
|
669
|
+
// en: Returns "should this fail?" — unchanged for existing projects since all
|
|
670
|
+
// pre-existing rules are errors; beta migration warnings never block.
|
|
671
|
+
return blocked;
|
|
471
672
|
}
|
|
472
673
|
|
|
473
674
|
export {
|
|
474
675
|
BUILTIN_RULES as RULES,
|
|
676
|
+
blockingFindings,
|
|
677
|
+
coerceSeverity,
|
|
475
678
|
collectFindings,
|
|
679
|
+
countBySeverity,
|
|
476
680
|
createCheckReport,
|
|
681
|
+
hasBlockingIssues,
|
|
477
682
|
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
478
683
|
};
|
package/lib/plugin-api.js
CHANGED
|
@@ -27,13 +27,29 @@
|
|
|
27
27
|
* featureSection?: string, // markdown emitted into the published anti-pattern doc (esa)
|
|
28
28
|
* jsdocTargets?: JSDocTarget[], // optional — JSDoc injection targets in the plugin's own source
|
|
29
29
|
* check?: {
|
|
30
|
+
* severity?: 'error' | 'warning' | 'info',
|
|
31
|
+
* // defaults to 'error'. Only 'error' affects the exit code
|
|
32
|
+
* // of `check --strict` / the stop hook; 'warning' / 'info'
|
|
33
|
+
* // are reported but never fail. An unknown value warns and
|
|
34
|
+
* // falls back to 'error'.
|
|
35
|
+
* targets?: Array<'source' | 'css'>,
|
|
36
|
+
* // which file kinds the rule runs on.
|
|
37
|
+
* // defaults to ['source'] (.js/.jsx/.ts/.tsx).
|
|
38
|
+
* // Add 'css' for rules that match CSS variable
|
|
39
|
+
* // references or @apply utilities. Note: the built-in
|
|
40
|
+
* // migration rules additionally skip references to
|
|
41
|
+
* // variables the same file declares (definition side),
|
|
42
|
+
* // which is how generated stylesheets are excluded —
|
|
43
|
+
* // by content, not by filename.
|
|
30
44
|
* description: string, // shown in `check` findings
|
|
31
45
|
* recommendation: string, // shown in `check` findings
|
|
32
46
|
* pattern?: RegExp, // simple regex check. MUST have the global (`g`) flag.
|
|
33
47
|
* // mutually exclusive with `match`.
|
|
34
|
-
* match?: (content: string, helpers: MatchHelpers) => Array<{ index: number, text: string }>,
|
|
48
|
+
* match?: (content: string, helpers: MatchHelpers) => Array<{ index: number, text: string, recommendation?: string }>,
|
|
35
49
|
* // opt-in API for complex matching (2-pass, AST, etc.).
|
|
36
50
|
* // `helpers` (2nd arg) is injected by the CLI — see ## MatchHelpers.
|
|
51
|
+
* // A per-hit `recommendation` overrides `check.recommendation`,
|
|
52
|
+
* // for rules whose fix differs per occurrence (e.g. token migration).
|
|
37
53
|
* },
|
|
38
54
|
* }
|
|
39
55
|
*
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sparkle-design-cli rules` の実装。
|
|
3
|
+
*
|
|
4
|
+
* ## なぜコマンドなのか
|
|
5
|
+
*
|
|
6
|
+
* ルール一覧はこれまで README / `check --help` / internal の setup-guide /
|
|
7
|
+
* コンポーネントの JSDoc / スキルの features.md と 6 箇所に写しが散らばっていて、
|
|
8
|
+
* 実際に `check --help` は組み込み 18 件のうち 16 件しか載せておらず、しかも
|
|
9
|
+
* `check` のルールではない項目を 2 件含んだまま腐っていた。
|
|
10
|
+
*
|
|
11
|
+
* それ以上に本質的なのは、**静的なドキュメントには「有効なルール」を書けない**
|
|
12
|
+
* ということ。プラグインはコンシューマの package.json から自動発見されるので、
|
|
13
|
+
* 実際に効いているルールはプロジェクトごとに違う。組み込み 18 件を列挙した
|
|
14
|
+
* ドキュメントは、プラグインを入れた利用者にとって最初から不正確になる。
|
|
15
|
+
*
|
|
16
|
+
* en: The rule list used to be copied into six places and had already rotted.
|
|
17
|
+
* More fundamentally, static docs cannot state which rules are *active* — plugins
|
|
18
|
+
* are discovered from the consumer's package.json, so the effective set differs
|
|
19
|
+
* per project. This command is the only place that can answer that.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
24
|
+
RULE_TARGET,
|
|
25
|
+
SEVERITY,
|
|
26
|
+
getCheckRules,
|
|
27
|
+
} from './anti-pattern-rules.js';
|
|
28
|
+
import { loadAntiPatternPlugins } from './load-plugins.js';
|
|
29
|
+
|
|
30
|
+
/** 表示順。`check` のレポートと同じ「重いものが先」に揃える。 */
|
|
31
|
+
const SEVERITY_ORDER = [SEVERITY.ERROR, SEVERITY.WARNING, SEVERITY.INFO];
|
|
32
|
+
|
|
33
|
+
const SEVERITY_NOTE = {
|
|
34
|
+
[SEVERITY.ERROR]: '例外なし。--strict / stop-hook を失敗させる',
|
|
35
|
+
[SEVERITY.WARNING]: '原則ダメだが理由があれば例外可。exit code は変えない',
|
|
36
|
+
[SEVERITY.INFO]: '参考。従わなくてもよい選択肢',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 組み込み + プラグインのルールを集める。
|
|
41
|
+
*
|
|
42
|
+
* プラグイン由来かどうかを `source` に持たせる。利用者が「なぜこのルールが
|
|
43
|
+
* 出るのか」を追えるようにするためで、これが分からないと組み込みの不具合と
|
|
44
|
+
* プラグインの不具合を切り分けられない。
|
|
45
|
+
*/
|
|
46
|
+
export async function collectActiveRules(options = {}) {
|
|
47
|
+
// cwd を受けるのはテストのためだけではない。プラグインは**そのディレクトリの**
|
|
48
|
+
// package.json から発見されるので、どこを見たかで結果が変わる。既定は
|
|
49
|
+
// loadAntiPatternPlugins 側の既定(process.cwd())に委ねる。
|
|
50
|
+
// en: The discovered set depends on which directory is inspected.
|
|
51
|
+
const { groups: pluginGroups, discovered } = await loadAntiPatternPlugins(
|
|
52
|
+
options.cwd ? { cwd: options.cwd } : undefined
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// 出所は**結合前**に決める。結合後に ID で引き当てる方式だと、プラグインが
|
|
56
|
+
// 組み込みと同じ ID を名乗ったときに両方が builtin として表示され、
|
|
57
|
+
// 「見慣れないルールが出た」ときの切り分けができなくなる。ID の重複は
|
|
58
|
+
// getCheckRules も check 側も弾かない(両方のルールが実際に動く)ので、
|
|
59
|
+
// ここで潰さず、出所を正しく付けたうえで衝突として見せる。
|
|
60
|
+
// en: Derive the origin before merging. Looking it up by id afterwards would
|
|
61
|
+
// label a plugin rule that reuses a built-in id as `builtin`, defeating the
|
|
62
|
+
// whole point of the field. Duplicate ids are not rejected anywhere — both
|
|
63
|
+
// rules really run — so surface the clash instead of hiding it.
|
|
64
|
+
const shape = (source) => (rule) => ({
|
|
65
|
+
id: rule.id,
|
|
66
|
+
severity: rule.severity,
|
|
67
|
+
description: rule.description,
|
|
68
|
+
targets: rule.targets,
|
|
69
|
+
source,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const rules = [
|
|
73
|
+
...getCheckRules(BUILTIN_ANTI_PATTERN_GROUPS).map(shape('builtin')),
|
|
74
|
+
...getCheckRules(pluginGroups).map(shape('plugin')),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
return { rules, plugins: discovered };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function severityRank(severity) {
|
|
81
|
+
const index = SEVERITY_ORDER.indexOf(severity);
|
|
82
|
+
return index === -1 ? SEVERITY_ORDER.length : index;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sortForDisplay(rules) {
|
|
86
|
+
return [...rules].sort(
|
|
87
|
+
(a, b) => severityRank(a.severity) - severityRank(b.severity) || a.id.localeCompare(b.id)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `--format json` の出力。CI や AI から読む前提なので件数の内訳も添える。 */
|
|
92
|
+
export function renderRulesJson({ rules, plugins }) {
|
|
93
|
+
const counts = Object.fromEntries(
|
|
94
|
+
SEVERITY_ORDER.map((severity) => [
|
|
95
|
+
severity,
|
|
96
|
+
rules.filter((r) => r.severity === severity).length,
|
|
97
|
+
])
|
|
98
|
+
);
|
|
99
|
+
return JSON.stringify(
|
|
100
|
+
{
|
|
101
|
+
summary: {
|
|
102
|
+
ruleCount: rules.length,
|
|
103
|
+
duplicateIds: [
|
|
104
|
+
...new Set(
|
|
105
|
+
rules
|
|
106
|
+
.filter((rule, i) => rules.findIndex((r) => r.id === rule.id) !== i)
|
|
107
|
+
.map((r) => r.id)
|
|
108
|
+
),
|
|
109
|
+
],
|
|
110
|
+
severityCounts: counts,
|
|
111
|
+
builtinCount: rules.filter((r) => r.source === 'builtin').length,
|
|
112
|
+
pluginCount: rules.filter((r) => r.source === 'plugin').length,
|
|
113
|
+
},
|
|
114
|
+
rules: sortForDisplay(rules),
|
|
115
|
+
plugins: plugins.map((record) => ({
|
|
116
|
+
packageName: record.packageName,
|
|
117
|
+
status: record.status,
|
|
118
|
+
error: record.error ?? null,
|
|
119
|
+
ruleIds: record.groupIds ?? [],
|
|
120
|
+
})),
|
|
121
|
+
},
|
|
122
|
+
null,
|
|
123
|
+
2
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 既定のテキスト出力。 */
|
|
128
|
+
export function renderRulesText({ rules, plugins }) {
|
|
129
|
+
const lines = [];
|
|
130
|
+
const sorted = sortForDisplay(rules);
|
|
131
|
+
|
|
132
|
+
lines.push(`有効なルール: ${sorted.length} 件`);
|
|
133
|
+
|
|
134
|
+
for (const severity of SEVERITY_ORDER) {
|
|
135
|
+
const group = sorted.filter((rule) => rule.severity === severity);
|
|
136
|
+
if (group.length === 0) continue;
|
|
137
|
+
lines.push('', `[${severity}] ${group.length} 件 — ${SEVERITY_NOTE[severity]}`);
|
|
138
|
+
for (const rule of group) {
|
|
139
|
+
// `.css` も見るルールは既定と違うので明示する。どのファイルが検査対象か
|
|
140
|
+
// 分からないと「なぜ検出されないのか」を利用者が追えない。
|
|
141
|
+
const css = rule.targets?.includes(RULE_TARGET.CSS) ? ' (.css も検査)' : '';
|
|
142
|
+
const from = rule.source === 'plugin' ? ' [plugin]' : '';
|
|
143
|
+
lines.push(` ${rule.id}${from}${css}`);
|
|
144
|
+
lines.push(` ${rule.description}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const clashes = [
|
|
149
|
+
...new Set(
|
|
150
|
+
rules.filter((rule, i) => rules.findIndex((r) => r.id === rule.id) !== i).map((r) => r.id)
|
|
151
|
+
),
|
|
152
|
+
];
|
|
153
|
+
if (clashes.length > 0) {
|
|
154
|
+
lines.push('');
|
|
155
|
+
lines.push(`⚠️ 組み込みと同じ ID を名乗るプラグインルールがあります: ${clashes.join(', ')}`);
|
|
156
|
+
lines.push(
|
|
157
|
+
' どちらも実行されます。指摘の出所が分からなくなるので、プラグイン側の ID を変えてください。'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push('');
|
|
162
|
+
if (plugins.length === 0) {
|
|
163
|
+
lines.push('プラグイン: なし(組み込みルールのみ)');
|
|
164
|
+
} else {
|
|
165
|
+
lines.push('プラグイン:');
|
|
166
|
+
for (const record of plugins) {
|
|
167
|
+
const status = record.status === 'loaded' ? 'ok' : `error: ${record.error}`;
|
|
168
|
+
lines.push(` - ${record.packageName} (${status})`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
lines.push('');
|
|
172
|
+
lines.push('個別ルールの背景と対処は docs/anti-patterns.md を参照してください。');
|
|
173
|
+
lines.push('抑制するには `sparkle-disable-next-line <rule-id>` を使います。');
|
|
174
|
+
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function runRules(options = {}) {
|
|
179
|
+
const collected = await collectActiveRules(options);
|
|
180
|
+
console.log(options.format === 'json' ? renderRulesJson(collected) : renderRulesText(collected));
|
|
181
|
+
}
|