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

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.
@@ -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',
@@ -377,9 +465,12 @@ const COMPONENT_ANTI_PATTERN_GROUPS = [
377
465
  {
378
466
  id: 'shadcn-token',
379
467
  check: {
380
- description: 'shadcn/ui 既定 token を Sparkle Design 内へ持ち込まない',
468
+ // 検出はファイル全体のクラス名に対して行う(Sparkle コンポーネント内かどうかは
469
+ // 見ない)ので、説明文もその範囲に合わせる(#104)。
470
+ // en: Detection is file-wide, not limited to Sparkle components (#104).
471
+ description: 'shadcn/ui 既定 token を使わず、Sparkle Design の token を使う',
381
472
  recommendation:
382
- 'text-muted-foreground / bg-background / border-border などは Sparkle Design token に置き換えてください。',
473
+ 'text-muted-foreground / bg-background / border-border などは Sparkle Design token に置き換えてください。まず置き換えを検討し、置き換えられない理由がある場合(プロジェクトが自前の CSS でこれらを定義し、Sparkle 以外の UI で意図して使っている等)に限り、sparkle.config.json の `check.allowTokens` で宣言できます。',
383
474
  // 新セマンティックトークン(border-border-neutral-* 等)へ前方一致しないよう、
384
475
  // 直後にハイフン/単語構成文字が続くケースを除外する
385
476
  // en: exclude prefix matches against new semantic tokens (e.g. border-border-neutral-*)
@@ -403,7 +494,7 @@ const COMPONENT_ANTI_PATTERN_GROUPS = [
403
494
  '</CardDescription>',
404
495
  '```',
405
496
  '',
406
- 'shadcn/ui と混在するプロジェクトでも、Sparkle Design のコンポーネント内では `character-*` / `text-text-*` / Sparkle の color token を優先する。',
497
+ 'shadcn/ui と混在するプロジェクトでも、`character-*` / `text-text-*` / Sparkle の color token を優先する(`sparkle-design-cli check` はファイル全体のクラス名を検査する)。',
407
498
  ]),
408
499
  jsdocTargets: [],
409
500
  },
@@ -1018,7 +1109,7 @@ const COMPONENT_ANTI_PATTERN_GROUPS = [
1018
1109
  check: {
1019
1110
  description: 'Tailwind デフォルト typography を Sparkle Design コンポーネント内で使わない',
1020
1111
  recommendation:
1021
- 'text-xs 〜 text-9xl / font-medium / font-semibold / font-bold は character-* utility に置き換えてください。ただし character-* に対応する token が無いサイズ(text-[10px] 等の arbitrary value、あるいは意図的に token 外のサイズを使う場合)は、同一行または直前行に `// sparkle-disable-line tailwind-typography` コメントを付けて例外扱いとして残すこともできます。font-medium(500) / font-semibold(600) は character-* に対応する token が無いため、`extend.custom-css` で独自クラスを定義してください(詳細は README の「character-* に無いウェイトを使いたい場合」参照)。',
1112
+ 'text-xs 〜 text-9xl / font-medium / font-semibold / font-bold は character-* utility に置き換えてください。ただし character-* に対応する token が無いサイズ(text-[10px] 等の arbitrary value、あるいは意図的に token 外のサイズを使う場合)は、同じ行に `// sparkle-disable-line tailwind-typography`、または直前の行に `// sparkle-disable-next-line tailwind-typography` を付けて例外扱いとして残すこともできます。font-medium(500) / font-semibold(600) は character-* に対応する token が無いため、`extend.custom-css` で独自クラスを定義してください(詳細は README の「character-* に無いウェイトを使いたい場合」参照)。',
1022
1113
  // text-base は旧カラートークンの text-base-50 〜 text-base-900 へ前方一致するため、
1023
1114
  // shadcn-token と同様に直後のハイフン/単語構成文字を除外する
1024
1115
  // en: exclude prefix matches such as text-base-900 (legacy color token)
@@ -1047,7 +1138,7 @@ const COMPONENT_ANTI_PATTERN_GROUPS = [
1047
1138
  '<span className="text-xs">どうしても text-xs で残したいケース</span>',
1048
1139
  '```',
1049
1140
  '',
1050
- 'Sparkle Design コンポーネント内では `character-*-pro` / `character-*-mono` を使用する。character-1(12px)より小さい指定や、対応 token が無いサイズは Tailwind の arbitrary value (`text-[10px]` 等) で表現するか、`// sparkle-disable-line tailwind-typography` で個別に例外指定する。',
1141
+ 'Sparkle Design コンポーネント内では `character-*-pro` / `character-*-mono` を使用する。character-1(12px)より小さい指定や、対応 token が無いサイズは Tailwind の arbitrary value (`text-[10px]` 等) で表現するか、同じ行の `// sparkle-disable-line tailwind-typography` / 直前の行の `// sparkle-disable-next-line tailwind-typography` で個別に例外指定する。',
1051
1142
  '',
1052
1143
  '`font-medium` / `font-semibold`(500 / 600)は character-* に対応する token が存在しない。`character-N-regular-pro font-semibold` のように Tailwind の font-weight ユーティリティを併用しても、character-* が意図的に Tailwind の後に読み込まれる cascade 設計のため上書きされず効かない。これらのウェイトが必要な場合は `extend.custom-css` で `character-N-semibold-pro` のような独自クラスを定義し、font-family / font-size / letter-spacing / line-height は character-* と同じプリミティブトークンを流用する(詳細は README の「character-* に無いウェイトを使いたい場合」)。',
1053
1144
  ]),
@@ -1302,6 +1393,239 @@ const COMPONENT_ANTI_PATTERN_GROUPS = [
1302
1393
  ]),
1303
1394
  jsdocTargets: [],
1304
1395
  },
