sparkle-design-cli 2.5.0-beta.3 → 2.5.0-beta.4

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/README.md CHANGED
@@ -74,6 +74,9 @@ npx sparkle-design-cli check src --format json
74
74
  # 現在有効なアンチパターンルールを一覧表示(プラグイン由来のものも含む)
75
75
  npx sparkle-design-cli rules
76
76
 
77
+ # 旧セマンティックトークンを新トークンへ移行(既定は dry-run。--write で書き換え)
78
+ npx --yes sparkle-design-cli migrate
79
+
77
80
  # Artifact Registry の npm registry 用トークンを ~/.npmrc に書く(beta)
78
81
  npx --yes sparkle-design-cli@beta auth
79
82
  ```
@@ -200,6 +203,21 @@ npx sparkle-design-cli rules --format json
200
203
 
201
204
  AI エージェントや CI からこの `lint:sparkle` を呼ぶ運用にすると、ガイドラインの注意書きだけに頼らず機械的に検査できます。
202
205
 
206
+ ### migrate: 新セマンティックトークンへの移行
207
+
208
+ `check` が警告する旧セマンティックトークン(`bg-primary-600` / `var(--radius-halfModal)` など)を新トークンへ書き換えます。
209
+
210
+ ```bash
211
+ npx --yes sparkle-design-cli migrate # src を dry-run(変更内容を diff 形式で表示するだけ)
212
+ npx --yes sparkle-design-cli migrate --write # [自動変換可] の箇所だけ書き換える
213
+ npx --yes sparkle-design-cli migrate --include 'src/components/**/*.tsx'
214
+ ```
215
+
216
+ - **既定は dry-run** で、`--write` を付けたときだけファイルを書き換えます。探索範囲は `check` と同じです(既定 `src`、パスを並べて指定可)
217
+ - 書き換えるのは移行先が一意に決まり値も変わらない `[自動変換可]` だけです。`[要判断]`(候補が複数ある等)と `[構造変更あり]` は報告するだけで置換しません
218
+
219
+ 分類の考え方は [docs/anti-patterns.md](docs/anti-patterns.md#新セマンティックトークンへの移行beta-期間中) を参照してください。
220
+
203
221
  ### auth: Artifact Registry の認証(beta)
204
222
 
205
223
  社内パッケージの配信先を Google Artifact Registry(AR)に移行するための準備です(goodpatch/sparkle-design-internal#261)。Google の資格情報から AR 用のアクセストークンを取得し、ユーザー単位の `~/.npmrc` に書きます。
@@ -8,11 +8,13 @@ import { PLUGIN_SPEC_MARKDOWN } from '../lib/plugin-api.js';
8
8
  import { loadAntiPatternPlugins } from '../lib/load-plugins.js';
9
9
  import { runRules } from '../lib/rules-report.js';
10
10
  import { runAuth } from '../lib/auth.js';
11
+ import { runMigrate } from '../lib/migrate.js';
11
12
 
12
13
  const SUBCOMMANDS = new Set([
13
14
  'generate',
14
15
  'check',
15
16
  'rules',
17
+ 'migrate',
16
18
  'setup',
17
19
  'auth',
18
20
  'stop-hook',
@@ -179,6 +181,43 @@ function parseRulesOptions(args) {
179
181
  return options;
180
182
  }
181
183
 
184
+ /**
185
+ * `migrate` のオプション。
186
+ *
187
+ * 既定は dry-run。`--write` を付けたときだけファイルを書き換える(不可逆操作を
188
+ * デフォルトにしない)。`--dry-run` は既定と同じだが、明示したい人のために受け付ける。
189
+ * 両方指定は意図が矛盾するのでその場で失敗させる。
190
+ * en: Dry-run by default; only `--write` rewrites files. `--dry-run` together
191
+ * with `--write` is contradictory and fails fast.
192
+ */
193
+ function parseMigrateOptions(args) {
194
+ const options = { targets: [], includes: [], write: false, dryRun: false, help: false };
195
+
196
+ for (let i = 0; i < args.length; i += 1) {
197
+ const arg = args[i];
198
+ if (arg === '-h' || arg === '--help') {
199
+ options.help = true;
200
+ } else if (arg === '--write') {
201
+ options.write = true;
202
+ } else if (arg === '--dry-run') {
203
+ options.dryRun = true;
204
+ } else if (arg === '--include') {
205
+ options.includes.push(requireOptionValue(args, i, '--include'));
206
+ i += 1;
207
+ } else if (arg.startsWith('-')) {
208
+ throw new Error(`Unknown option for migrate: ${arg}`);
209
+ } else {
210
+ options.targets.push(arg);
211
+ }
212
+ }
213
+
214
+ if (options.write && options.dryRun) {
215
+ throw new Error('--write と --dry-run は同時に指定できません');
216
+ }
217
+
218
+ return options;
219
+ }
220
+
182
221
  /**
183
222
  * `auth` のオプション。未知のフラグはその場で失敗させる(他のサブコマンドと同じ)。
184
223
  * en: Unknown flags fail fast, same as the other subcommands.
@@ -214,6 +253,7 @@ Commands:
214
253
  generate sparkle.config.json から CSS を生成
215
254
  check Sparkle Design のアンチパターンを検査
216
255
  rules 現在有効なアンチパターンルールを一覧表示(プラグイン由来のものも含む)
256
+ migrate 旧セマンティックトークンを新トークンへ移行(既定は dry-run。--write で書き換え)
217
257
  setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
218
258
  auth Artifact Registry の npm registry 用アクセストークンを ~/.npmrc に書く(beta)
219
259
  stop-hook AI assistant の Stop hook 用 internal subcommand(severity=error の findings か、
@@ -237,6 +277,12 @@ Check:
237
277
  sparkle-design-cli check src --format json
238
278
  sparkle-design-cli check src/components src/features
239
279
 
280
+ Migrate:
281
+ sparkle-design-cli migrate # src を dry-run(変更内容を表示するだけ)
282
+ sparkle-design-cli migrate --include 'src/components/**/*.tsx'
283
+ sparkle-design-cli migrate --write # [自動変換可] の箇所だけ書き換える
284
+ sparkle-design-cli migrate src/app src/features --write
285
+
240
286
  Rules:
241
287
  sparkle-design-cli rules # 有効なルールを severity 別に一覧表示
242
288
  sparkle-design-cli rules --format json # CI / AI 向け
@@ -313,6 +359,23 @@ Check options:
313
359
  (warning / info は報告のみで exit code に影響しない)
314
360
  --format <text|json> 出力形式 (default: text)
315
361
 
362
+ Migrate options:
363
+ -h, --help このヘルプメッセージを表示
364
+ [path ...] 探索するパス(default: src。check と同じ探索範囲。
365
+ .js / .jsx / .ts / .tsx / .css、node_modules と .git は除外)
366
+ --dry-run ファイルを変更せず、変更内容を diff 形式で表示する(既定)
367
+ --write [自動変換可] の箇所だけを書き換える
368
+ --include <glob> 対象を cwd からの相対パス(cwd の外は絶対パス)が glob に
369
+ マッチするファイルに絞る(複数指定可。** / * / ? / {a,b} に対応)
370
+
371
+ Migrate の分類:
372
+ 自動変換可 bg- / text- / border- などの用途と variant 接頭辞(hover: 等)から移行先が
373
+ 一意に決まり、値も変わらないもの。--write のときだけ置換する
374
+ 要判断 移行先候補が複数ある / 対応する新トークンが無いもの。候補を表示するだけで置換しない
375
+ 構造変更あり トークンの差し替えでは済まない(マークアップ変更を伴う)もの。指摘するだけで置換しない
376
+ 対応表は check の移行ルールと共通(lib/token-migration.js)。check で抑制コメント
377
+ (sparkle-disable-line <rule-id>)を付けた箇所は migrate でも書き換えない
378
+
316
379
  Auth options:
317
380
  -h, --help このヘルプメッセージを表示
318
381
  --registry <url> 対象の registry(default: プロジェクトの .npmrc の @goodpatch:registry)
@@ -501,6 +564,16 @@ async function main() {
501
564
  return;
502
565
  }
503
566
 
567
+ if (command === 'migrate') {
568
+ const options = parseMigrateOptions(args.slice(1));
569
+ if (options.help) {
570
+ showHelp();
571
+ process.exit(0);
572
+ }
573
+ runMigrate({ targets: options.targets, includes: options.includes, write: options.write });
574
+ return;
575
+ }
576
+
504
577
  if (command === 'plugin-spec') {
505
578
  await runPluginSpec(args.slice(1));
506
579
  return;
@@ -55,6 +55,27 @@ src/Card.tsx:8 [warning] [legacy-color-token] 旧セマンティックカラー
55
55
  >
56
56
  > 旧→新の対応表は `lib/token-migration.js` に一元化してあり、`test/token-migration.test.js` が**実際に生成した CSS を読んで**「表に書いた移行先が実在し、旧トークンと同じ実値に解決される」ことを検証しています。表とトークン定義が食い違ったらテストが落ちるので、案内が嘘になりません。検出パターンがマッチする文字列は必ず対応表で解決できることも全数検証しています(片方だけ更新すると「検出したのに黙って捨てる」穴になるため)。
57
57
 
58
+ ### `migrate` で書き換える
59
+
60
+ `check` が提示する移行先のうち `[自動変換可]` のものは、`migrate` サブコマンドでまとめて書き換えられます。対応表は `check` と同じ `lib/token-migration.js` を使うので、`check` の案内と `migrate` の書き換え先が食い違うことはありません。
61
+
62
+ ```bash
63
+ npx --yes sparkle-design-cli migrate # dry-run。変更内容を diff 形式で表示するだけ
64
+ npx --yes sparkle-design-cli migrate --write # [自動変換可] の箇所だけ書き換える
65
+ ```
66
+
67
+ | 分類 | `migrate` の挙動 |
68
+ | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
69
+ | 自動変換可 | 用途(`bg-` / `text-` / `border-` …)と variant 接頭辞から移行先が一意に決まり、値も変わらない。`--write` のときだけ置換する |
70
+ | 要判断 | 移行先候補が複数ある / 対応する新トークンが無い。候補を列挙するだけで置換しない |
71
+ | 構造変更あり | トークンの差し替えでは済まない(マークアップ変更を伴う)。該当箇所を指摘するだけ |
72
+
73
+ - variant 接頭辞(`hover:` / `group-hover:` / `dark:` …)、opacity modifier(`/50`)、important(`!`)は置換後もそのまま残ります
74
+ - `check` で報告しないもの(そのファイル自身が宣言している変数・行頭から始まるブロックコメント)は `migrate` も書き換えません。`sparkle-disable-line legacy-color-token` などの抑制コメントを付けた箇所も書き換えません(誤検出を抑制した箇所を黙って書き換えないため)
75
+ - 書き換えた後に 2 回目を実行しても、`migrate` の `[自動変換可]` は 0 件になります(新トークンは検出パターンにマッチしない)
76
+ - **`check` が `[自動変換可]` と案内しても、`migrate` が `[要判断]` として書き換えずに残す箇所があります。** 検出はファイル全体への正規表現なので、パス(`'/img/fill-primary-600.svg'` / `"bg-primary-600/hero.png"`)、import / require の specifier、`url()` の中のようなクラス名でない文字列にもマッチします。`check` は報告するだけなので案内を変えていませんが、`migrate` は資産パスや import を壊さないよう、これらと数値・角括弧以外の modifier(`/data.json` など)付きのものを書き換えません。クラス名として使っている箇所なら手で置き換えてください
77
+ - `--include <glob>` で対象を cwd からの相対パスで絞れます(`**` / `*` / `?` / `{a,b}`)
78
+
58
79
  ## 余白のスケール(`use-figma-spacing-scale`)
59
80
 
60
81
  Figma の余白は 17 段(0 / 2 / 4 / 6 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48 / 56 / 72 / 88 / 104 / 120 px)です。Tailwind の 1 ステップは 4px なので、`p-4` = 16px、`p-6` = 24px が対応します。このスケールから外れた値を `info` で報告し、前後の候補を提示します。
@@ -10,6 +10,7 @@ import {
10
10
  resolveTailwindPaletteUtility,
11
11
  } from './token-migration.js';
12
12
  import { buildSpacingUtilityPattern, pxToStep, resolveSpacingStep } from './spacing-scale.js';
13
+ import { findOpeningTagEnd } from './plugin-helpers.js';
13
14
 
14
15
  const COMMON_INTRO =
15
16
  '以下は頻繁に発生する誤用パターンです。コンポーネントが提供する専用 props・サブコンポーネントを使ってください。';
@@ -136,6 +137,93 @@ const BUILTIN_MANUAL_REVIEW_REMINDERS = [
136
137
  },
137
138
  ];
138
139
 
140
+ // --- JSX 開きタグを扱う match 用の小道具 ----------------------------------------
141
+ //
142
+ // フォーム系の a11y ルール(form-control-select-root / handwritten-radio-role /
143
+ // handwritten-form-error)は「どのタグの属性か」「直下の子は何か」を見る必要があり、
144
+ // 1 本の regex では prop 値中の `=>` や `{...}` で打ち切られる。プラグインに注入して
145
+ // いる findOpeningTagEnd を組み込みルールでも使い、同じパース実装に揃える。
146
+ // en: Helpers for the form a11y rules, built on the same findOpeningTagEnd that
147
+ // plugins receive, so built-in and plugin rules share one tag scanner.
148
+
149
+ // カスタム要素(`<my-radio>`)も拾えるよう `-` を許す
150
+ // en: Allow `-` so custom elements such as `<my-radio>` are scanned too.
151
+ const JSX_OPENING_TAG = /<([A-Za-z][\w.-]*)(?=[\s/>])/g;
152
+
153
+ function blankOut(text) {
154
+ return text.replace(/[^\n]/g, ' ');
155
+ }
156
+
157
+ /**
158
+ * コメント中の疑似 JSX(`// Radix は <button role="radio"> を描画する` のような説明)を
159
+ * 検出しないよう、コメントを同じ長さの空白で潰す。index と行番号はずれない。
160
+ * 行頭からのブロックコメント(maskBlockComments と同じ前提)・JSX コメント
161
+ * `{/* ... *\/}`・行頭からの `//` コメントが対象。
162
+ * en: Blank out comments (same length, so offsets stay valid) so explanatory
163
+ * pseudo-JSX inside comments is not reported.
164
+ */
165
+ function maskSourceComments(content) {
166
+ return maskBlockComments(content)
167
+ .replace(/\{\s*\/\*(?:(?!\*\/)[\s\S])*\*\/\s*\}/g, blankOut)
168
+ .replace(/^[ \t]*\/\/.*$/gm, blankOut);
169
+ }
170
+
171
+ /**
172
+ * `index` から始まる開きタグを読む。読めなければ null。
173
+ * en: Read the opening tag starting at `index`, or null.
174
+ */
175
+ function readOpeningTag(content, index, tagName) {
176
+ const bodyStart = index + 1 + tagName.length;
177
+ const end = findOpeningTagEnd(content, bodyStart);
178
+ if (end === -1) return null;
179
+ const props = content.slice(bodyStart, end);
180
+ return {
181
+ index,
182
+ end,
183
+ tagName,
184
+ props,
185
+ selfClosing: props.trimEnd().endsWith('/'),
186
+ text: content.slice(index, end + 1),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * `index` の直前にある「閉じていない開きタグ」(=親要素)を返す。直前が
192
+ * whitespace を挟んで開きタグの `>` で終わっている場合だけを親とみなす
193
+ * (テキストや兄弟要素を挟む場合は追わない。ヒューリスティック用途のため)。
194
+ * en: The opening tag that immediately precedes `index` (only whitespace in
195
+ * between), i.e. the direct parent in the common `<span><Icon /></span>` shape.
196
+ */
197
+ function findImmediateParentTag(content, index) {
198
+ const windowStart = Math.max(0, index - 2000);
199
+ const before = content.slice(windowStart, index);
200
+ const trimmed = before.trimEnd();
201
+ if (!trimmed.endsWith('>') || trimmed.endsWith('/>')) return null;
202
+ const closeAt = windowStart + trimmed.length - 1;
203
+ // 走査を closeAt までに切る。content 全体を渡すと、閉じ `>` が遠い候補(TS の
204
+ // ジェネリクスなど)ごとにファイル末尾まで走査してしまう。
205
+ // en: Bound the scan at closeAt so each candidate can't walk to EOF.
206
+ const bounded = content.slice(0, closeAt + 1);
207
+ let parent = null;
208
+ for (const opener of before.matchAll(JSX_OPENING_TAG)) {
209
+ const tag = readOpeningTag(bounded, windowStart + opener.index, opener[1]);
210
+ if (tag && tag.end === closeAt && !tag.selfClosing) parent = tag;
211
+ }
212
+ return parent;
213
+ }
214
+
215
+ // role="radio" / role="radiogroup"(JSX 式 `role={"radio"}` も)。`aria-role` /
216
+ // `data-role` を拾わないよう、直前を空白か先頭に限る。
217
+ // en: role="radio" / role="radiogroup", excluding aria-role / data-role.
218
+ const RADIO_ROLE_ATTR =
219
+ /(?:^|\s)role\s*=\s*(?:(["'])(?:radio|radiogroup)\1|\{\s*(["'`])(?:radio|radiogroup)\2\s*\})/;
220
+
221
+ const ERROR_ICON_ATTR = /(?:^|\s)icon\s*=\s*(?:(["'])error\1|\{\s*(["'`])error\2\s*\})/;
222
+
223
+ // 旧体系 `text-negative-500` と新体系 `text-text-negative-enabled` の両方
224
+ // en: Both the legacy (`text-negative-*`) and new (`text-text-negative-*`) tokens.
225
+ const NEGATIVE_TEXT_COLOR = /(?<![\w-])text-(?:text-)?negative-[\w-]+/;
226
+
139
227
  const COMPONENT_ANTI_PATTERN_GROUPS = [
140
228
  {
141
229
  id: 'card-description',
@@ -1302,6 +1390,239 @@ const COMPONENT_ANTI_PATTERN_GROUPS = [
1302
1390
  ]),
1303
1391
  jsdocTargets: [],
1304
1392
  },
1393
+ {
1394
+ id: 'form-control-select-root',
1395
+ check: {
1396
+ description: 'FormControl で Select のルートを包まない(ラベルが関連付かない)',
1397
+ recommendation:
1398
+ 'FormControl は SelectTrigger を包んでください(<Select><FormControl><SelectTrigger>…</SelectTrigger></FormControl><SelectContent>…</SelectContent></Select>)。FormControl は id / aria-describedby / aria-invalid を直下の子へ渡しますが、Select のルートは DOM を持たないため id がどこにも付かず、FormHeader の <label for> が何も指さなくなります。',
1399
+ // 直下の最初の子要素が <Select> のときだけ。<Select> の中の <FormControl>
1400
+ // (正しい形)や、<FormControl> 直下が <SelectTrigger> のものは拾わない。
1401
+ // en: Only when the first direct child of <FormControl> is <Select>.
1402
+ match: (rawContent) => {
1403
+ const content = maskSourceComments(rawContent);
1404
+ const hits = [];
1405
+ for (const opener of content.matchAll(/<FormControl(?=[\s/>])/g)) {
1406
+ const tag = readOpeningTag(content, opener.index, 'FormControl');
1407
+ if (!tag || tag.selfClosing) continue;
1408
+ const child = /^\s*<([A-Za-z][\w.-]*)(?=[\s/>])/.exec(content.slice(tag.end + 1));
1409
+ if (child?.[1] !== 'Select') continue;
1410
+ hits.push({
1411
+ index: tag.index,
1412
+ text: content.slice(tag.index, tag.end + 1 + child[0].length),
1413
+ });
1414
+ }
1415
+ return hits;
1416
+ },
1417
+ },
1418
+ featureSection: lines([
1419
+ '### Select をフォームで使うときは FormControl で SelectTrigger を包む',
1420
+ '',
1421
+ '```tsx',
1422
+ '// ✅ Correct — FormControl は SelectTrigger を包む',
1423
+ '<FormItem>',
1424
+ ' <FormHeader label="プラン" />',
1425
+ ' <Select value={field.value} onValueChange={field.onChange}>',
1426
+ ' <FormControl>',
1427
+ ' <SelectTrigger>',
1428
+ ' <SelectValue placeholder="選択してください" />',
1429
+ ' </SelectTrigger>',
1430
+ ' </FormControl>',
1431
+ ' <SelectContent>',
1432
+ ' <SelectItem value="free">Free</SelectItem>',
1433
+ ' </SelectContent>',
1434
+ ' </Select>',
1435
+ '</FormItem>',
1436
+ '',
1437
+ '// ❌ Wrong — Select のルートを FormControl で包まない',
1438
+ '<FormControl>',
1439
+ ' <Select value={field.value} onValueChange={field.onChange}>',
1440
+ ' <SelectTrigger>…</SelectTrigger>',
1441
+ ' <SelectContent>…</SelectContent>',
1442
+ ' </Select>',
1443
+ '</FormControl>',
1444
+ '```',
1445
+ '',
1446
+ '`FormControl` は Slot で `id` / `aria-describedby` / `aria-invalid` を直下の子に渡す。`Select` のルートは DOM を持たないので id がどこにも付かず、`FormHeader` の `<label for>` が何も指さない(ラベルを押してもフォーカスせず、読み上げでも関連が伝わらない)。',
1447
+ ]),
1448
+ jsdocTargets: [
1449
+ {
1450
+ file: 'src/components/ui/form/index.tsx',
1451
+ targetName: 'FormControl',
1452
+ section: {
1453
+ bullets: [
1454
+ {
1455
+ ja: '`Select` と組み合わせるときは `Select` のルートではなく `SelectTrigger` を包んでください。`Select` のルートは DOM を持たないため、渡した `id` が付かずラベルが関連付きません。',
1456
+ en: 'With `Select`, wrap `SelectTrigger` rather than the `Select` root. The root renders no DOM, so the forwarded `id` lands nowhere and the label is not associated.',
1457
+ },
1458
+ ],
1459
+ example: lines([
1460
+ '// ✅ Correct',
1461
+ '<Select value={field.value} onValueChange={field.onChange}>',
1462
+ ' <FormControl>',
1463
+ ' <SelectTrigger>',
1464
+ ' <SelectValue placeholder="選択してください" />',
1465
+ ' </SelectTrigger>',
1466
+ ' </FormControl>',
1467
+ ' <SelectContent>...</SelectContent>',
1468
+ '</Select>',
1469
+ '',
1470
+ '// ❌ Wrong - Select のルートを包まない',
1471
+ '<FormControl>',
1472
+ ' <Select>...</Select>',
1473
+ '</FormControl>',
1474
+ ]),
1475
+ },
1476
+ },
1477
+ ],
1478
+ },
1479
+ {
1480
+ id: 'handwritten-radio-role',
1481
+ check: {
1482
+ // 原則ダメだが、Radio / Select / SegmentedControl のどれにも収まらない
1483
+ // 見た目の選択 UI(色見本など)で手組みを選ぶ判断はありうるので warning。
1484
+ // en: warning, not error — a visual picker that none of the components fit
1485
+ // can be a deliberate exception (suppress it with a comment).
1486
+ severity: SEVERITY.WARNING,
1487
+ description: '単一選択を role="radio" / role="radiogroup" で手組みしない',
1488
+ recommendation:
1489
+ '単一選択には Radio(選択肢が少なく常に 1 つ選ばれている)/ Select(選択肢が多い・表示スペースが狭い)/ sparkle-design-internal の SegmentedControl(2〜5 個の切り替え)を使ってください。手組みの role="radio" は矢印キーでの移動・roving tabindex・aria-checked の同期を自前で正しく実装する必要があります。どうしても手組みが必要な場合は `// sparkle-disable-next-line handwritten-radio-role` で理由を添えて残してください。',
1490
+ match: (rawContent) => {
1491
+ if (!/role\s*=\s*\{?\s*["'`]radio/.test(rawContent)) return [];
1492
+ const content = maskSourceComments(rawContent);
1493
+ const hits = [];
1494
+ for (const opener of content.matchAll(JSX_OPENING_TAG)) {
1495
+ // Radio / RadioGroup / SegmentedControl 自身(とその実装の Radix
1496
+ // primitive)は role を持つのが正しいので対象外
1497
+ // en: The components themselves legitimately carry these roles.
1498
+ if (/Radio|SegmentedControl/.test(opener[1])) continue;
1499
+ const tag = readOpeningTag(content, opener.index, opener[1]);
1500
+ if (tag && RADIO_ROLE_ATTR.test(tag.props)) {
1501
+ hits.push({ index: tag.index, text: tag.text });
1502
+ }
1503
+ }
1504
+ return hits;
1505
+ },
1506
+ },
1507
+ featureSection: lines([
1508
+ '### 単一選択を role="radio" で手組みしない',
1509
+ '',
1510
+ '```tsx',
1511
+ '// ✅ Correct — 選択肢が少なく常に 1 つ選ばれている',
1512
+ '<Radio value={value} onValueChange={setValue}>',
1513
+ ' <RadioItem value="sm" label="小" />',
1514
+ ' <RadioItem value="md" label="中" />',
1515
+ '</Radio>',
1516
+ '',
1517
+ '// ✅ Correct — 選択肢が多い・表示スペースが狭い',
1518
+ '<Select value={value} onValueChange={setValue}>…</Select>',
1519
+ '',
1520
+ '// ❌ Wrong — button に role="radio" を付けて手組みしない',
1521
+ '<div role="radiogroup">',
1522
+ ' <button type="button" role="radio" aria-checked={value === "sm"}>小</button>',
1523
+ ' <button type="button" role="radio" aria-checked={value === "md"}>中</button>',
1524
+ '</div>',
1525
+ '```',
1526
+ '',
1527
+ '単一選択は `Radio` / `Select` / sparkle-design-internal の `SegmentedControl`(2〜5 個の切り替え)から選ぶ。手組みの `role="radio"` は矢印キーでの移動や `aria-checked` の同期を自前で実装することになり、漏れやすい。',
1528
+ ]),
1529
+ jsdocTargets: [],
1530
+ },
1531
+ {
1532
+ id: 'handwritten-form-error',
1533
+ check: {
1534
+ // 「error アイコン + negative 系の文字色が並ぶ」というヒューリスティックで、
1535
+ // 入力欄に紐づかないページ全体のエラー表示なども拾いうるので info に留める。
1536
+ // en: Heuristic (error icon next to a negative text color) — it can also hit
1537
+ // page-level errors that aren't tied to a field, so keep it at info.
1538
+ severity: SEVERITY.INFO,
1539
+ description: 'フォームのエラー表示を Icon + negative 色で手組みしない',
1540
+ recommendation:
1541
+ '入力欄のエラーは FormErrorMessage を使ってください。FormErrorMessage は aria-describedby / aria-invalid で入力欄と関連付くため、読み上げでどの欄のエラーかが伝わります(手組みでは伝わりません)。入力欄に紐づかないエラー(ページ全体の失敗など)なら InlineMessage を検討し、意図的な手組みであれば `// sparkle-disable-next-line handwritten-form-error` で残してください。',
1542
+ // `<Icon icon="error">` の (1) 自身 (2) 直上の親 (3) 直後の兄弟 のいずれかに
1543
+ // negative 系の文字色があれば手組みのエラー表示とみなす。親に data-slot が
1544
+ // 付いているもの(FormErrorMessage など Sparkle 本体の実装)は対象外。
1545
+ // en: Flag `<Icon icon="error">` when the icon itself, its immediate parent,
1546
+ // or its next sibling carries a negative text color. Parents with
1547
+ // `data-slot` are Sparkle's own implementation (FormErrorMessage) — skip.
1548
+ match: (rawContent) => {
1549
+ if (!/icon\s*=\s*\{?\s*["'`]error/.test(rawContent)) return [];
1550
+ const content = maskSourceComments(rawContent);
1551
+ const hits = [];
1552
+ for (const opener of content.matchAll(/<Icon(?=[\s/>])/g)) {
1553
+ const icon = readOpeningTag(content, opener.index, 'Icon');
1554
+ if (!icon || !ERROR_ICON_ATTR.test(icon.props)) continue;
1555
+
1556
+ const parent = findImmediateParentTag(content, icon.index);
1557
+ if (
1558
+ parent &&
1559
+ (/(?:^|\s)data-slot\s*=/.test(parent.props) || parent.tagName === 'FormErrorMessage')
1560
+ ) {
1561
+ continue;
1562
+ }
1563
+
1564
+ let sibling = null;
1565
+ if (icon.selfClosing) {
1566
+ const next = /^\s*<([A-Za-z][\w.-]*)(?=[\s/>])/.exec(content.slice(icon.end + 1));
1567
+ if (next) {
1568
+ sibling = readOpeningTag(content, icon.end + 1 + next[0].indexOf('<'), next[1]);
1569
+ }
1570
+ }
1571
+
1572
+ if ([icon, parent, sibling].some((tag) => tag && NEGATIVE_TEXT_COLOR.test(tag.props))) {
1573
+ hits.push({ index: icon.index, text: icon.text });
1574
+ }
1575
+ }
1576
+ return hits;
1577
+ },
1578
+ },
1579
+ featureSection: lines([
1580
+ '### フォームのエラー表示は FormErrorMessage を使う',
1581
+ '',
1582
+ '```tsx',
1583
+ '// ✅ Correct — 入力欄と aria-describedby / aria-invalid で関連付く',
1584
+ '<FormItem>',
1585
+ ' <FormHeader label="招待コード" />',
1586
+ ' <FormControl>',
1587
+ ' <Input {...field} isInvalid={fieldState.invalid} />',
1588
+ ' </FormControl>',
1589
+ ' <FormErrorMessage />',
1590
+ '</FormItem>',
1591
+ '',
1592
+ '// ❌ Wrong — Icon と negative 色でエラー表示を手組みしない',
1593
+ '<span className="flex items-center gap-1 text-text-negative-enabled">',
1594
+ ' <Icon icon="error" size={3} />',
1595
+ ' コードが見つかりません',
1596
+ '</span>',
1597
+ '```',
1598
+ '',
1599
+ '手組みのエラー表示は見た目が同じでも入力欄との関連が無く、スクリーンリーダーではどの欄のエラーかが伝わらない。入力欄に紐づかないエラーは `InlineMessage` を使う。',
1600
+ ]),
1601
+ jsdocTargets: [
1602
+ {
1603
+ file: 'src/components/ui/form/index.tsx',
1604
+ targetName: 'FormErrorMessage',
1605
+ section: {
1606
+ bullets: [
1607
+ {
1608
+ ja: '`<Icon icon="error" />` と negative 系の文字色でエラー表示を手組みしないでください。`FormErrorMessage` は `aria-describedby` / `aria-invalid` で入力欄と関連付きます。',
1609
+ en: 'Do not hand-roll error text with `<Icon icon="error" />` and a negative text color. `FormErrorMessage` is linked to the field via `aria-describedby` / `aria-invalid`.',
1610
+ },
1611
+ ],
1612
+ example: lines([
1613
+ '// ✅ Correct',
1614
+ '<FormErrorMessage />',
1615
+ '',
1616
+ '// ❌ Wrong - 手組みのエラー表示',
1617
+ '<span className="text-text-negative-enabled">',
1618
+ ' <Icon icon="error" size={3} />',
1619
+ ' コードが見つかりません',
1620
+ '</span>',
1621
+ ]),
1622
+ },
1623
+ },
1624
+ ],
1625
+ },
1305
1626
  ];
1306
1627
 
1307
1628
  // Built-in groups always come before plugin-supplied groups in this priority order.
@@ -1351,6 +1672,23 @@ function maskBlockComments(content) {
1351
1672
  return content.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, (comment) => comment.replace(/[^\n]/g, ' '));
1352
1673
  }
1353
1674
 
1675
+ /**
1676
+ * そのファイル自身が宣言している CSS カスタムプロパティ名の集合。
1677
+ *
1678
+ * `check` の移行ルールと `migrate` サブコマンドの両方が「定義側は移行対象の
1679
+ * 使用ではない」という同じ判定を使う。片方だけ直すと、check は黙っているのに
1680
+ * migrate が生成物の定義を書き換える(あるいはその逆)というずれが生まれるため、
1681
+ * 1 箇所にまとめている。
1682
+ * en: Custom properties declared by this file. Shared by `check` and `migrate`
1683
+ * so both agree that definitions are not usages to migrate.
1684
+ *
1685
+ * @param {string} content ブロックコメントをマスク済みの内容
1686
+ * @returns {Set<string>}
1687
+ */
1688
+ function collectDeclaredCustomProperties(content) {
1689
+ return new Set([...content.matchAll(/(?:^|[{;])\s*(--[\w-]+)\s*:/gm)].map((m) => m[1]));
1690
+ }
1691
+
1354
1692
  function migrationMatcher(pattern, resolve, label) {
1355
1693
  return (rawContent) => {
1356
1694
  const content = maskBlockComments(rawContent);
@@ -1369,9 +1707,7 @@ function migrationMatcher(pattern, resolve, label) {
1369
1707
  // CSS や minify された theme ファイルで**定義側まで「使用」として報告**する。
1370
1708
  // en: Also treat `{`/`;` as declaration starts so single-line/minified CSS
1371
1709
  // doesn't get its own definitions reported as usages.
1372
- const declaredHere = new Set(
1373
- [...content.matchAll(/(?:^|[{;])\s*(--[\w-]+)\s*:/gm)].map((m) => m[1])
1374
- );
1710
+ const declaredHere = collectDeclaredCustomProperties(content);
1375
1711
 
1376
1712
  const hits = [];
1377
1713
  for (const match of content.matchAll(pattern)) {
@@ -1761,6 +2097,9 @@ const BUILTIN_CHECK_ORDER = [
1761
2097
  'disabled-vs-is-disabled',
1762
2098
  'button-prefixicon-jsx',
1763
2099
  'icon-children-text',
2100
+ 'form-control-select-root',
2101
+ 'handwritten-radio-role',
2102
+ 'handwritten-form-error',
1764
2103
  // NOTE: この配列が決めるのは **ルールの評価順** だけで、レポートの表示順では
1765
2104
  // ない(findings は check.js 側で severity 優先にソートされる)。移行ルールを
1766
2105
  // 末尾に置いているのは評価順を安定させるためで、「warning が error を埋もれ
@@ -1814,9 +2153,11 @@ export {
1814
2153
  BUILTIN_ANTI_PATTERN_GROUPS as ANTI_PATTERN_GROUPS,
1815
2154
  BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
1816
2155
  COMMON_INTRO,
2156
+ collectDeclaredCustomProperties,
1817
2157
  getAllJSDocTargets,
1818
2158
  getCheckRules,
1819
2159
  getManualReviewReminders,
2160
+ maskBlockComments,
1820
2161
  renderFeatureSections,
1821
2162
  renderJSDocSection,
1822
2163
  };
package/lib/check.js CHANGED
@@ -696,9 +696,11 @@ export {
696
696
  BUILTIN_RULES as RULES,
697
697
  blockingFindings,
698
698
  coerceSeverity,
699
+ collectFiles,
699
700
  collectFindings,
700
701
  countBySeverity,
701
702
  createCheckReport,
702
703
  hasBlockingIssues,
704
+ isSuppressed,
703
705
  BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
704
706
  };