sparkle-design-cli 2.4.2 → 2.5.0-beta.2

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.
@@ -1,6 +1,96 @@
1
+ import {
2
+ MIGRATION_CLASS,
3
+ buildDeprecatedAliasPattern,
4
+ buildLegacyCssVarPattern,
5
+ buildLegacyScalePattern,
6
+ buildLegacyTokenPattern,
7
+ buildTailwindPalettePattern,
8
+ resolveLegacyCssVar,
9
+ resolveLegacyUtility,
10
+ resolveTailwindPaletteUtility,
11
+ } from './token-migration.js';
12
+ import { buildSpacingUtilityPattern, pxToStep, resolveSpacingStep } from './spacing-scale.js';
13
+
1
14
  const COMMON_INTRO =
2
15
  '以下は頻繁に発生する誤用パターンです。コンポーネントが提供する専用 props・サブコンポーネントを使ってください。';
3
16
 
17
+ /**
18
+ * ルールの「強さ」。AI がすべてのルールを同じ重みで扱ってしまい、全部を過剰に
19
+ * 守るか全部を読み流すかの両極端に振れるのを防ぐために付ける(issue #74)。
20
+ *
21
+ * - `error` … 例外なし。`--strict` / stop-hook はこれだけを失敗として扱う
22
+ * - `warning` … 原則ダメだが理由があれば例外可。報告はするが exit code は変えない
23
+ * - `info` … 参考。従わなくてもよい選択肢
24
+ *
25
+ * 既存ルールはすべて `error` 相当(`--strict` で落ちる)だったため、severity 未指定は
26
+ * `error` にフォールバックする。これで**プラグイン rule を含め既存の挙動が一切変わらない**。
27
+ * en: Unspecified severity falls back to `error`, so existing built-in and plugin
28
+ * rules keep their current pass/fail behaviour exactly.
29
+ */
30
+ export const SEVERITY = {
31
+ ERROR: 'error',
32
+ WARNING: 'warning',
33
+ INFO: 'info',
34
+ };
35
+
36
+ export const DEFAULT_SEVERITY = SEVERITY.ERROR;
37
+
38
+ /**
39
+ * ルールを当てるファイル種別。
40
+ *
41
+ * 既定は `source`(`.js` / `.jsx` / `.ts` / `.tsx`)で、severity 導入前の
42
+ * 挙動と同じ。組み込み・プラグインを問わず既存ルールは JSX 前提で書かれて
43
+ * いるため、既定を広げると既存 consumer に新しい findings が湧いて CI が落ちる。
44
+ *
45
+ * トークン移行ルールだけは `css` も対象にする。`--radius-halfModal` のような
46
+ * CSS 変数の直接参照は `.css` にこそ自然に書かれるうえ、消えてもビルドエラーに
47
+ * ならず角丸だけ失われる — まさにこのルールが防ごうとしている故障が、CSS では
48
+ * 検出できないという穴になっていた。
49
+ * en: Which file kinds a rule applies to. Defaults to source files (previous
50
+ * behaviour); the token-migration rules opt into `.css` as well, since CSS
51
+ * variable references live there and fail silently when the alias is removed.
52
+ */
53
+ export const RULE_TARGET = {
54
+ SOURCE: 'source',
55
+ CSS: 'css',
56
+ };
57
+
58
+ export const DEFAULT_RULE_TARGETS = [RULE_TARGET.SOURCE];
59
+ const KNOWN_RULE_TARGETS = new Set(Object.values(RULE_TARGET));
60
+
61
+ function normalizeRuleTargets(targets, ruleId) {
62
+ if (targets === undefined || targets === null) return DEFAULT_RULE_TARGETS;
63
+ const list = Array.isArray(targets) ? targets : [targets];
64
+ const unknown = list.filter((target) => !KNOWN_RULE_TARGETS.has(target));
65
+ if (unknown.length > 0 || list.length === 0) {
66
+ console.warn(
67
+ `⚠️ sparkle-design-cli: rule "${ruleId ?? '(unknown)'}" has invalid targets ${JSON.stringify(targets)} — falling back to ${JSON.stringify(DEFAULT_RULE_TARGETS)}`
68
+ );
69
+ return DEFAULT_RULE_TARGETS;
70
+ }
71
+ return list;
72
+ }
73
+
74
+ /** `--strict` / stop-hook が失敗として扱う severity */
75
+ export const BLOCKING_SEVERITIES = new Set([SEVERITY.ERROR]);
76
+
77
+ const KNOWN_SEVERITIES = new Set(Object.values(SEVERITY));
78
+
79
+ function normalizeSeverity(severity, ruleId) {
80
+ if (severity === undefined || severity === null) return DEFAULT_SEVERITY;
81
+ if (KNOWN_SEVERITIES.has(severity)) return severity;
82
+ // 既定(error)に倒すので CI は落ちる。これは意図した fail-safe —— 「強さが
83
+ // 分からないルールを黙って非ブロックにする」ほうが危険なため。ただし黙って
84
+ // 落とすとタイポに気付けないので warn を出す(loadAntiPatternPlugins の
85
+ // warn-and-skip と同じ方針)。
86
+ // en: Falling back to `error` is deliberate (fail-safe): silently downgrading
87
+ // an unknown severity to non-blocking is worse. Warn so the typo is visible.
88
+ console.warn(
89
+ `⚠️ sparkle-design-cli: rule "${ruleId ?? '(unknown)'}" has an unknown severity ${JSON.stringify(severity)} — falling back to "${DEFAULT_SEVERITY}"`
90
+ );
91
+ return DEFAULT_SEVERITY;
92
+ }
93
+
4
94
  function lines(values) {
5
95
  return values.join('\n');
6
96
  }
