sparkle-design-cli 2.4.2 → 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/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
- recommendation: rule.recommendation,
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
- console.warn(
197
- `⚠️ sparkle-design-cli: rule "${rule.id ?? '(unknown)'}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
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]) => collectFindings(filePath, content, rules))
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
- console.log(`sparkle-design-cli check: ${report.findings.length} finding(s)\n`);
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
- console.log(`${finding.filePath}:${finding.line} [${finding.id}] ${finding.description}`);
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
- if (options.strict && report.findings.length > 0) {
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 passed = !(strictMode && report.findings.length > 0);
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
- export async function checkProject(targets = [], options = {}) {
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.findings.length > 0;
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
+ }