1396
+ {
1397
+ id: 'form-control-select-root',
1398
+ check: {
1399
+ description: 'FormControl で Select のルートを包まない(ラベルが関連付かない)',
1400
+ recommendation:
1401
+ 'FormControl は SelectTrigger を包んでください(<Select><FormControl><SelectTrigger>…</SelectTrigger></FormControl><SelectContent>…</SelectContent></Select>)。FormControl は id / aria-describedby / aria-invalid を直下の子へ渡しますが、Select のルートは DOM を持たないため id がどこにも付かず、FormHeader の <label for> が何も指さなくなります。',
1402
+ // 直下の最初の子要素が <Select> のときだけ。<Select> の中の <FormControl>
1403
+ // (正しい形)や、<FormControl> 直下が <SelectTrigger> のものは拾わない。
1404
+ // en: Only when the first direct child of <FormControl> is <Select>.
1405
+ match: (rawContent) => {
1406
+ const content = maskSourceComments(rawContent);
1407
+ const hits = [];
1408
+ for (const opener of content.matchAll(/<FormControl(?=[\s/>])/g)) {
1409
+ const tag = readOpeningTag(content, opener.index, 'FormControl');
1410
+ if (!tag || tag.selfClosing) continue;
1411
+ const child = /^\s*<([A-Za-z][\w.-]*)(?=[\s/>])/.exec(content.slice(tag.end + 1));
1412
+ if (child?.[1] !== 'Select') continue;
1413
+ hits.push({
1414
+ index: tag.index,
1415
+ text: content.slice(tag.index, tag.end + 1 + child[0].length),
1416
+ });
1417
+ }
1418
+ return hits;
1419
+ },
1420
+ },
1421
+ featureSection: lines([
1422
+ '### Select をフォームで使うときは FormControl で SelectTrigger を包む',
1423
+ '',
1424
+ '```tsx',
1425
+ '// ✅ Correct — FormControl は SelectTrigger を包む',
1426
+ '<FormItem>',
1427
+ ' <FormHeader label="プラン" />',
1428
+ ' <Select value={field.value} onValueChange={field.onChange}>',
1429
+ ' <FormControl>',
1430
+ ' <SelectTrigger>',
1431
+ ' <SelectValue placeholder="選択してください" />',
1432
+ ' </SelectTrigger>',
1433
+ ' </FormControl>',
1434
+ ' <SelectContent>',
1435
+ ' <SelectItem value="free">Free</SelectItem>',
1436
+ ' </SelectContent>',
1437
+ ' </Select>',
1438
+ '</FormItem>',
1439
+ '',
1440
+ '// ❌ Wrong — Select のルートを FormControl で包まない',
1441
+ '<FormControl>',
1442
+ ' <Select value={field.value} onValueChange={field.onChange}>',
1443
+ ' <SelectTrigger>…</SelectTrigger>',
1444
+ ' <SelectContent>…</SelectContent>',
1445
+ ' </Select>',
1446
+ '</FormControl>',
1447
+ '```',
1448
+ '',
1449
+ '`FormControl` は Slot で `id` / `aria-describedby` / `aria-invalid` を直下の子に渡す。`Select` のルートは DOM を持たないので id がどこにも付かず、`FormHeader` の `<label for>` が何も指さない(ラベルを押してもフォーカスせず、読み上げでも関連が伝わらない)。',
1450
+ ]),
1451
+ jsdocTargets: [
1452
+ {
1453
+ file: 'src/components/ui/form/index.tsx',
1454
+ targetName: 'FormControl',
1455
+ section: {
1456
+ bullets: [
1457
+ {
1458
+ ja: '`Select` と組み合わせるときは `Select` のルートではなく `SelectTrigger` を包んでください。`Select` のルートは DOM を持たないため、渡した `id` が付かずラベルが関連付きません。',
1459
+ 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.',
1460
+ },
1461
+ ],
1462
+ example: lines([
1463
+ '// ✅ Correct',
1464
+ '<Select value={field.value} onValueChange={field.onChange}>',
1465
+ ' <FormControl>',
1466
+ ' <SelectTrigger>',
1467
+ ' <SelectValue placeholder="選択してください" />',
1468
+ ' </SelectTrigger>',
1469
+ ' </FormControl>',
1470
+ ' <SelectContent>...</SelectContent>',
1471
+ '</Select>',
1472
+ '',
1473
+ '// ❌ Wrong - Select のルートを包まない',
1474
+ '<FormControl>',
1475
+ ' <Select>...</Select>',
1476
+ '</FormControl>',
1477
+ ]),
1478
+ },
1479
+ },
1480
+ ],
1481
+ },
1482
+ {
1483
+ id: 'handwritten-radio-role',
1484
+ check: {
1485
+ // 原則ダメだが、Radio / Select / SegmentedControl のどれにも収まらない
1486
+ // 見た目の選択 UI(色見本など)で手組みを選ぶ判断はありうるので warning。
1487
+ // en: warning, not error — a visual picker that none of the components fit
1488
+ // can be a deliberate exception (suppress it with a comment).
1489
+ severity: SEVERITY.WARNING,
1490
+ description: '単一選択を role="radio" / role="radiogroup" で手組みしない',
1491
+ recommendation:
1492
+ '単一選択には Radio(選択肢が少なく常に 1 つ選ばれている)/ Select(選択肢が多い・表示スペースが狭い)/ sparkle-design-internal の SegmentedControl(2〜5 個の切り替え)を使ってください。手組みの role="radio" は矢印キーでの移動・roving tabindex・aria-checked の同期を自前で正しく実装する必要があります。どうしても手組みが必要な場合は `// sparkle-disable-next-line handwritten-radio-role` で理由を添えて残してください。',
1493
+ match: (rawContent) => {
1494
+ if (!/role\s*=\s*\{?\s*["'`]radio/.test(rawContent)) return [];
1495
+ const content = maskSourceComments(rawContent);
1496
+ const hits = [];
1497
+ for (const opener of content.matchAll(JSX_OPENING_TAG)) {
1498
+ // Radio / RadioGroup / SegmentedControl 自身(とその実装の Radix
1499
+ // primitive)は role を持つのが正しいので対象外
1500
+ // en: The components themselves legitimately carry these roles.
1501
+ if (/Radio|SegmentedControl/.test(opener[1])) continue;
1502
+ const tag = readOpeningTag(content, opener.index, opener[1]);
1503
+ if (tag && RADIO_ROLE_ATTR.test(tag.props)) {
1504
+ hits.push({ index: tag.index, text: tag.text });
1505
+ }
1506
+ }
1507
+ return hits;
1508
+ },
1509
+ },
1510
+ featureSection: lines([
1511
+ '### 単一選択を role="radio" で手組みしない',
1512
+ '',
1513
+ '```tsx',
1514
+ '// ✅ Correct — 選択肢が少なく常に 1 つ選ばれている',
1515
+ '<Radio value={value} onValueChange={setValue}>',
1516
+ ' <RadioItem value="sm" label="小" />',
1517
+ ' <RadioItem value="md" label="中" />',
1518
+ '</Radio>',
1519
+ '',
1520
+ '// ✅ Correct — 選択肢が多い・表示スペースが狭い',
1521
+ '<Select value={value} onValueChange={setValue}>…</Select>',
1522
+ '',
1523
+ '// ❌ Wrong — button に role="radio" を付けて手組みしない',
1524
+ '<div role="radiogroup">',
1525
+ ' <button type="button" role="radio" aria-checked={value === "sm"}>小</button>',
1526
+ ' <button type="button" role="radio" aria-checked={value === "md"}>中</button>',
1527
+ '</div>',
1528
+ '```',
1529
+ '',
1530
+ '単一選択は `Radio` / `Select` / sparkle-design-internal の `SegmentedControl`(2〜5 個の切り替え)から選ぶ。手組みの `role="radio"` は矢印キーでの移動や `aria-checked` の同期を自前で実装することになり、漏れやすい。',
1531
+ ]),
1532
+ jsdocTargets: [],
1533
+ },
1534
+ {
1535
+ id: 'handwritten-form-error',
1536
+ check: {
1537
+ // 「error アイコン + negative 系の文字色が並ぶ」というヒューリスティックで、
1538
+ // 入力欄に紐づかないページ全体のエラー表示なども拾いうるので info に留める。
1539
+ // en: Heuristic (error icon next to a negative text color) — it can also hit
1540
+ // page-level errors that aren't tied to a field, so keep it at info.
1541
+ severity: SEVERITY.INFO,
1542
+ description: 'フォームのエラー表示を Icon + negative 色で手組みしない',
1543
+ recommendation:
1544
+ '入力欄のエラーは FormErrorMessage を使ってください。FormErrorMessage は aria-describedby / aria-invalid で入力欄と関連付くため、読み上げでどの欄のエラーかが伝わります(手組みでは伝わりません)。入力欄に紐づかないエラー(ページ全体の失敗など)なら InlineMessage を検討し、意図的な手組みであれば `// sparkle-disable-next-line handwritten-form-error` で残してください。',
1545
+ // `<Icon icon="error">` の (1) 自身 (2) 直上の親 (3) 直後の兄弟 のいずれかに
1546
+ // negative 系の文字色があれば手組みのエラー表示とみなす。親に data-slot が
1547
+ // 付いているもの(FormErrorMessage など Sparkle 本体の実装)は対象外。
1548
+ // en: Flag `<Icon icon="error">` when the icon itself, its immediate parent,
1549
+ // or its next sibling carries a negative text color. Parents with
1550
+ // `data-slot` are Sparkle's own implementation (FormErrorMessage) — skip.
1551
+ match: (rawContent) => {
1552
+ if (!/icon\s*=\s*\{?\s*["'`]error/.test(rawContent)) return [];
1553
+ const content = maskSourceComments(rawContent);
1554
+ const hits = [];
1555
+ for (const opener of content.matchAll(/<Icon(?=[\s/>])/g)) {
1556
+ const icon = readOpeningTag(content, opener.index, 'Icon');
1557
+ if (!icon || !ERROR_ICON_ATTR.test(icon.props)) continue;
1558
+
1559
+ const parent = findImmediateParentTag(content, icon.index);
1560
+ if (
1561
+ parent &&
1562
+ (/(?:^|\s)data-slot\s*=/.test(parent.props) || parent.tagName === 'FormErrorMessage')
1563
+ ) {
1564
+ continue;
1565
+ }
1566
+
1567
+ let sibling = null;
1568
+ if (icon.selfClosing) {
1569
+ const next = /^\s*<([A-Za-z][\w.-]*)(?=[\s/>])/.exec(content.slice(icon.end + 1));
1570
+ if (next) {
1571
+ sibling = readOpeningTag(content, icon.end + 1 + next[0].indexOf('<'), next[1]);
1572
+ }
1573
+ }
1574
+
1575
+ if ([icon, parent, sibling].some((tag) => tag && NEGATIVE_TEXT_COLOR.test(tag.props))) {
1576
+ hits.push({ index: icon.index, text: icon.text });
1577
+ }
1578
+ }
1579
+ return hits;
1580
+ },
1581
+ },
1582
+ featureSection: lines([
1583
+ '### フォームのエラー表示は FormErrorMessage を使う',
1584
+ '',
1585
+ '```tsx',
1586
+ '// ✅ Correct — 入力欄と aria-describedby / aria-invalid で関連付く',
1587
+ '<FormItem>',
1588
+ ' <FormHeader label="招待コード" />',
1589
+ ' <FormControl>',
1590
+ ' <Input {...field} isInvalid={fieldState.invalid} />',
1591
+ ' </FormControl>',
1592
+ ' <FormErrorMessage />',
1593
+ '</FormItem>',
1594
+ '',
1595
+ '// ❌ Wrong — Icon と negative 色でエラー表示を手組みしない',
1596
+ '<span className="flex items-center gap-1 text-text-negative-enabled">',
1597
+ ' <Icon icon="error" size={3} />',
1598
+ ' コードが見つかりません',
1599
+ '</span>',
1600
+ '```',
1601
+ '',
1602
+ '手組みのエラー表示は見た目が同じでも入力欄との関連が無く、スクリーンリーダーではどの欄のエラーかが伝わらない。入力欄に紐づかないエラーは `InlineMessage` を使う。',
1603
+ ]),
1604
+ jsdocTargets: [
1605
+ {
1606
+ file: 'src/components/ui/form/index.tsx',
1607
+ targetName: 'FormErrorMessage',
1608
+ section: {
1609
+ bullets: [
1610
+ {
1611
+ ja: '`<Icon icon="error" />` と negative 系の文字色でエラー表示を手組みしないでください。`FormErrorMessage` は `aria-describedby` / `aria-invalid` で入力欄と関連付きます。',
1612
+ 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`.',
1613
+ },
1614
+ ],
1615
+ example: lines([
1616
+ '// ✅ Correct',
1617
+ '<FormErrorMessage />',
1618
+ '',
1619
+ '// ❌ Wrong - 手組みのエラー表示',
1620
+ '<span className="text-text-negative-enabled">',
1621
+ ' <Icon icon="error" size={3} />',
1622
+ ' コードが見つかりません',
1623
+ '</span>',
1624
+ ]),
1625
+ },
1626
+ },
1627
+ ],
1628
+ },
1305
1629
  ];
1306
1630
 
1307
1631
  // Built-in groups always come before plugin-supplied groups in this priority order.
@@ -1351,6 +1675,23 @@ function maskBlockComments(content) {
1351
1675
  return content.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, (comment) => comment.replace(/[^\n]/g, ' '));
1352
1676
  }
1353
1677
 
1678
+ /**
1679
+ * そのファイル自身が宣言している CSS カスタムプロパティ名の集合。
1680
+ *
1681
+ * `check` の移行ルールと `migrate` サブコマンドの両方が「定義側は移行対象の
1682
+ * 使用ではない」という同じ判定を使う。片方だけ直すと、check は黙っているのに
1683
+ * migrate が生成物の定義を書き換える(あるいはその逆)というずれが生まれるため、
1684
+ * 1 箇所にまとめている。
1685
+ * en: Custom properties declared by this file. Shared by `check` and `migrate`
1686
+ * so both agree that definitions are not usages to migrate.
1687
+ *
1688
+ * @param {string} content ブロックコメントをマスク済みの内容
1689
+ * @returns {Set<string>}
1690
+ */
1691
+ function collectDeclaredCustomProperties(content) {
1692
+ return new Set([...content.matchAll(/(?:^|[{;])\s*(--[\w-]+)\s*:/gm)].map((m) => m[1]));
1693
+ }
1694
+
1354
1695
  function migrationMatcher(pattern, resolve, label) {
1355
1696
  return (rawContent) => {
1356
1697
  const content = maskBlockComments(rawContent);
@@ -1369,9 +1710,7 @@ function migrationMatcher(pattern, resolve, label) {
1369
1710
  // CSS や minify された theme ファイルで**定義側まで「使用」として報告**する。
1370
1711
  // en: Also treat `{`/`;` as declaration starts so single-line/minified CSS
1371
1712
  // 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
- );
1713
+ const declaredHere = collectDeclaredCustomProperties(content);
1375
1714
 
1376
1715
  const hits = [];
1377
1716
  for (const match of content.matchAll(pattern)) {
@@ -1761,6 +2100,9 @@ const BUILTIN_CHECK_ORDER = [
1761
2100
  'disabled-vs-is-disabled',
1762
2101
  'button-prefixicon-jsx',
1763
2102
  'icon-children-text',
2103
+ 'form-control-select-root',
2104
+ 'handwritten-radio-role',
2105
+ 'handwritten-form-error',
1764
2106
  // NOTE: この配列が決めるのは **ルールの評価順** だけで、レポートの表示順では
1765
2107
  // ない(findings は check.js 側で severity 優先にソートされる)。移行ルールを
1766
2108
  // 末尾に置いているのは評価順を安定させるためで、「warning が error を埋もれ
@@ -1814,9 +2156,11 @@ export {
1814
2156
  BUILTIN_ANTI_PATTERN_GROUPS as ANTI_PATTERN_GROUPS,
1815
2157
  BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
1816
2158
  COMMON_INTRO,
2159
+ collectDeclaredCustomProperties,
1817
2160
  getAllJSDocTargets,
1818
2161
  getCheckRules,
1819
2162
  getManualReviewReminders,
2163
+ maskBlockComments,
1820
2164
  renderFeatureSections,
1821
2165
  renderJSDocSection,
1822
2166
  };