@@ -46,7 +136,7 @@ const BUILTIN_MANUAL_REVIEW_REMINDERS = [
46
136
  },
47
137
  ];
48
138
 
49
- const BUILTIN_ANTI_PATTERN_GROUPS = [
139
+ const COMPONENT_ANTI_PATTERN_GROUPS = [
50
140
  {
51
141
  id: 'card-description',
52
142
  featureSection: lines([
@@ -57,7 +147,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
57
147
  '<CardHeader>',
58
148
  ' <CardTitle>',
59
149
  ' プロジェクト一覧',
60
- ' <CardDescription className="character-3-regular-pro text-text-low">',
150
+ ' <CardDescription className="character-3-regular-pro text-text-neutral-low">',
61
151
  ' 全 12 件',
62
152
  ' </CardDescription>',
63
153
  ' </CardTitle>',
@@ -93,7 +183,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
93
183
  '// ✅ Correct',
94
184
  '<CardTitle>',
95
185
  ' プロジェクト一覧',
96
- ' <CardDescription className="character-3-regular-pro text-text-low">',
186
+ ' <CardDescription className="character-3-regular-pro text-text-neutral-low">',
97
187
  ' 全 12 件',
98
188
  ' </CardDescription>',
99
189
  '</CardTitle>',
@@ -290,14 +380,17 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
290
380
  description: 'shadcn/ui 既定 token を Sparkle Design 内へ持ち込まない',
291
381
  recommendation:
292
382
  'text-muted-foreground / bg-background / border-border などは Sparkle Design token に置き換えてください。',
293
- pattern: /\b(text-muted-foreground|bg-background|border-border)\b/g,
383
+ // 新セマンティックトークン(border-border-neutral-* 等)へ前方一致しないよう、
384
+ // 直後にハイフン/単語構成文字が続くケースを除外する
385
+ // en: exclude prefix matches against new semantic tokens (e.g. border-border-neutral-*)
386
+ pattern: /\b(text-muted-foreground|bg-background|border-border)(?![\w-])/g,
294
387
  },
295
388
  featureSection: lines([
296
389
  '### shadcn/ui 由来の class / token をそのまま使わない',
297
390
  '',
298
391
  '```tsx',
299
392
  '// ✅ Correct — Sparkle Design の typography / color token を使う',
300
- '<CardDescription className="character-3-regular-pro text-text-low">',
393
+ '<CardDescription className="character-3-regular-pro text-text-neutral-low">',
301
394
  ' 全 12 件',
302
395
  '</CardDescription>',
303
396
  '',
@@ -438,7 +531,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
438
531
  '<CardHeader>',
439
532
  ' <CardTitle>',
440
533
  ' タイトル',
441
- ' <CardDescription className="character-3-regular-pro text-text-low">',
534
+ ' <CardDescription className="character-3-regular-pro text-text-neutral-low">',
442
535
  ' 全 12 件',
443
536
  ' </CardDescription>',
444
537
  ' </CardTitle>',
@@ -925,8 +1018,17 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
925
1018
  check: {
926
1019
  description: 'Tailwind デフォルト typography を Sparkle Design コンポーネント内で使わない',
927
1020
  recommendation:
928
- 'text-sm / text-xs / text-base / 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-* に無いウェイトを使いたい場合」参照)。',
929
- pattern: /\b(text-(?:xs|sm|base|lg|xl|2xl)|font-(?:medium|semibold|bold|normal|light))\b/g,
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-* に無いウェイトを使いたい場合」参照)。',
1022
+ // text-base は旧カラートークンの text-base-50 〜 text-base-900 へ前方一致するため、
1023
+ // shadcn-token と同様に直後のハイフン/単語構成文字を除外する
1024
+ // en: exclude prefix matches such as text-base-900 (legacy color token)
1025
+ //
1026
+ // `2xl` までしか見ておらず text-3xl 以上が漏れていた(issue #84)。見出しほど
1027
+ // 大きいサイズを使うので、抜けていた側のほうが目立つ誤りだった。Tailwind の
1028
+ // 既定スケールは text-9xl まで。
1029
+ // en: Sizes above 2xl were missed entirely; Tailwind's scale goes to 9xl.
1030
+ pattern:
1031
+ /\b(text-(?:xs|sm|base|lg|xl|[2-9]xl)|font-(?:medium|semibold|bold|normal|light))(?![\w-])/g,
930
1032
  },
931
1033
  featureSection: lines([
932
1034
  '### Tailwind デフォルト typography を使わない',
@@ -940,7 +1042,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
940
1042
  '',
941
1043
  '// 例外 — character-* に対応するサイズが無い場合は arbitrary value を使うか',
942
1044
  '// suppress コメントを付けて残す',
943
- '<span className="text-[10px] text-text-low">12px 未満の極小メタ情報</span>',
1045
+ '<span className="text-[10px] text-text-neutral-low">12px 未満の極小メタ情報</span>',
944
1046
  '{/* sparkle-disable-next-line tailwind-typography */}',
945
1047
  '<span className="text-xs">どうしても text-xs で残したいケース</span>',
946
1048
  '```',
@@ -1205,6 +1307,446 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
1205
1307
  // Built-in groups always come before plugin-supplied groups in this priority order.
1206
1308
  // Plugin-supplied IDs that aren't in this list fall to the end (stable insertion order).
1207
1309
  // en: Determines `check` finding emission order so the most-important built-in rules surface first.
1310
+ // --- 新セマンティックトークンへの移行ルール(beta 期間中は warning) ----------
1311
+ //
1312
+ // 旧トークンは beta 期間中テンプレートに併存するので、使っていても即座には
1313
+ // 壊れない。ここで `error` にすると「まだ移行していないコンポーネントでは
1314
+ // 旧トークンが正」という移行期の現実と衝突して CI が通らなくなるため、beta
1315
+ // 中は `warning`(報告のみ)に留める。beta 終了で旧トークンを削除するとき
1316
+ // `error` に昇格させる(issue #74)。
1317
+ // en: Legacy tokens still exist during beta, so flag them as `warning` — an
1318
+ // `error` here would collide with the reality that un-migrated components
1319
+ // legitimately still use them. Promote to `error` when the legacy layer is
1320
+ // removed.
1321
+
1322
+ /**
1323
+ * マッチ 1 件ごとに移行先を計算して recommendation にする match 関数を作る。
1324
+ * 「代わりに何を使うか」までルールに含めないと、AI は違反を見つけても直し方を
1325
+ * 発明してしまう(issue #74 の提案3)。
1326
+ * en: Compute the migration target per occurrence — a rule that only says
1327
+ * "don't" makes the AI invent its own replacement.
1328
+ */
1329
+ /**
1330
+ * ブロックコメントの中身を同じ長さの空白に置き換える。
1331
+ *
1332
+ * コメント内の言及(「旧 `--color-ring-normal` の移行先」のような説明文)まで
1333
+ * 移行対象として報告すると、生成物やドキュメントコメントが大量の誤検出になる。
1334
+ * 中身を消すのではなく**同じ長さの空白で潰す**ことで、後段が使う index と
1335
+ * 行番号がずれない。
1336
+ *
1337
+ * **行頭(インデント可)から始まるものだけ**を対象にする。任意位置の `/*` を
1338
+ * コメント開始とみなすと、文字列リテラル内の `/*`——`matcher: ['/dashboard/*']`
1339
+ * や `content: "/*"`、glob、正規表現リテラル——を起点に、次の `*\/` までの
1340
+ * **実コードを黙って検査対象から外してしまう**。無言の false negative は、
1341
+ * 未解決の hit をわざわざ表に出しているこのモジュールの方針と正反対なので、
1342
+ * 「コメントは行頭から書く」という緩い前提を取るほうが安全側に倒れる。
1343
+ * 行末コメント(`const x = 1; /* ... *\/`)は潰さないので、そこに書かれた
1344
+ * トークン名は検出されうるが、warning 止まりで抑制コメントも使える。
1345
+ * en: Only mask block comments that start a line. Treating any `/*` as a
1346
+ * comment start would silently blank real code after a string literal such as
1347
+ * `matcher: ['/dashboard/*']` — a false negative, which is worse than the
1348
+ * false positive it avoids.
1349
+ */
1350
+ function maskBlockComments(content) {
1351
+ return content.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, (comment) => comment.replace(/[^\n]/g, ' '));
1352
+ }
1353
+
1354
+ function migrationMatcher(pattern, resolve, label) {
1355
+ return (rawContent) => {
1356
+ const content = maskBlockComments(rawContent);
1357
+ // そのファイル自身が宣言している変数への参照は「移行対象の使用」ではなく
1358
+ // 定義側の内部配線。CLI が生成した CSS(旧トークンを定義している側)を
1359
+ // 丸ごと報告してしまうのを防ぐ。
1360
+ //
1361
+ // ファイル名(`sparkle-design.css`)で除外していたが、`generate --scope` は
1362
+ // `-o` が必須で出力名が任意になるため、テナント別テーマを使うプロジェクトの
1363
+ // 生成物が素通りしていた。「定義しているファイルは、その変数について正」
1364
+ // という内容ベースの判定なら出力名に依存しない。
1365
+ // en: Skip references to variables the same file declares — those are the
1366
+ // definition side, not usage to migrate. Content-based, so it also covers
1367
+ // `generate --scope` output whose filename is arbitrary.
1368
+ // `{` / `;` 直後も宣言位置として拾う。行頭だけを見ていると、1 行にまとめた
1369
+ // CSS や minify された theme ファイルで**定義側まで「使用」として報告**する。
1370
+ // en: Also treat `{`/`;` as declaration starts so single-line/minified CSS
1371
+ // 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
+ );
1375
+
1376
+ const hits = [];
1377
+ for (const match of content.matchAll(pattern)) {
1378
+ const text = match[0];
1379
+ if (text.startsWith('--') && declaredHere.has(text)) continue;
1380
+ const resolved = resolve(text);
1381
+ // 検出パターンが拾ったのに解決できない = 検出側と対応表がずれている。
1382
+ // ここで `continue` すると「旧トークンを使っているのに何も報告されない」
1383
+ // 無記録の drop になるので、要判断として必ず表に出す。
1384
+ // (現状この分岐には到達しないことを token-migration.test.js の全数検証で
1385
+ // 確認しているが、テーブル編集で簡単に壊れるため fail-loud にしておく)
1386
+ // en: A pattern hit that the table cannot resolve means the two have
1387
+ // drifted. Surface it instead of dropping it silently.
1388
+ if (!resolved) {
1389
+ hits.push({
1390
+ index: match.index ?? 0,
1391
+ text,
1392
+ recommendation:
1393
+ `[要判断] ${text}: ${label}として検出しましたが移行先を解決できませんでした。` +
1394
+ 'sparkle-design-cli の検出パターンと対応表がずれている可能性があります(issue 報告をお願いします)。',
1395
+ });
1396
+ continue;
1397
+ }
1398
+
1399
+ let recommendation;
1400
+ if (resolved.classification === MIGRATION_CLASS.AUTO) {
1401
+ recommendation = `[自動変換可] ${text} → ${resolved.replacement}${resolved.reason ? `(${resolved.reason})` : ''}`;
1402
+ } else if (resolved.candidates?.length) {
1403
+ recommendation = `[要判断] ${text} の移行先候補: ${resolved.candidates.join(' / ')}。${resolved.reason ?? ''}`;
1404
+ } else if (resolved.replacement) {
1405
+ // 移行先自体は算出できているが人間の確認が要るケース(`text-placeholder`
1406
+ // の統合など)。せっかく分かっている置換先を捨てない。
1407
+ // en: The target is known but needs a human decision — still show it.
1408
+ recommendation = `[要判断] ${text} → ${resolved.replacement}。${resolved.reason ?? ''}`;
1409
+ } else {
1410
+ recommendation = `[要判断] ${text}: ${resolved.reason ?? `${label} の移行先を Figma で確認してください。`}`;
1411
+ }
1412
+
1413
+ hits.push({ index: match.index ?? 0, text, recommendation });
1414
+ }
1415
+ return hits;
1416
+ };
1417
+ }
1418
+
1419
+ const TAILWIND_PALETTE_PATTERN = buildTailwindPalettePattern();
1420
+
1421
+ /**
1422
+ * そのファイルが Sparkle Design を使っているか判定する材料。
1423
+ *
1424
+ * Tailwind 既定パレットの色は「Sparkle を導入したプロジェクトなのに色だけ移行が
1425
+ * 取り残されている」箇所を拾うためのルールなので、Sparkle と無関係なファイル
1426
+ * (素の React コード、管理用スクリプト、vendored なコード)まで報告すると
1427
+ * ノイズになる(issue #84)。
1428
+ *
1429
+ * 2 つの材料を見る:
1430
+ * 1. Sparkle パッケージからの import — `sparkle-design` / `@goodpatch/sparkle-design-internal`
1431
+ * のほか、`sparkle-design/components/...` のようなサブパスも拾えるよう部分一致にする
1432
+ * 2. `character-*` typography の使用 — Sparkle 固有のユーティリティで、
1433
+ * **これが使われている時点でそのファイルは Sparkle に載っている**。
1434
+ * issue #84 の実例はまさに「typography は character-* に移行済みなのに色だけ
1435
+ * Tailwind 既定パレットのまま」という形で、import 判定だけだと
1436
+ * re-export 経由(`@/components/ui/...`)のファイルを取りこぼす
1437
+ *
1438
+ * en: Gate the rule to files that actually use Sparkle — either an import from a
1439
+ * Sparkle package, or a `character-*` utility (Sparkle-specific, and the exact
1440
+ * signal in issue #84 where typography had migrated but colours had not).
1441
+ */
1442
+ const SPARKLE_USAGE_PATTERNS = [
1443
+ /\b(?:from|import)\s*\(?\s*['"][^'"]*sparkle-design[^'"]*['"]/,
1444
+ /\brequire\(\s*['"][^'"]*sparkle-design[^'"]*['"]\s*\)/,
1445
+ /(?<![\w-])character-\d/,
1446
+ ];
1447
+
1448
+ function usesSparkle(content) {
1449
+ return SPARKLE_USAGE_PATTERNS.some((pattern) => pattern.test(content));
1450
+ }
1451
+
1452
+ /**
1453
+ * Tailwind 既定パレットの色ユーティリティを拾う。
1454
+ *
1455
+ * `shadcn-token` の説明文は Tailwind パレット色(`text-slate-500`)も Wrong として
1456
+ * 示していたのに、実際の `check.pattern` は shadcn 由来の 3 語しか見ていなかった。
1457
+ * 「説明が禁止しているものを検査が拾っていない」状態を解消する(issue #84)。
1458
+ *
1459
+ * 生の hex(`#6b7280`)は**検出しない**。SVG・チャート描画・ユーザー定義色など、
1460
+ * 移行対象ではない動的な用途が大半を占めるため(報告元のプロジェクトでは 43 件中
1461
+ * 32 件がこれ)、ルール化すると偽陽性で埋まる。
1462
+ * en: Raw hex values are intentionally out of scope — most of them are dynamic
1463
+ * (SVG, charts, user-defined colours) and would drown the report in false positives.
1464
+ *
1465
+ * `targets` を指定していないので対象はソースファイルのみ。`legacy-color-token` が
1466
+ * `.css` も見るのに対し、こちらは `@apply` を拾わない。CSS には import が無く、
1467
+ * `usesSparkle` の 2 材料のうち `character-*` しか効かないため、対象に加えると
1468
+ * 「CSS の一部だけ検査される」という説明しにくい半端なカバレッジになる。
1469
+ * **意図的な線引きなので、featureSection と description に明記してある。**
1470
+ * en: Source files only. CSS has no imports, so the Sparkle gate would work only
1471
+ * half the time there — an unexplainable partial coverage. The limitation is
1472
+ * stated in the rule's description and feature section rather than left implicit.
1473
+ */
1474
+ function tailwindPaletteMatcher(rawContent) {
1475
+ if (!usesSparkle(maskBlockComments(rawContent))) return [];
1476
+ return migrationMatcher(
1477
+ TAILWIND_PALETTE_PATTERN,
1478
+ resolveTailwindPaletteUtility,
1479
+ 'Tailwind 既定パレットの色'
1480
+ )(rawContent);
1481
+ }
1482
+
1483
+ const SPACING_UTILITY_PATTERN = buildSpacingUtilityPattern();
1484
+
1485
+ /**
1486
+ * Figma の spacing スケールから外れた値を拾う。
1487
+ *
1488
+ * 移行ルール(migrationMatcher)と違い「同じファイルが宣言しているか」は見ない。
1489
+ * spacing は CSS 変数として出力していないので、**変数の**定義側という概念が
1490
+ * 無いため。ただし Tailwind がコンパイルした CSS の `.p-7{padding:1.75rem}` は
1491
+ * 素通しで拾う。既定の検査対象が `src` なので通常は踏まないが、`dist` や
1492
+ * vendored CSS を明示的に渡すと大量に報告される。
1493
+ * en: No CSS-variable definition side exists for spacing, but compiled Tailwind
1494
+ * output (`.p-7{…}`) is still matched — only reachable if someone points the
1495
+ * check at `dist`/vendored CSS.
1496
+ */
1497
+ function spacingScaleMatcher(rawContent) {
1498
+ const content = maskBlockComments(rawContent);
1499
+ const hits = [];
1500
+ for (const match of content.matchAll(SPACING_UTILITY_PATTERN)) {
1501
+ const { lead, sign: prefixModifiers, step, important } = match.groups;
1502
+ const text = match[0];
1503
+ const offScale = resolveSpacingStep(step);
1504
+ if (!offScale) continue;
1505
+
1506
+ // 負のマージン(`-mt-7`)はステップだけ見ると正数なので、px 表示に符号を
1507
+ // 戻す。クラス名は lead に `-` が入っているので正しいが、「-mt-7 は 28px」
1508
+ // という案内は px 換算の正確さが売りのルールとして嘘になる。
1509
+ // 判定は lead 全体ではなく **ユーティリティ直前の修飾子だけ**を見る。
1510
+ // lead を走査すると `[&:-moz-any-link]:mt-7` の variant 内の `-` を拾う。
1511
+ // en: Restore the sign in the px figures, reading only the modifier right
1512
+ // before the utility — scanning `lead` would misread variants like
1513
+ // `[&:-moz-any-link]:mt-7` as negative.
1514
+ const sign = prefixModifiers.includes('-') ? '-' : '';
1515
+
1516
+ // 候補は variant 連鎖・負号・important をすべて保ったまま数字だけ差し替える。
1517
+ // `p-7!` に対して `p-6` を案内すると、そのまま従った人が important を落とす。
1518
+ // en: Rebuild candidates with every modifier intact — suggesting `p-6` for
1519
+ // `p-7!` would quietly drop the important flag.
1520
+ const neighbours = [offScale.lower, offScale.upper]
1521
+ .filter((px) => px !== null)
1522
+ .map((px) => `${lead}${pxToStep(px)}${important}(${sign}${px}px)`);
1523
+
1524
+ hits.push({
1525
+ index: match.index ?? 0,
1526
+ text,
1527
+ recommendation:
1528
+ `[要判断] ${text} は ${sign}${offScale.px}px で、Figma の Spacing: Primitives に無い値です。` +
1529
+ // スケールに 0 が含まれる限り `lower` は必ず埋まるので、この else には
1530
+ // 到達しない(`upper` が null になるのは 120px 超のときだけで、
1531
+ // そのとき `lower` は 120)。FIGMA_SPACING_PX を空にした場合の保険。
1532
+ // en: Unreachable while the scale contains 0 — kept as a guard.
1533
+ (neighbours.length
1534
+ ? `近いステップは ${neighbours.join(' / ')}。`
1535
+ : 'Figma のスケールを確認してください。') +
1536
+ 'デザイン上その値が必要なら、Figma 側にステップを追加するか、' +
1537
+ '`sparkle-disable-next-line use-figma-spacing-scale` で明示的に外してください。',
1538
+ });
1539
+ }
1540
+ return hits;
1541
+ }
1542
+
1543
+ const LEGACY_SCALE_PATTERN = buildLegacyScalePattern();
1544
+ const LEGACY_TOKEN_PATTERN = buildLegacyTokenPattern();
1545
+ const LEGACY_CSS_VAR_PATTERN = buildLegacyCssVarPattern();
1546
+ const DEPRECATED_ALIAS_PATTERN = buildDeprecatedAliasPattern();
1547
+
1548
+ const TOKEN_MIGRATION_GROUPS = [
1549
+ {
1550
+ id: 'legacy-color-token',
1551
+ check: {
1552
+ severity: SEVERITY.WARNING,
1553
+ // `.css` も対象にする(`@apply` のユーティリティ、`var(--color-*)` の直接参照)
1554
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1555
+ description:
1556
+ '旧セマンティックカラーのユーティリティクラスを使っています(新体系は用途 × 意味 × 強度 × 状態)',
1557
+ recommendation:
1558
+ '用途別セマンティックトークン(bg- → surface / text- → text / border- → border / fill- → object)に置き換えてください。',
1559
+ match: (content) => [
1560
+ ...migrationMatcher(LEGACY_SCALE_PATTERN, resolveLegacyUtility, '旧トークン')(content),
1561
+ ...migrationMatcher(LEGACY_TOKEN_PATTERN, resolveLegacyUtility, '旧トークン')(content),
1562
+ ],
1563
+ },
1564
+ featureSection: lines([
1565
+ '### 旧セマンティックカラーではなく用途別トークンを使う',
1566
+ '',
1567
+ '新体系は「用途 × 意味 × 強度 × **状態**」で、状態がトークン名に内包される。',
1568
+ 'そのため**同じ旧クラスでも文脈によって移行先が変わる**。',
1569
+ '',
1570
+ '| if(状況) | then(移行先) |',
1571
+ '|---|---|',
1572
+ '| 通常状態の背景に `bg-primary-600` | `bg-surface-primary-high-enabled` |',
1573
+ '| hover の背景に `hover:bg-primary-700` | `hover:bg-surface-primary-high-hover` |',
1574
+ '| variant 接頭辞なしで `bg-primary-200` | **自動変換しない**(`surface-primary-high-disabled` と `surface-primary-middle-active` のどちらか判断が要る) |',
1575
+ '| 文字色に `text-primary-600` | `text-text-primary-enabled` |',
1576
+ '| 枠線に `border-primary-300` | `border-border-primary-high` |',
1577
+ '| アイコン色に `fill-primary-600` | `fill-object-primary-enabled` |',
1578
+ '',
1579
+ '`secondary` は `neutral` に統合された。ユーティリティ接頭辞がそのまま用途に対応する',
1580
+ '(`bg-` → surface / `text-` → text / `border-`・`divide-`・`outline-`・`ring-` → border / `fill-`・`stroke-` → object)。',
1581
+ '',
1582
+ '判断が付かないときは推測で置換せず、Figma の該当箇所を確認する。',
1583
+ ]),
1584
+ jsdocTargets: [],
1585
+ },
1586
+ {
1587
+ id: 'tailwind-palette-color',
1588
+ check: {
1589
+ severity: SEVERITY.WARNING,
1590
+ description:
1591
+ 'Tailwind 既定パレットの色ユーティリティを使っています(Sparkle のセマンティックトークンに置き換えます)',
1592
+ recommendation:
1593
+ 'text-gray-* / bg-slate-* / border-red-* のような Tailwind 既定パレットの色は、用途別セマンティックトークン(bg- → surface / text- → text / border- → border / fill- → object)に置き換えてください。検査対象はソースファイルのみで、.css の @apply と生の hex 値は見ていません。',
1594
+ match: tailwindPaletteMatcher,
1595
+ },
1596
+ featureSection: lines([
1597
+ '### Tailwind 既定パレットの色をそのまま使わない',
1598
+ '',
1599
+ '```tsx',
1600
+ '// ✅ Correct — 用途別セマンティックトークンを使う',
1601
+ '<main className="bg-surface-base-100">',
1602
+ ' <h1 className="character-6-bold-pro text-text-negative-enabled">認証エラー</h1>',
1603
+ '</main>',
1604
+ '',
1605
+ '// ❌ Wrong — Tailwind 既定パレットの色を直接指定する',
1606
+ '<main className="bg-gray-50">',
1607
+ ' <h1 className="character-6-bold-pro text-red-600">認証エラー</h1>',
1608
+ '</main>',
1609
+ '```',
1610
+ '',
1611
+ 'Sparkle のプリミティブは Tailwind の同名変数をそのまま参照しているため',
1612
+ '(`--color-negative-600: var(--color-red-600)`)、`gray` / `red` / `green` / `yellow` / `blue`',
1613
+ 'の 5 系統は**置き換えても値が変わらない**。それ以外(`slate` / `zinc` / `emerald` …)は',
1614
+ '色味が変わるので、移行先は候補として提示するだけで自動変換はしない。',
1615
+ '',
1616
+ '| if(状況) | then(移行先) |',
1617
+ '|---|---|',
1618
+ '| 背景に `bg-gray-100` | `bg-surface-base-100`(ページ地の専用トークン) |',
1619
+ '| 文字色に `text-red-600` | `text-text-negative-enabled` |',
1620
+ '| 枠線に `border-gray-200` | `border-border-neutral-*` |',
1621
+ '| アイコン色に `fill-red-600` | `fill-object-negative-enabled` |',
1622
+ '| `bg-blue-600` | ブランド色なら `surface-primary-*`、状態表示なら `surface-info-*`。**Figma を見て決める** |',
1623
+ '| `text-purple-500` など対応する意味が無い色 | 用途から選び直す。装飾用の面なら `bg-surface-accent-1` 〜 `3` |',
1624
+ '',
1625
+ '`surface-base-*` は `0` / `100` / `200` の 3 段しか無い。`bg-gray-50` のように対応する',
1626
+ '段が無いレベルは `surface-neutral-*` 側に案内される。**移行先は check の出力に従うこと**',
1627
+ '(この表は用途の考え方を示すもので、レベルごとの対応はコマンドが出す)。',
1628
+ '',
1629
+ '`text-neutral-*` は Sparkle の旧セマンティック層と同名なので、このルールではなく',
1630
+ '`legacy-color-token` が扱う(同じ箇所を二重に報告しないため)。',
1631
+ '',
1632
+ 'このルールは **Sparkle を使っているファイルにだけ**適用される(Sparkle からの import か',
1633
+ '`character-*` の使用がある場合)。素の React コードや、Sparkle と無関係なユーティリティは対象外。',
1634
+ '',
1635
+ '検査対象は `.js` / `.jsx` / `.ts` / `.tsx` のみで、**`.css` の `@apply` は見ていない**',
1636
+ '(`legacy-color-token` は `.css` も見るので、そちらとは対象範囲が違う)。CSS 側に',
1637
+ 'Tailwind 既定パレットの色が残っていないかは手で確認すること。',
1638
+ ]),
1639
+ jsdocTargets: [],
1640
+ },
1641
+ {
1642
+ id: 'legacy-color-var',
1643
+ check: {
1644
+ severity: SEVERITY.WARNING,
1645
+ // `.css` も対象にする(`@apply` のユーティリティ、`var(--color-*)` の直接参照)
1646
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1647
+ description: '旧セマンティックカラーの CSS 変数を直接参照しています',
1648
+ recommendation:
1649
+ '新しい用途別セマンティックトークンの変数に置き換えてください。用途が変数名から決まらない場合は Figma を確認してください。',
1650
+ match: migrationMatcher(LEGACY_CSS_VAR_PATTERN, resolveLegacyCssVar, '旧トークン'),
1651
+ },
1652
+ featureSection: lines([
1653
+ '### 旧セマンティックカラーの CSS 変数を直接参照しない',
1654
+ '',
1655
+ '```tsx',
1656
+ '// ✅ Correct',
1657
+ '<div className="ring-2 ring-[var(--color-border-ring)] ring-offset-2" />',
1658
+ '',
1659
+ '// ❌ Wrong — beta 終了時に削除される旧トークン',
1660
+ '<div className="ring-2 ring-[var(--color-ring-normal)] ring-offset-2" />',
1661
+ '```',
1662
+ '',
1663
+ 'フォーカスリングの `--color-ring-normal` は `--color-border-ring`(blue/700 固定)に移行する。',
1664
+ '`--color-primary-600` のようなスケール付きの旧変数は、変数名だけでは用途',
1665
+ '(surface / text / border / object)が決まらないため**自動変換しない**。',
1666
+ ]),
1667
+ jsdocTargets: [],
1668
+ },
1669
+ {
1670
+ id: 'deprecated-radius-alias',
1671
+ check: {
1672
+ severity: SEVERITY.WARNING,
1673
+ // `.css` も対象にする(`@apply` のユーティリティ、`var(--color-*)` の直接参照)
1674
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1675
+ description: 'beta 期間だけ残している後方互換エイリアスを直接参照しています',
1676
+ recommendation: 'リネーム後の変数を直接参照してください。',
1677
+ match: migrationMatcher(DEPRECATED_ALIAS_PATTERN, resolveLegacyCssVar, '後方互換エイリアス'),
1678
+ },
1679
+ featureSection: lines([
1680
+ '### 削除予定の後方互換エイリアスを直接参照しない',
1681
+ '',
1682
+ '```tsx',
1683
+ '// ✅ Correct',
1684
+ '<div className="rounded-[var(--radius-container)]" />',
1685
+ '',
1686
+ '// ❌ Wrong — beta 終了時に削除され、角丸がサイレントに消える',
1687
+ '<div className="rounded-[var(--radius-halfModal)]" />',
1688
+ '```',
1689
+ '',
1690
+ 'Figma の `Borders: Semantics` で `halfModal` は `container` にリネームされた。',
1691
+ '`--radius-halfModal` は beta 期間だけのエイリアスで、消えても CSS としては',
1692
+ '有効なまま(値が空になるだけ)なので**ビルドエラーにならずに角丸だけが失われる**。',
1693
+ ]),
1694
+ jsdocTargets: [],
1695
+ },
1696
+ {
1697
+ id: 'use-figma-spacing-scale',
1698
+ check: {
1699
+ // `info` から始める。既存 consumer にとっては未知の制約で、しかも
1700
+ // 「動かない」わけではなく「スケールから外れている」だけなので、
1701
+ // いきなり warning にすると移行ルールの警告に埋もれて全部読み流される。
1702
+ // en: Ships at `info` — it's a new constraint on working code, and
1703
+ // shouting would drown out the migration warnings that do need action.
1704
+ severity: SEVERITY.INFO,
1705
+ // `.css` も対象にする(`@apply p-7` のような書き方を拾うため)
1706
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1707
+ description: 'Figma の Spacing: Primitives に無い余白ステップを使っています',
1708
+ recommendation:
1709
+ 'Figma のスケールに乗ったステップに寄せてください。判断が付かない場合は Figma の該当箇所を確認してください。',
1710
+ match: spacingScaleMatcher,
1711
+ },
1712
+ featureSection: lines([
1713
+ '### 余白は Figma の Spacing: Primitives のステップだけ使う',
1714
+ '',
1715
+ 'Figma の余白は 17 段(0 / 2 / 4 / 6 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48 / 56 / 72 / 88 / 104 / 120 px)。',
1716
+ 'Tailwind の 1 ステップは 4px なので、`p-4` = 16px、`p-6` = 24px が対応する。',
1717
+ '',
1718
+ '```tsx',
1719
+ '// ✅ Correct — 6=24px / 4=16px / 8=32px はいずれもスケール上にある',
1720
+ '<div className="p-6 gap-4 mt-8" />',
1721
+ '',
1722
+ '// ❌ Wrong — 7 は 28px。24px と 32px の間にステップは無い',
1723
+ '<div className="p-7" />',
1724
+ '```',
1725
+ '',
1726
+ '**Tailwind の数字と Figma の数字は一致しない。** Figma の `padding/16` は 16px、',
1727
+ 'Tailwind の `p-16` は 64px で 4 倍違う。Figma の値を見て同じ数字を書かないこと。',
1728
+ '',
1729
+ '| Figma | px | Tailwind |',
1730
+ '|---|---|---|',
1731
+ '| `padding/8` | 8px | `p-2` |',
1732
+ '| `padding/16` | 16px | `p-4` |',
1733
+ '| `padding/24` | 24px | `p-6` |',
1734
+ '| `padding/120` | 120px | `p-30` |',
1735
+ '',
1736
+ 'このスケールは **CSS 変数として出力されていない**。`--spacing-16` のような',
1737
+ '名前付きキーを定義すると Tailwind の `p-16` がその値に差し替わり、',
1738
+ '既存コードの余白がビルドも警告も通ったまま変わってしまうため。',
1739
+ 'だから「変数を使う」ではなく「使ってよい値を守る」という形の制約になっている。',
1740
+ '',
1741
+ 'デザイン上どうしてもスケール外の値が要る場合は、Figma 側にステップを追加するのが本筋。',
1742
+ '個別に外すなら `sparkle-disable-next-line use-figma-spacing-scale` を使う。',
1743
+ ]),
1744
+ jsdocTargets: [],
1745
+ },
1746
+ ];
1747
+
1748
+ const BUILTIN_ANTI_PATTERN_GROUPS = [...COMPONENT_ANTI_PATTERN_GROUPS, ...TOKEN_MIGRATION_GROUPS];
1749
+
1208
1750
  const BUILTIN_CHECK_ORDER = [
1209
1751
  'dialog-form',
1210
1752
  'dialog-button-wrap',
@@ -1219,6 +1761,17 @@ const BUILTIN_CHECK_ORDER = [
1219
1761
  'disabled-vs-is-disabled',
1220
1762
  'button-prefixicon-jsx',
1221
1763
  'icon-children-text',
1764
+ // NOTE: この配列が決めるのは **ルールの評価順** だけで、レポートの表示順では
1765
+ // ない(findings は check.js 側で severity 優先にソートされる)。移行ルールを
1766
+ // 末尾に置いているのは評価順を安定させるためで、「warning が error を埋もれ
1767
+ // させない」保証は severity ソートが担っている。
1768
+ // en: This only orders rule *evaluation*. Report ordering is by severity in
1769
+ // check.js — don't read this list as a display-order guarantee.
1770
+ 'legacy-color-token',
1771
+ 'tailwind-palette-color',
1772
+ 'legacy-color-var',
1773
+ 'deprecated-radius-alias',
1774
+ 'use-figma-spacing-scale',
1222
1775
  ];
1223
1776
 
1224
1777
  function getCheckRules(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
@@ -1227,6 +1780,8 @@ function getCheckRules(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
1227
1780
  .map((group) => ({
1228
1781
  id: group.id,
1229
1782
  ...group.check,
1783
+ severity: normalizeSeverity(group.check.severity, group.id),
1784
+ targets: normalizeRuleTargets(group.check.targets, group.id),
1230
1785
  }))
1231
1786
  .sort((left, right) => {
1232
1787
  const leftIndex = BUILTIN_CHECK_ORDER.indexOf(left.id);