sparkle-design-cli 2.4.1 → 2.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,678 @@
1
+ /**
2
+ * 旧セマンティックトークン → 新セマンティックトークンの移行マッピング。
3
+ *
4
+ * このファイルが移行知識の唯一の source of truth。`check`(アンチパターン
5
+ * ルールの recommendation 文面)と、将来の `migrate` サブコマンド
6
+ * (goodpatch/sparkle-design-cli#73)の両方がここを参照する。同じ対応表を
7
+ * 2 箇所に書くと必ず片方だけ更新されて「ルールが嘘をつく」状態になるため。
8
+ *
9
+ * en: Single source of truth for the legacy -> new semantic token migration.
10
+ * Both the `check` anti-pattern rules and the planned `migrate` subcommand
11
+ * read from here, so the mapping can never drift between them.
12
+ *
13
+ * ## 設計の背景
14
+ *
15
+ * 旧セマンティック層は「意味名 + スケール値」(`bg-primary-500`)、新体系は
16
+ * 「用途 + 意味 + 強度 + 状態」(`bg-surface-primary-high-enabled`)で設計思想が
17
+ * 異なる。とくに **状態がトークン名に内包された**ことで、同じ旧クラスでも
18
+ * 文脈(Tailwind の variant 接頭辞)によって移行先が変わる。
19
+ *
20
+ * そのため単純な文字列置換表ではなく、次の 3 分類で返す:
21
+ *
22
+ * - `auto` … 移行先が一意に決まる。値も変わらない
23
+ * - `review` … 候補が複数ある / 対応する新トークンが無い。人間の判断が要る
24
+ * - `structural` … トークン差し替えでは済まない(マークアップ変更を伴う)
25
+ *
26
+ * ## 用途プレフィックスの対応
27
+ *
28
+ * Tailwind のユーティリティ接頭辞は、そのまま Figma の「用途」軸に対応する。
29
+ * `bg-` → surface(背景) / `text-` → text(文字) / `border-`・`divide-`・
30
+ * `outline-`・`ring-` → border(枠線) / `fill-`・`stroke-` → object(図形)。
31
+ *
32
+ * ## 値が変わらないことの保証
33
+ *
34
+ * 下の SCALE_MAP は「旧トークンが指していたプリミティブのレベル」を key に
35
+ * 「同じプリミティブを指す新トークン」を並べて作ってある(例: 旧
36
+ * `primary-600` と新 `surface-primary-high-enabled` はどちらも
37
+ * `var(--color-<primary>-600)`)。つまり `auto` 分類の変換は**見た目が変わらない**。
38
+ * この不変条件は test/token-migration.test.js が実際に生成した CSS を読んで
39
+ * 継続的に検証する(表と sparkle-variables のテンプレートが食い違ったら落ちる)。
40
+ * en: Every `auto` mapping is value-preserving by construction, and
41
+ * test/token-migration.test.js re-verifies that against generated CSS.
42
+ */
43
+
44
+ export const MIGRATION_CLASS = {
45
+ AUTO: 'auto',
46
+ REVIEW: 'review',
47
+ STRUCTURAL: 'structural',
48
+ };
49
+
50
+ /** Tailwind ユーティリティ接頭辞 → Figma の用途カテゴリ */
51
+ export const UTILITY_PREFIX_TO_CATEGORY = {
52
+ bg: 'surface',
53
+ text: 'text',
54
+ // placeholder-* も文字色。LEGACY_TOKEN_MAP 側で `placeholder-text-placeholder`
55
+ // を扱っているのにここに無いと、`placeholder-neutral-500` のようなスケール
56
+ // 形式だけが素通りする。
57
+ // en: `placeholder-*` is a text colour too — without it the scale form slips
58
+ // through while the standalone form is detected.
59
+ placeholder: 'text',
60
+ border: 'border',
61
+ divide: 'border',
62
+ outline: 'border',
63
+ ring: 'border',
64
+ fill: 'object',
65
+ stroke: 'object',
66
+ };
67
+
68
+ /** 旧セマンティック層のファミリー名。`secondary` は `neutral` に統合された */
69
+ export const LEGACY_FAMILY_ALIASES = {
70
+ secondary: 'neutral',
71
+ };
72
+
73
+ export const LEGACY_FAMILIES = [
74
+ 'primary',
75
+ 'secondary',
76
+ 'info',
77
+ 'success',
78
+ 'warning',
79
+ 'negative',
80
+ 'neutral',
81
+ 'base',
82
+ ];
83
+
84
+ export const LEGACY_LEVELS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
85
+
86
+ /**
87
+ * 用途カテゴリ × ファミリー × 旧レベル → 新トークン名(`--color-` を除いた部分)。
88
+ * 値が配列のときは候補が複数あるという意味で、variant 接頭辞で絞れなければ
89
+ * `review` になる。
90
+ */
91
+ const SCALE_MAP = {
92
+ surface: {
93
+ primary: {
94
+ 600: 'surface-primary-high-enabled',
95
+ 700: 'surface-primary-high-hover',
96
+ 800: 'surface-primary-high-active',
97
+ 50: ['surface-primary-middle-enabled', 'surface-primary-middle-disabled'],
98
+ 100: 'surface-primary-middle-hover',
99
+ 200: ['surface-primary-high-disabled', 'surface-primary-middle-active'],
100
+ },
101
+ negative: {
102
+ 600: 'surface-negative-high-enabled',
103
+ 700: 'surface-negative-high-hover',
104
+ 800: 'surface-negative-high-active',
105
+ 50: ['surface-negative-middle-enabled', 'surface-negative-middle-disabled'],
106
+ 100: 'surface-negative-middle-hover',
107
+ 200: ['surface-negative-high-disabled', 'surface-negative-middle-active'],
108
+ },
109
+ neutral: {
110
+ 600: 'surface-neutral-high-enabled',
111
+ 700: 'surface-neutral-high-hover',
112
+ 800: 'surface-neutral-high-active',
113
+ 50: ['surface-neutral-middle-enabled', 'surface-neutral-middle-disabled'],
114
+ 100: 'surface-neutral-middle-hover',
115
+ 200: ['surface-neutral-high-disabled', 'surface-neutral-middle-active'],
116
+ },
117
+ info: { 500: 'surface-info-high', 100: 'surface-info-middle', 50: 'surface-info-low' },
118
+ success: {
119
+ 500: 'surface-success-high',
120
+ 100: 'surface-success-middle',
121
+ 50: 'surface-success-low',
122
+ },
123
+ warning: {
124
+ 600: 'surface-warning-high',
125
+ 100: 'surface-warning-middle',
126
+ 50: 'surface-warning-low',
127
+ },
128
+ base: { 100: 'surface-base-100', 200: 'surface-base-200' },
129
+ },
130
+ text: {
131
+ primary: {
132
+ 600: 'text-primary-enabled',
133
+ 700: 'text-primary-hover',
134
+ 800: 'text-primary-active',
135
+ 200: 'text-primary-disabled',
136
+ },
137
+ negative: {
138
+ 600: 'text-negative-enabled',
139
+ 700: 'text-negative-hover',
140
+ 800: 'text-negative-active',
141
+ 200: 'text-negative-disabled',
142
+ },
143
+ neutral: {
144
+ 900: 'text-neutral-high',
145
+ 700: 'text-neutral-middle',
146
+ 500: 'text-neutral-low',
147
+ 300: 'text-neutral-disabled',
148
+ },
149
+ info: { 600: 'text-info' },
150
+ success: { 700: 'text-success' },
151
+ warning: { 700: 'text-warning' },
152
+ },
153
+ border: {
154
+ primary: {
155
+ 500: 'border-primary-extra-high',
156
+ 300: 'border-primary-high',
157
+ 100: 'border-primary-low',
158
+ },
159
+ negative: {
160
+ 500: 'border-negative-extra-high-enabled',
161
+ 600: 'border-negative-extra-high-hover',
162
+ 200: 'border-negative-extra-high-disabled',
163
+ 300: 'border-negative-high',
164
+ 100: 'border-negative-low',
165
+ },
166
+ neutral: {
167
+ 500: 'border-neutral-extra-high-enabled',
168
+ 600: 'border-neutral-extra-high-hover',
169
+ 200: ['border-neutral-extra-high-disabled', 'border-neutral-middle'],
170
+ 300: 'border-neutral-high',
171
+ 100: 'border-neutral-low',
172
+ },
173
+ info: { 300: 'border-info' },
174
+ success: { 300: 'border-success' },
175
+ warning: { 300: 'border-warning' },
176
+ },
177
+ object: {
178
+ primary: {
179
+ 600: 'object-primary-enabled',
180
+ 700: 'object-primary-hover',
181
+ 800: 'object-primary-active',
182
+ 200: 'object-primary-disabled',
183
+ },
184
+ negative: {
185
+ 600: 'object-negative-enabled',
186
+ 700: 'object-negative-hover',
187
+ 800: 'object-negative-active',
188
+ 200: 'object-negative-disabled',
189
+ },
190
+ neutral: {
191
+ 900: 'object-neutral-high',
192
+ 700: 'object-neutral-middle',
193
+ 500: 'object-neutral-low',
194
+ 300: 'object-neutral-disabled',
195
+ },
196
+ info: { 600: 'object-info' },
197
+ success: { 700: 'object-success' },
198
+ warning: { 700: 'object-warning' },
199
+ },
200
+ };
201
+
202
+ /**
203
+ * スケールを持たない旧トークン(`--color-text-high` 等)の 1:1 対応。
204
+ * `sameValue` はどちらも同じプリミティブを指す=見た目が変わらないという意味。
205
+ */
206
+ export const LEGACY_TOKEN_MAP = [
207
+ { from: 'text-high', to: 'text-neutral-high', utilities: ['text'], sameValue: true },
208
+ { from: 'text-middle', to: 'text-neutral-middle', utilities: ['text'], sameValue: true },
209
+ { from: 'text-low', to: 'text-neutral-low', utilities: ['text'], sameValue: true },
210
+ { from: 'text-disabled', to: 'text-neutral-disabled', utilities: ['text'], sameValue: true },
211
+ {
212
+ from: 'text-placeholder',
213
+ to: 'text-neutral-low',
214
+ utilities: ['text', 'placeholder'],
215
+ sameValue: true,
216
+ classification: MIGRATION_CLASS.REVIEW,
217
+ reason:
218
+ 'placeholder 専用トークンは新体系には無く、`text/neutral/low` に統合された(値は同じ gray/500)。' +
219
+ 'placeholder と通常の低強度テキストを将来別の色にしたい場合は、統合してよいかを確認してから置換してください。',
220
+ },
221
+ {
222
+ from: 'divider-low',
223
+ to: 'border-neutral-low',
224
+ utilities: ['border', 'divide'],
225
+ sameValue: true,
226
+ },
227
+ {
228
+ from: 'divider-middle',
229
+ to: 'border-neutral-middle',
230
+ utilities: ['border', 'divide'],
231
+ sameValue: true,
232
+ },
233
+ {
234
+ from: 'divider-high',
235
+ to: 'border-neutral-high',
236
+ utilities: ['border', 'divide'],
237
+ sameValue: true,
238
+ },
239
+ { from: 'ring-normal', to: 'border-ring', utilities: ['ring', 'border'], sameValue: true },
240
+ {
241
+ from: 'skeleton-fill',
242
+ to: null,
243
+ utilities: ['bg'],
244
+ classification: MIGRATION_CLASS.REVIEW,
245
+ reason:
246
+ 'Figma の `Colors: Semantics` に対応するトークンが無い。値が同じ `surface/neutral/middle/active`(gray/200)に寄せるか、' +
247
+ 'スケルトン専用トークンを Figma 側に追加してもらうかをデザイナーと決めてください。',
248
+ },
249
+ ];
250
+
251
+ /**
252
+ * beta 期間の後方互換エイリアス。beta 終了時に削除されるため、直接参照は
253
+ * 消えた瞬間にサイレントで壊れる(角丸が none になる)。
254
+ */
255
+ export const DEPRECATED_VAR_ALIASES = [
256
+ {
257
+ from: '--radius-halfModal',
258
+ to: '--radius-container',
259
+ reason:
260
+ 'Figma の `Borders: Semantics` で `halfModal` は `container` にリネームされた。' +
261
+ '`--radius-halfModal` は beta 期間だけ残す後方互換エイリアスで、削除されると角丸がサイレントに消える。',
262
+ },
263
+ ];
264
+
265
+ /** Tailwind の variant 接頭辞 → 新トークンが内包する状態 */
266
+ const VARIANT_TO_STATE = {
267
+ hover: 'hover',
268
+ active: 'active',
269
+ disabled: 'disabled',
270
+ 'aria-disabled': 'disabled',
271
+ 'data-disabled': 'disabled',
272
+ };
273
+
274
+ /**
275
+ * variant チェーン全体から、新トークンが内包する状態を割り出す。
276
+ *
277
+ * 最後のセグメントだけを見ると `group-hover:` / `peer-hover:` / `dark:hover:` を
278
+ * 取りこぼす。実コードで最頻の `group-hover:` が「候補が 1 件しか無いのに要判断」
279
+ * に落ちていたため、全セグメントを走査し `group-` / `peer-` 接頭と `[` `]` を
280
+ * 剥がしてから対応表を引く。
281
+ *
282
+ * `not-hover:` のような否定 variant は剥がした後も対応表に無いので `null` に
283
+ * なる(= 状態で絞り込まない)。ここで hover 扱いにすると意味が反転するため、
284
+ * 部分一致ではなく完全一致で引くことが重要。
285
+ * en: Scan every variant segment (not just the last) and strip `group-`/`peer-`
286
+ * so `group-hover:` resolves. Uses exact lookup, never substring matching —
287
+ * `not-hover:` must not be read as `hover`.
288
+ *
289
+ * @param {string} variantPrefix 例 `dark:group-hover:`
290
+ * @returns {string | null}
291
+ */
292
+ function splitVariantSegments(variantPrefix) {
293
+ // `supports-[display:grid]:` のように角括弧の中に `:` を含む variant があるため、
294
+ // 素朴な `split(':')` では `grid]` のような壊れたセグメントができる。さらに
295
+ // `supports-[&:hover]:` だと `hover]` → 角括弧を剥がすと `hover` になり、
296
+ // **hover 状態のトークンだと誤判定して意味の違うトークンを案内してしまう**
297
+ // (同じレベルの候補は同じプリミティブを指すので色は変わらないが、
298
+ // トークン名が表す意味がずれる)。角括弧の外側の `:` だけで区切る。
299
+ // en: Split only on colons outside brackets — `supports-[&:hover]:` would
300
+ // otherwise yield a `hover]` segment and be misread as the hover state.
301
+ const segments = [];
302
+ let current = '';
303
+ let depth = 0;
304
+ for (const char of variantPrefix) {
305
+ if (char === '[') depth += 1;
306
+ else if (char === ']') depth = Math.max(0, depth - 1);
307
+ if (char === ':' && depth === 0) {
308
+ if (current) segments.push(current);
309
+ current = '';
310
+ continue;
311
+ }
312
+ current += char;
313
+ }
314
+ if (current) segments.push(current);
315
+ return segments;
316
+ }
317
+
318
+ function variantState(variantPrefix) {
319
+ for (const segment of splitVariantSegments(variantPrefix)) {
320
+ // `group-` / `peer-` を先に剥がしてから判定する。順序を逆にすると
321
+ // `group-data-[disabled]:` が「括弧つき = 判定に使わない」に落ちて、
322
+ // `group-hover:` は解決できるのにこちらはできない、という不整合になる。
323
+ const withoutScope = segment.replace(/^(?:group|peer)-/, '');
324
+ // 角括弧つきの arbitrary variant(`supports-[...]` / `[&:hover]`)は
325
+ // 状態の判定に使わない。`data-[disabled]` のような Tailwind 標準形だけ
326
+ // 括弧を剥がして引く。
327
+ const normalized = /^(?:data|aria)-\[[a-z-]+\]$/.test(withoutScope)
328
+ ? withoutScope.replace(/[[\]]/g, '')
329
+ : withoutScope;
330
+ if (normalized.includes('[')) continue;
331
+ if (VARIANT_TO_STATE[normalized]) return VARIANT_TO_STATE[normalized];
332
+ }
333
+ return null;
334
+ }
335
+
336
+ const STATE_SUFFIXES = ['enabled', 'hover', 'active', 'disabled'];
337
+
338
+ function tokenState(tokenName) {
339
+ const last = tokenName.split('-').pop();
340
+ return STATE_SUFFIXES.includes(last) ? last : null;
341
+ }
342
+
343
+ /**
344
+ * `base-*` は旧体系では gray(= `neutral`)と同値。ただし Figma には
345
+ * `surface/base/*`(ページ地の背景)という専用トークンがあるので、surface では
346
+ * それを優先し、対応レベルが無いところだけ `neutral` で埋める。
347
+ * text / border / object には `base` が無いので全面的に `neutral` を使う。
348
+ * en: `base-*` equals gray. Prefer Figma's dedicated `surface/base/*` where it
349
+ * exists and fall back to `neutral` elsewhere — both resolve to the same
350
+ * primitive, so the substitution stays value-preserving.
351
+ */
352
+ function familyScaleMap(category, family) {
353
+ const byFamily = SCALE_MAP[category] ?? {};
354
+ if (family !== 'base') return byFamily[family];
355
+ const merged = { ...(byFamily.neutral ?? {}), ...(byFamily.base ?? {}) };
356
+ return Object.keys(merged).length > 0 ? merged : undefined;
357
+ }
358
+
359
+ // Tailwind の opacity modifier(`/50`・`/[0.6]`)と important(末尾 `!`)。
360
+ // これらを落としたまま「自動変換可」として置換先を案内すると、案内どおりに
361
+ // 直したときに**不透明度や詳細度が黙って変わる**。`auto` は見た目が変わらない、
362
+ // という本モジュールの中心的な保証が破れる唯一の経路なので、必ず持ち回して
363
+ // 置換先の末尾に付け直す。
364
+ // en: Carry the opacity modifier / important flag through to the replacement.
365
+ // Dropping them would silently change opacity or specificity, breaking the
366
+ // "auto conversions are value-preserving" guarantee.
367
+ // 角括弧つきの arbitrary value(`/[var(--x)]` `/[calc(1/3)]`)は括弧の中に
368
+ // `(` `/` を含むため、単純な文字クラスでは途中で切れてしまう。切れたまま
369
+ // 「自動変換可」として案内すると、案内どおり直したクラスが壊れる。
370
+ // en: Bracketed arbitrary modifiers contain `(` and `/`, so a plain character
371
+ // class truncates them and would emit a broken replacement.
372
+ const MODIFIER_BODY = '(?:\\[[^\\]\\s]*\\]|[A-Za-z0-9._%-]+)';
373
+ const MODIFIER_SUFFIX_PATTERN = new RegExp(`^(.*?)((?:\\/${MODIFIER_BODY})?!?)$`);
374
+
375
+ /**
376
+ * ユーティリティクラス 1 個を解析して移行結果を返す。
377
+ *
378
+ * @param {string} utility 例 `hover:bg-primary-700` / `text-text-high`
379
+ * @returns {null | {
380
+ * input: string,
381
+ * classification: 'auto'|'review'|'structural',
382
+ * replacement?: string,
383
+ * candidates?: string[],
384
+ * reason?: string,
385
+ * }} 旧トークンを使っていなければ null
386
+ */
387
+ export function resolveLegacyUtility(utility) {
388
+ if (typeof utility !== 'string' || utility.length === 0) return null;
389
+
390
+ const lastColon = utility.lastIndexOf(':');
391
+ const variantPrefix = lastColon === -1 ? '' : utility.slice(0, lastColon + 1);
392
+ let rawBare = lastColon === -1 ? utility : utility.slice(lastColon + 1);
393
+
394
+ // Tailwind v3 形式の先頭 `!` を切り離して置換先に付け直す
395
+ const importantPrefix = rawBare.startsWith('!') ? '!' : '';
396
+ if (importantPrefix) rawBare = rawBare.slice(1);
397
+
398
+ // opacity modifier / important を切り離してから解析し、置換先に付け直す
399
+ const [, bare, modifier] = MODIFIER_SUFFIX_PATTERN.exec(rawBare);
400
+
401
+ const dashIndex = bare.indexOf('-');
402
+ if (dashIndex === -1) return null;
403
+ const prefix = bare.slice(0, dashIndex);
404
+ const rest = bare.slice(dashIndex + 1);
405
+
406
+ const wrap = (result) => ({ input: utility, ...result });
407
+ const withVariant = (tokenName) =>
408
+ `${variantPrefix}${importantPrefix}${prefix}-${tokenName}${modifier}`;
409
+
410
+ // 1. スケールを持たない旧トークン
411
+ const direct = LEGACY_TOKEN_MAP.find(
412
+ (entry) => entry.from === rest && entry.utilities.includes(prefix)
413
+ );
414
+ if (direct) {
415
+ if (!direct.to) {
416
+ return wrap({ classification: MIGRATION_CLASS.REVIEW, reason: direct.reason });
417
+ }
418
+ return wrap({
419
+ classification: direct.classification ?? MIGRATION_CLASS.AUTO,
420
+ replacement: withVariant(direct.to),
421
+ reason: direct.reason,
422
+ });
423
+ }
424
+
425
+ // 2. 「意味名 + スケール値」形式
426
+ const category = UTILITY_PREFIX_TO_CATEGORY[prefix];
427
+ if (!category) return null;
428
+
429
+ const scaleMatch = /^([a-z]+)-(\d{2,3})$/.exec(rest);
430
+ if (!scaleMatch) return null;
431
+ const rawFamily = scaleMatch[1];
432
+ const level = scaleMatch[2];
433
+ if (!LEGACY_FAMILIES.includes(rawFamily) || !LEGACY_LEVELS.includes(level)) return null;
434
+
435
+ const family = LEGACY_FAMILY_ALIASES[rawFamily] ?? rawFamily;
436
+ const familyMap = familyScaleMap(category, family);
437
+ if (!familyMap) {
438
+ return wrap({
439
+ classification: MIGRATION_CLASS.REVIEW,
440
+ reason:
441
+ `${prefix}-(用途: ${category})と ${rawFamily} の組み合わせに対応する新トークンがありません。` +
442
+ 'Figma の該当箇所を見て用途から選び直してください。',
443
+ });
444
+ }
445
+
446
+ const raw = familyMap[level];
447
+ if (!raw) {
448
+ return wrap({
449
+ classification: MIGRATION_CLASS.REVIEW,
450
+ reason:
451
+ `${rawFamily}-${level} をそのまま引き継ぐ ${category} トークンはありません(新体系は用途ごとに使うレベルを絞っている)。` +
452
+ `利用可能: ${Object.values(familyMap).flat().join(', ')}`,
453
+ });
454
+ }
455
+
456
+ const candidates = Array.isArray(raw) ? raw : [raw];
457
+
458
+ // variant 接頭辞(hover: / group-hover: / disabled: 等)があれば状態で絞り込む
459
+ const variantName = variantPrefix.replace(/:$/, '') || '(なし)';
460
+ const wantedState = variantState(variantPrefix);
461
+ if (wantedState) {
462
+ const matched = candidates.filter((token) => tokenState(token) === wantedState);
463
+ if (matched.length === 1) {
464
+ return wrap({ classification: MIGRATION_CLASS.AUTO, replacement: withVariant(matched[0]) });
465
+ }
466
+ if (matched.length > 1) {
467
+ // 状態で絞ってもなお複数残る場合。絞り込み前の candidates を出すと
468
+ // 状態が合わない候補まで並べてしまうので、絞った結果だけを見せる。
469
+ // en: Report the state-filtered candidates, not the unfiltered set.
470
+ return wrap({
471
+ classification: MIGRATION_CLASS.REVIEW,
472
+ candidates: matched,
473
+ reason:
474
+ `variant \`${variantName}:\` で状態を絞っても移行先が一意に決まりません(${matched.join(' / ')})。` +
475
+ '用途を確認して選んでください。',
476
+ });
477
+ }
478
+ if (matched.length === 0 && candidates.some((token) => tokenState(token) !== null)) {
479
+ // `hover:bg-primary-600` のように「variant は hover なのにレベルは enabled 相当」なケース。
480
+ // 機械的に置換すると hover 時の色が変わってしまうので必ず人間に返す。
481
+ return wrap({
482
+ classification: MIGRATION_CLASS.REVIEW,
483
+ candidates,
484
+ reason:
485
+ `variant は \`${variantName}:\` ですが、${rawFamily}-${level} が対応するのは ` +
486
+ `${candidates.join(' / ')} で状態が一致しません。意図した状態のトークンを選んでください。`,
487
+ });
488
+ }
489
+ }
490
+
491
+ if (candidates.length === 1) {
492
+ const token = candidates[0];
493
+ const state = tokenState(token);
494
+ if (state && state !== 'enabled' && !wantedState) {
495
+ // 状態つきトークンなのに variant が無い=どの状態で使っているか判断できない
496
+ return wrap({
497
+ classification: MIGRATION_CLASS.REVIEW,
498
+ candidates,
499
+ reason:
500
+ `${rawFamily}-${level} の移行先 \`${token}\` は状態を内包しています。` +
501
+ `variant 接頭辞が無いため、どの状態で使っているかコードを見て判断してください。`,
502
+ });
503
+ }
504
+ return wrap({ classification: MIGRATION_CLASS.AUTO, replacement: withVariant(token) });
505
+ }
506
+
507
+ return wrap({
508
+ classification: MIGRATION_CLASS.REVIEW,
509
+ candidates,
510
+ reason:
511
+ `${rawFamily}-${level} の移行先が一意に決まりません(${candidates.join(' / ')})。` +
512
+ 'variant 接頭辞が無いか、強度違いで同じレベルを共有しているためです。',
513
+ });
514
+ }
515
+
516
+ // --- check ルール用のパターン生成 -------------------------------------------
517
+
518
+ // Tailwind のクラス名は `-` で連結されるため `\b` だと `border-ring-normal` の
519
+ // 中の `ring-normal` にもマッチしてしまう。`[\w-]` を境界にして誤検出を防ぐ。
520
+ // en: Use `[\w-]` boundaries — `\b` would match `ring-normal` inside
521
+ // `border-ring-normal` and double-report.
522
+ const NOT_CLASS_CHAR_BEFORE = '(?<![\\w-])';
523
+ const NOT_CLASS_CHAR_AFTER = '(?![\\w-])';
524
+
525
+ // 空配列を `join('|')` すると空文字になり、`(?:)` が**あらゆる位置の空文字に
526
+ // マッチする**正規表現ができてしまう(ルールが死ぬのではなく、全ファイルが
527
+ // 誤検出で埋まる)。beta 終了時に DEPRECATED_VAR_ALIASES / LEGACY_TOKEN_MAP を
528
+ // 空にする計画があるため、そのときに踏む地雷を今のうちに潰しておく。
529
+ // en: `join('|')` on an empty list yields `(?:)`, which matches the empty string
530
+ // everywhere. Emptying these tables is planned for the end of beta, so guard now.
531
+ function alternation(values) {
532
+ if (values.length === 0) {
533
+ throw new Error(
534
+ 'alternation() に空配列が渡されました。空の選択肢は「全位置にマッチする正規表現」になるため許容しません。' +
535
+ '対象のテーブルが空になったなら、対応するルール自体を削除してください。'
536
+ );
537
+ }
538
+ // 長いものを先に並べて、部分一致で短い方が先に食われないようにする
539
+ return [...values].sort((a, b) => b.length - a.length).join('|');
540
+ }
541
+
542
+ // Tailwind の variant 接頭辞チェーン(`hover:` / `focus-visible:` /
543
+ // `group-hover:` / `data-[state=open]:` …)をマッチに含める。
544
+ //
545
+ // これが無いと `hover:bg-primary-700` のマッチ文字列が `bg-primary-700` だけに
546
+ // なり、状態を内包する新トークンへの移行先が「variant が無いので判断不能」と
547
+ // 誤って要判断に落ちる。実コードでは状態つきの旧クラスはほぼ必ず variant と
548
+ // セットで書かれているため、ここを取りこぼすと自動変換可のものが大量に
549
+ // 要判断へ流れてしまう。
550
+ // en: Include Tailwind's variant chain in the match. Without it,
551
+ // `hover:bg-primary-700` matches as bare `bg-primary-700` and gets
552
+ // misclassified as "needs review" for lack of a variant — which is most of
553
+ // the state-bearing legacy classes in real code.
554
+ const VARIANT_CHAIN = '(?:[a-z][a-z0-9-]*(?:\\[[^\\]\\s]*\\])?:)*';
555
+
556
+ // opacity modifier(`/50` `/[0.6]`)と important(末尾 `!`)もマッチに含める。
557
+ // 含めないと `bg-primary-600/50` が `bg-primary-600` として拾われ、案内する
558
+ // 置換先から `/50` が消えて不透明度が黙って変わる(MODIFIER_SUFFIX_PATTERN
559
+ // のコメント参照)。
560
+ // en: Include the opacity modifier / important flag in the match so they can be
561
+ // carried over to the replacement instead of being silently dropped.
562
+ const MODIFIER_SUFFIX = `(?:\\/${MODIFIER_BODY})?!?`;
563
+ // Tailwind v3 形式の先頭 `!`(`!bg-primary-600`)。v4 は末尾 `!` だが、
564
+ // 移行対象のコードベースには v3 記法が残っていることがある。
565
+ const IMPORTANT_PREFIX = '!?';
566
+
567
+ /** `bg-primary-500` / `hover:bg-primary-700/50` のような「意味名 + スケール値」形式を検出する */
568
+ export function buildLegacyScalePattern() {
569
+ const prefixes = alternation(Object.keys(UTILITY_PREFIX_TO_CATEGORY));
570
+ const families = alternation(LEGACY_FAMILIES);
571
+ const levels = alternation(LEGACY_LEVELS);
572
+ return new RegExp(
573
+ `${NOT_CLASS_CHAR_BEFORE}${VARIANT_CHAIN}${IMPORTANT_PREFIX}(?:${prefixes})-(?:${families})-(?:${levels})${NOT_CLASS_CHAR_AFTER}${MODIFIER_SUFFIX}`,
574
+ 'g'
575
+ );
576
+ }
577
+
578
+ /** `text-text-high` / `bg-divider-low` のようなスケール無し旧トークンを検出する正規表現 */
579
+ export function buildLegacyTokenPattern() {
580
+ const classNames = LEGACY_TOKEN_MAP.flatMap((entry) =>
581
+ entry.utilities.map((prefix) => `${prefix}-${entry.from}`)
582
+ );
583
+ return new RegExp(
584
+ `${NOT_CLASS_CHAR_BEFORE}${VARIANT_CHAIN}${IMPORTANT_PREFIX}(?:${alternation(classNames)})${NOT_CLASS_CHAR_AFTER}${MODIFIER_SUFFIX}`,
585
+ 'g'
586
+ );
587
+ }
588
+
589
+ /**
590
+ * `var(--color-ring-normal)` のような **CSS 変数の直接参照**を検出する正規表現。
591
+ *
592
+ * 実際のコードでは `ring-[var(--color-ring-normal)]` のように arbitrary value
593
+ * 経由で参照されることが多く(sparkle-design だけで 14 箇所)、ユーティリティ
594
+ * クラスのパターンだけでは 1 件も拾えない。
595
+ * en: Real code references these through arbitrary values, so the utility-class
596
+ * pattern alone would miss every occurrence.
597
+ */
598
+ export function buildLegacyCssVarPattern() {
599
+ const standalone = LEGACY_TOKEN_MAP.map((entry) => `--color-${entry.from}`);
600
+ const scaled = LEGACY_FAMILIES.flatMap((family) =>
601
+ LEGACY_LEVELS.map((level) => `--color-${family}-${level}`)
602
+ );
603
+ return new RegExp(
604
+ `${NOT_CLASS_CHAR_BEFORE}(?:${alternation([...standalone, ...scaled])})${NOT_CLASS_CHAR_AFTER}`,
605
+ 'g'
606
+ );
607
+ }
608
+
609
+ /**
610
+ * CSS 変数名 1 個を解析して移行結果を返す。
611
+ *
612
+ * スケール付きの旧トークン(`--color-primary-600`)は、変数名だけでは用途
613
+ * (surface / text / border / object)が分からないため必ず `review` になる。
614
+ * どの用途で使っているかは参照側のコードを見ないと決まらない。
615
+ *
616
+ * @param {string} varName 例 `--color-ring-normal`
617
+ * @returns {null | { input: string, classification: string, replacement?: string, candidates?: string[], reason?: string }}
618
+ */
619
+ export function resolveLegacyCssVar(varName) {
620
+ if (typeof varName !== 'string') return null;
621
+ const alias = DEPRECATED_VAR_ALIASES.find((entry) => entry.from === varName);
622
+ if (alias) {
623
+ return {
624
+ input: varName,
625
+ classification: MIGRATION_CLASS.AUTO,
626
+ replacement: alias.to,
627
+ reason: alias.reason,
628
+ };
629
+ }
630
+
631
+ if (!varName.startsWith('--color-')) return null;
632
+ const tokenName = varName.slice('--color-'.length);
633
+
634
+ const direct = LEGACY_TOKEN_MAP.find((entry) => entry.from === tokenName);
635
+ if (direct) {
636
+ if (!direct.to) {
637
+ return { input: varName, classification: MIGRATION_CLASS.REVIEW, reason: direct.reason };
638
+ }
639
+ return {
640
+ input: varName,
641
+ classification: direct.classification ?? MIGRATION_CLASS.AUTO,
642
+ replacement: `--color-${direct.to}`,
643
+ reason: direct.reason,
644
+ };
645
+ }
646
+
647
+ const scaleMatch = /^([a-z]+)-(\d{2,3})$/.exec(tokenName);
648
+ if (!scaleMatch) return null;
649
+ const rawFamily = scaleMatch[1];
650
+ const level = scaleMatch[2];
651
+ if (!LEGACY_FAMILIES.includes(rawFamily) || !LEGACY_LEVELS.includes(level)) return null;
652
+
653
+ const family = LEGACY_FAMILY_ALIASES[rawFamily] ?? rawFamily;
654
+ const candidates = Object.keys(SCALE_MAP)
655
+ .flatMap((category) => familyScaleMap(category, family)?.[level] ?? [])
656
+ .map((token) => `--color-${token}`);
657
+
658
+ return {
659
+ input: varName,
660
+ classification: MIGRATION_CLASS.REVIEW,
661
+ candidates,
662
+ reason:
663
+ candidates.length > 0
664
+ ? `変数名だけでは用途(surface / text / border / object)が決まらないため自動変換しません。用途に応じて選んでください。`
665
+ : `${rawFamily}-${level} に対応する新トークンがありません。Figma の該当箇所を見て選び直してください。`,
666
+ };
667
+ }
668
+
669
+ /** `--radius-halfModal` のような削除予定エイリアスへの直接参照を検出する正規表現 */
670
+ export function buildDeprecatedAliasPattern() {
671
+ const names = DEPRECATED_VAR_ALIASES.map((entry) => entry.from);
672
+ return new RegExp(
673
+ `${NOT_CLASS_CHAR_BEFORE}(?:${alternation(names)})${NOT_CLASS_CHAR_AFTER}`,
674
+ 'g'
675
+ );
676
+ }
677
+
678
+ export { SCALE_MAP, familyScaleMap };