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.
@@ -0,0 +1,734 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sparkle-variables のトークン定義に「嘘」が混入していないかを機械的に検証する。
4
+ *
5
+ * このリポジトリは sparkle-design-cli と sparkle-design-theme-settings の
6
+ * 2 つのツールから submodule として直接参照される純粋なデータで、これまで
7
+ * 検証が無かった。人間の目視では見つからない不整合(同じ情報が 2 箇所に
8
+ * あって片方が古い、リネームの片側だけ直っている等)を CI で止めるのが目的。
9
+ *
10
+ * en: Machine-checks the token definitions for the failure modes a human
11
+ * reviewer cannot reliably catch — duplicated sources of truth, half-applied
12
+ * renames, dangling variable references.
13
+ *
14
+ * ## 設計方針: 「0 件マッチ = 成功」を作らない
15
+ *
16
+ * 集合比較だけの検査は、対象が 1 件も取れなかったときに黙って合格する。
17
+ * 接頭辞を一括リネームしたら検査が丸ごと無効化された、という事故が起きても
18
+ * CI は緑のままになる。そのため各検査は「最低これだけの件数を実際に見た」
19
+ * ことを `expect()` で表明してから判定する。
20
+ * en: Every check asserts a minimum number of inspected items, so a check that
21
+ * silently stops matching anything fails instead of vacuously passing.
22
+ *
23
+ * 使い方: node scripts/validate-tokens.mjs
24
+ * 終了コード: 0 = 全て OK / 1 = 違反あり
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
33
+ const readJSON = (f) => JSON.parse(read(f));
34
+
35
+ const failures = [];
36
+ const checks = [];
37
+
38
+ /**
39
+ * @param {string} name 検証名(失敗時に表示される)
40
+ * @param {() => string | null} fn 問題があれば説明文字列、無ければ null を返す
41
+ */
42
+ function check(name, fn) {
43
+ let detail = null;
44
+ try {
45
+ detail = fn();
46
+ } catch (err) {
47
+ // 検査コード自身のバグ(TypeError 等)をトークン不整合と誤読しないよう、
48
+ // スタックトレースまで残す。
49
+ // en: Keep the stack so a bug in the check isn't mistaken for bad data.
50
+ detail = `検証自体が失敗しました: ${err.message}\n${err.stack ?? ''}`;
51
+ }
52
+ checks.push({ name, ok: !detail });
53
+ if (detail) failures.push({ name, detail });
54
+ }
55
+
56
+ /** 「実際に N 件以上を検査した」ことを表明する。下回ったら検査が空振りしている。 */
57
+ function expect(count, minimum, what) {
58
+ if (count < minimum) {
59
+ throw new Error(
60
+ `${what}を ${count} 件しか検査できませんでした(最低 ${minimum} 件を想定)。` +
61
+ '検査対象の命名やファイル構造が変わって、検査が空振りしている可能性があります。'
62
+ );
63
+ }
64
+ }
65
+
66
+ /** 違反リストを表示用に切り詰める。省略した件数は必ず明示する。 */
67
+ function summarize(items, limit = 5) {
68
+ if (items.length <= limit) return items.join(', ');
69
+ return `${items.slice(0, limit).join(', ')} ほか ${items.length - limit} 件`;
70
+ }
71
+
72
+ // CSS コメントは検査対象から外す。説明文の中に架空の変数名を書いた瞬間に
73
+ // CI が落ちる、という理不尽を避ける。
74
+ // en: Strip comments so prose examples don't count as real references.
75
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
76
+
77
+ // `var(--x)` と `var(--x, fallback)` の両方にマッチする。
78
+ // en: Matches both `var(--x)` and `var(--x, fallback)`.
79
+ const varRefPattern = () => /var\(\s*(--[a-zA-Z0-9_-]+)\s*[,)]/g;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // 入力の読み込み(壊れた入力でも他の検査結果を出せるよう check の中で行う)
83
+ // ---------------------------------------------------------------------------
84
+ let colors;
85
+ let gray;
86
+ let template;
87
+ let templateNoComments;
88
+ let radiusCsv;
89
+ let RADIUS_HEADER = [];
90
+ let RADIUS_ROWS = [];
91
+
92
+ check('入力ファイルが読み込めること', () => {
93
+ const problems = [];
94
+ try {
95
+ colors = readJSON('colors.json');
96
+ } catch (err) {
97
+ problems.push(`colors.json: ${err.message}`);
98
+ }
99
+ try {
100
+ gray = readJSON('gray.json');
101
+ } catch (err) {
102
+ problems.push(`gray.json: ${err.message}`);
103
+ }
104
+ try {
105
+ template = read('sparkle-design.template.css');
106
+ templateNoComments = stripComments(template);
107
+ } catch (err) {
108
+ problems.push(`sparkle-design.template.css: ${err.message}`);
109
+ }
110
+ try {
111
+ // radius.csv は CRLF 改行。単純な split('\n') だと末尾カラムに '\r' が残り
112
+ // ヘッダー名が "round\r" になる。CLI 側(lib/file-loader.js の
113
+ // loadRadiusMapping)も同じ理由で /\r?\n/ を使っているので合わせる。
114
+ // en: radius.csv uses CRLF; matches the CLI's own loadRadiusMapping.
115
+ radiusCsv = read('radius.csv')
116
+ .trim()
117
+ .split(/\r?\n/)
118
+ .map((l) => l.split(','));
119
+ RADIUS_HEADER = radiusCsv[0].slice(1);
120
+ RADIUS_ROWS = radiusCsv.slice(1);
121
+ } catch (err) {
122
+ problems.push(`radius.csv: ${err.message}`);
123
+ }
124
+ return problems.length ? problems.join(' / ') : null;
125
+ });
126
+
127
+ // 後続の検査は入力が揃っている前提。1 つでも読めなければここで止める。
128
+ if (failures.length > 0) {
129
+ console.error('❌ 入力ファイルの読み込みに失敗しました:\n');
130
+ for (const { detail } of failures) console.error(` ${detail}\n`);
131
+ process.exit(1);
132
+ }
133
+
134
+ // gray.json のキー 0–9 は CLI の GRAY_LEVEL_MAP で 50–900 に展開される。
135
+ const GRAY_LEVEL_MAP = {
136
+ 0: '50',
137
+ 1: '100',
138
+ 2: '200',
139
+ 3: '300',
140
+ 4: '400',
141
+ 5: '500',
142
+ 6: '600',
143
+ 7: '700',
144
+ 8: '800',
145
+ 9: '900',
146
+ };
147
+
148
+ // colors.json のうち「primary として選べる色相」ではないキー。
149
+ // alpha スケールと、beta 期間の互換用に残している ring。
150
+ const NON_PALETTE_COLOR_KEYS = new Set([
151
+ 'black',
152
+ 'white',
153
+ 'black-alpha',
154
+ 'white-alpha',
155
+ 'ring',
156
+ ]);
157
+
158
+ // テンプレートのブロック境界
159
+ const PRIMITIVE_MARKER = '/* Primitive Tokens */';
160
+ const SEMANTIC_MARKER = '/* セマンティックトークン - CSS変数としてランタイムで利用可能にする */';
161
+ const THEME_INLINE_MARKER = '@theme inline {';
162
+ // `@theme static`。`@theme inline {` には部分一致しないので取り違えない。
163
+ const THEME_MARKER = '@theme static {';
164
+
165
+ function blockAfter(marker) {
166
+ const start = template.indexOf(marker);
167
+ if (start === -1) throw new Error(`マーカーが見つかりません: ${marker}`);
168
+ const braceStart = template.indexOf('{', start);
169
+ const end = template.indexOf('\n}', braceStart);
170
+ if (braceStart === -1 || end === -1) throw new Error(`ブロックの範囲を特定できません: ${marker}`);
171
+ return stripComments(template.slice(braceStart + 1, end));
172
+ }
173
+
174
+ function declaredNames(block, prefixPattern = '[a-zA-Z0-9_-]+') {
175
+ return [...block.matchAll(new RegExp(`^\\s*(--${prefixPattern})\\s*:`, 'gm'))].map((m) => m[1]);
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // 1. gray の source of truth が 1 箇所であること
180
+ // ---------------------------------------------------------------------------
181
+ // colors.json と gray.json の両方に gray があると、CLI の generateColorTokens が
182
+ // --color-gray-* を二重に出力し、後から出る gray.json 側が colors.json 側を
183
+ // サイレントに上書きする。見た目は壊れないので目視では気付けない。
184
+ check('gray の source of truth が gray.json 一本であること', () => {
185
+ if ('gray' in colors) {
186
+ return 'colors.json に `gray` キーがあります。gray は primary ごとに切り替わるため gray.json が正で、両方にあると CLI が --color-gray-* を二重出力し gray.json 側が後勝ちで上書きします。';
187
+ }
188
+ return null;
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // 2. primary 候補の整合(双方向)
193
+ // ---------------------------------------------------------------------------
194
+ // CLI は「colors.json と gray.json の両方にキーがある色」を primary として
195
+ // 使える色とみなす。片方にしか無い色は、選べそうに見えて選べない/選ぶと
196
+ // 壊れる、という分かりにくい状態になる。
197
+ check('colors.json と gray.json の色相が双方向で揃っていること', () => {
198
+ const grayKeys = Object.keys(gray);
199
+ const paletteKeys = Object.keys(colors).filter(
200
+ (k) => !NON_PALETTE_COLOR_KEYS.has(k) && typeof colors[k] === 'object' && colors[k] !== null
201
+ );
202
+ expect(grayKeys.length, 5, 'gray.json の色相');
203
+ expect(paletteKeys.length, 5, 'colors.json のパレット色相');
204
+
205
+ const problems = [];
206
+ const missingInColors = grayKeys.filter((h) => !(h in colors));
207
+ if (missingInColors.length) {
208
+ problems.push(`colors.json に存在しない色相が gray.json にあります: ${missingInColors.join(', ')}`);
209
+ }
210
+ const missingInGray = paletteKeys.filter((h) => !(h in gray));
211
+ if (missingInGray.length) {
212
+ problems.push(
213
+ `gray.json に対応が無いパレット色相があります: ${missingInGray.join(', ')}(primary に選べそうに見えて選ぶと gray が生成されません)`
214
+ );
215
+ }
216
+ // CLI の getValidPrimaryColors はネストしたオブジェクトのみを候補とする。
217
+ // gray.json 側にスカラーが紛れると CLI と validator で判定がずれる。
218
+ const nonObject = grayKeys.filter((h) => typeof colors[h] !== 'object' || colors[h] === null);
219
+ if (nonObject.length) {
220
+ problems.push(`gray.json にあるがパレットではない(スカラー)色: ${nonObject.join(', ')}`);
221
+ }
222
+ return problems.length ? problems.join(' / ') : null;
223
+ });
224
+
225
+ check('gray.json の各色相が 0–9 の 10 段を持つこと', () => {
226
+ const entries = Object.entries(gray);
227
+ expect(entries.length, 5, 'gray.json の色相');
228
+ const bad = entries
229
+ .filter(([, v]) => Object.keys(v).sort().join(',') !== '0,1,2,3,4,5,6,7,8,9')
230
+ .map(([k]) => k);
231
+ return bad.length ? `段数が 0–9 になっていない色相: ${bad.join(', ')}` : null;
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // 3. radius.csv の構造
236
+ // ---------------------------------------------------------------------------
237
+ check('radius.csv の行・列構造が壊れていないこと', () => {
238
+ const problems = [];
239
+ if (RADIUS_HEADER.length === 0) problems.push('ヘッダー行にセマンティック列がありません');
240
+ if (RADIUS_ROWS.length === 0) problems.push('プリセット行がありません');
241
+ expect(RADIUS_HEADER.length, 3, 'radius.csv のセマンティック列');
242
+ expect(RADIUS_ROWS.length, 3, 'radius.csv のプリセット行');
243
+
244
+ const width = RADIUS_HEADER.length + 1;
245
+ const ragged = RADIUS_ROWS.filter((row) => row.length !== width).map((row) => row[0] || '(空行)');
246
+ if (ragged.length) {
247
+ problems.push(`列数がヘッダー(${width})と違う行: ${summarize(ragged)}`);
248
+ }
249
+ const empty = RADIUS_ROWS.flatMap((row) =>
250
+ row.some((cell) => cell.trim() === '') ? [row[0] || '(空行)'] : []
251
+ );
252
+ if (empty.length) problems.push(`空セルを含む行: ${summarize(empty)}`);
253
+ return problems.length ? problems.join(' / ') : null;
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // 4. radius.csv とテンプレートのリネーム整合(双方向)
258
+ // ---------------------------------------------------------------------------
259
+ // CLI は `--radius-<列名>:` への正規表現置換で radius.csv の値を流し込む。
260
+ // 片側だけリネームすると、旧名の変数が残ったまま新名の変数が生成されない、
261
+ // あるいはプロファイルの値が流し込まれない、という事故になる。
262
+ check('radius.csv の列名とテンプレートの --radius-* 変数が双方向で一致すること', () => {
263
+ // プリミティブは「プリミティブ :root ブロックで宣言されている --radius-*」と
264
+ // して**動的に**求める。ハードコードすると、正当な新プリミティブを足したときに
265
+ // 事実と違うメッセージで CI が落ちる(このリポジトリ自身が「同じ情報を 2 箇所に
266
+ // 持つな」と言っている以上、許可リストの二重管理は避ける)。
267
+ // en: Derive primitives from the primitive :root block instead of hardcoding,
268
+ // so adding a legitimate new primitive doesn't fail with a bogus reason.
269
+ const primitives = new Set(declaredNames(blockAfter(PRIMITIVE_MARKER), 'radius-[a-zA-Z0-9-]+').map((n) => n.slice('--radius-'.length)));
270
+ expect(primitives.size, 5, 'radius のプリミティブ');
271
+
272
+ const declared = new Set(
273
+ declaredNames(templateNoComments, 'radius-[a-zA-Z0-9-]+').map((n) => n.slice('--radius-'.length))
274
+ );
275
+ expect(declared.size, primitives.size + 3, 'テンプレートの --radius-* 宣言');
276
+
277
+ const problems = [];
278
+
279
+ const missing = RADIUS_HEADER.filter((k) => !declared.has(k));
280
+ if (missing.length) {
281
+ problems.push(
282
+ `radius.csv に列があるのにテンプレートに --radius-<列名> が無い: ${missing.join(', ')}(CSV だけリネームすると CLI の置換が効きません)`
283
+ );
284
+ }
285
+
286
+ // 逆方向。テンプレートだけリネームすると、新名の変数は CSV に列が無いので
287
+ // プロファイルの値が流し込まれず、置換前のデフォルトのまま出荷される。
288
+ // `halfModal` は「CSV に列を持たない意図的なエイリアス」なので、
289
+ // テンプレート側のコメントで @deprecated と宣言されていることを条件に許す。
290
+ const deprecatedAliases = new Set(
291
+ [...template.matchAll(/@deprecated[^*]*?--radius-([a-zA-Z0-9-]+)/g)].map((m) => m[1])
292
+ );
293
+ const orphan = [...declared].filter(
294
+ (k) => !primitives.has(k) && !deprecatedAliases.has(k) && !RADIUS_HEADER.includes(k)
295
+ );
296
+ if (orphan.length) {
297
+ problems.push(
298
+ `テンプレートに --radius-* があるのに radius.csv に列が無い: ${orphan.join(', ')}(プロファイルの値が流し込まれず置換前のデフォルトのまま出力されます。意図的なエイリアスなら @deprecated コメントに変数名を書いてください)`
299
+ );
300
+ }
301
+
302
+ // 後方互換エイリアスと同名の列が CSV に残っていると、CLI の置換が
303
+ // `--radius-halfModal: var(--radius-container)` を上書きしてしまい、
304
+ // 「container に追従する」というエイリアスの意味が壊れる。
305
+ const shadowed = [...deprecatedAliases].filter((k) => RADIUS_HEADER.includes(k));
306
+ if (shadowed.length) {
307
+ problems.push(
308
+ `後方互換エイリアスと同名の列が radius.csv に残っています: ${shadowed.join(', ')}(CLI の置換がエイリアスの参照先を上書きします)`
309
+ );
310
+ }
311
+
312
+ // CSV のセル値(= 置換後の参照先)が実在するプリミティブであること。
313
+ // これを見ないと、CLI が `--radius-action: var(--radius-4xl)` のような
314
+ // dangling reference を生成しても validator は緑のまま通る。
315
+ // テンプレート単体の dangling 検査(下の検査)と合わせて、
316
+ // 「置換後の CSS にも dangling が無い」ことが担保される。
317
+ // en: Verify every CSV cell resolves to a real primitive — otherwise the CLI
318
+ // emits a dangling `var(--radius-<x>)` that a template-only scan can't see.
319
+ const badCells = [];
320
+ for (const row of RADIUS_ROWS) {
321
+ for (const value of row.slice(1)) {
322
+ if (!primitives.has(value)) badCells.push(`${row[0]} 行の "${value}"`);
323
+ }
324
+ // プロファイル名は `{{RADIUS}}` に代入されて `var(--radius-<プロファイル名>)`
325
+ // になるため、これもプリミティブとして実在する必要がある。
326
+ if (!primitives.has(row[0])) badCells.push(`プロファイル名 "${row[0]}"`);
327
+ }
328
+ if (badCells.length) {
329
+ problems.push(
330
+ `radius.csv の値に対応する --radius-* プリミティブがテンプレートにありません: ${summarize(badCells)}`
331
+ );
332
+ }
333
+
334
+ return problems.length ? problems.join(' / ') : null;
335
+ });
336
+
337
+ // CLI の lib/constants.js が依存している不変条件。プリセット値(none/xs/…)が
338
+ // セマンティックキー名(divide/minimum/…)と文字列衝突すると、置換用の
339
+ // 正規表現が意図しないマッチをする。
340
+ check('radius のプリセット値がセマンティックキー名と衝突しないこと', () => {
341
+ const semanticKeys = new Set(RADIUS_HEADER);
342
+ const collisions = [];
343
+ let inspected = 0;
344
+ for (const row of RADIUS_ROWS) {
345
+ for (const value of row.slice(1)) {
346
+ inspected += 1;
347
+ if (semanticKeys.has(value)) collisions.push(value);
348
+ }
349
+ }
350
+ expect(inspected, 20, 'radius.csv のセル');
351
+ return collisions.length
352
+ ? `プリセット値がセマンティックキー名と衝突しています: ${[...new Set(collisions)].join(', ')}`
353
+ : null;
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // 5. Spacing / Sizing の扱い
358
+ // ---------------------------------------------------------------------------
359
+ // Figma の `Spacing: Primitives` は **CSS 変数として出力してはいけない**。
360
+ // Tailwind v4 の `p-N` / `m-N` / `gap-N` は `calc(var(--spacing) * N)` に
361
+ // コンパイルされるが、`--spacing-<N>` という名前付きキーを定義するとその N だけ
362
+ // 名前付きの値が優先される。`--spacing-2: 2px` を足した瞬間に、既存 consumer の
363
+ // `p-2` が 8px から 2px に変わる。ビルドは通り、警告も出ず、見た目だけが崩れる。
364
+ // Figma のステップは代わりに sparkle-design-cli の `check` ルールで
365
+ // 「使ってよい値」として検査する予定(`use-figma-spacing-scale`。
366
+ // goodpatch/sparkle-design-cli#76 でリリース予定で、**まだ released な CLI には
367
+ // 入っていない**)。
368
+ // en: Never emit `--spacing-*`. Tailwind v4 compiles `p-2` to
369
+ // `calc(var(--spacing) * 2)`; defining `--spacing-2` silently overrides it and
370
+ // changes every consumer's padding. The allowed steps will be enforced by the
371
+ // CLI's `check` rule instead — not in a released CLI yet (cli#76).
372
+ check('テンプレートが --spacing / --spacing-* を定義していないこと', () => {
373
+ const declared = declaredNames(templateNoComments, 'spacing(?:-[a-zA-Z0-9-]+)?');
374
+ return declared.length
375
+ ? `${summarize([...new Set(declared)])} が定義されています。Tailwind v4 の動的スペーシング(\`p-N\` = \`calc(var(--spacing) * N)\`)を名前付きキーが上書きするため、既存 consumer の余白がサイレントに変わります。Figma のステップは CSS 変数ではなく sparkle-design-cli の check ルールで担保する方針です(\`use-figma-spacing-scale\`。goodpatch/sparkle-design-cli#76 でリリース予定)。`
376
+ : null;
377
+ });
378
+
379
+ // `Sizing: Primitives` は逆に衝突しない。Tailwind の `--container-*` 名前空間は
380
+ // 元から名前付きキーだけで構成されていて、動的な計算式を持たないため。
381
+ const FIGMA_MAX_WIDTH_KEYS = [
382
+ 'xs',
383
+ 'sm',
384
+ 'md',
385
+ 'lg',
386
+ 'xl',
387
+ '2xl',
388
+ '3xl',
389
+ '4xl',
390
+ '5xl',
391
+ '6xl',
392
+ '7xl',
393
+ ];
394
+
395
+ check('--container-* が Figma の Sizing: Primitives と過不足なく一致すること', () => {
396
+ const block = blockAfter(THEME_MARKER);
397
+ const declared = declaredNames(block, 'container-[a-zA-Z0-9-]+').map((n) =>
398
+ n.slice('--container-'.length)
399
+ );
400
+
401
+ const problems = [];
402
+ const declaredSet = new Set(declared);
403
+ const missing = FIGMA_MAX_WIDTH_KEYS.filter((k) => !declaredSet.has(k));
404
+ if (missing.length) {
405
+ problems.push(`Figma の max-width/* に対応する変数がありません: ${missing.join(', ')}`);
406
+ }
407
+ const extra = declared.filter((k) => !FIGMA_MAX_WIDTH_KEYS.includes(k));
408
+ if (extra.length) {
409
+ problems.push(
410
+ `Figma の Sizing: Primitives に無い --container-* があります: ${extra.join(', ')}(Tailwind のデフォルト 3xs/2xs は「定義しない」ことで既定値のまま残す設計です)`
411
+ );
412
+ }
413
+ // 同名を 2 回書いても `missing` にも `extra` にも出ない(集合で比較しているため)。
414
+ // CSS は後勝ちなので、2 つ目が最初の値をサイレントに上書きする。
415
+ // en: A repeated key shows up in neither set-difference, yet CSS last-wins makes
416
+ // the second declaration silently override the first.
417
+ const duplicated = [...new Set(declared.filter((k, i) => declared.indexOf(k) !== i))];
418
+ if (duplicated.length) {
419
+ problems.push(
420
+ `同じ --container-* が複数回宣言されています: ${duplicated.join(', ')}(後に書いたほうが勝つのでサイレントに上書きされます)`
421
+ );
422
+ }
423
+
424
+ // 値が昇順であること。character/4 と /5 の line-height が入れ替わっていた
425
+ // 事故と同じクラス(名前は正しいが対応する値だけが入れ替わる)を拾う。
426
+ // en: Catch swapped values — the same failure class as the line-height mix-up.
427
+ // 値は **リテラルの rem** でなければならない。`var(...)` を書くと、
428
+ // container query バリアント(`@xl:` 等)は条件に var() を置けないため
429
+ // **ユーティリティごと生成されなくなる**(ビルドは成功する)。
430
+ // en: Literal rem only. A `var()` here silently drops the `@xl:`-style
431
+ // container-query utilities, because `@container (width >= …)` can't use var().
432
+ const values = FIGMA_MAX_WIDTH_KEYS.map((key) => {
433
+ const m = block.match(
434
+ new RegExp(`^\\s*--container-${key}\\s*:\\s*(\\d+(?:\\.\\d+)?)rem\\s*;`, 'm')
435
+ );
436
+ return m ? Number(m[1]) : null;
437
+ });
438
+ const unparsed = FIGMA_MAX_WIDTH_KEYS.filter((_, i) => values[i] === null);
439
+ if (unparsed.length) {
440
+ problems.push(
441
+ `リテラルの rem 値として読めない --container-*: ${unparsed.join(', ')}(var() や calc() を書くと container query バリアントが生成されなくなります)`
442
+ );
443
+ } else {
444
+ const outOfOrder = [];
445
+ for (let i = 1; i < values.length; i += 1) {
446
+ if (values[i] <= values[i - 1]) {
447
+ outOfOrder.push(
448
+ `${FIGMA_MAX_WIDTH_KEYS[i - 1]}(${values[i - 1]}rem) → ${FIGMA_MAX_WIDTH_KEYS[i]}(${values[i]}rem)`
449
+ );
450
+ }
451
+ }
452
+ if (outOfOrder.length) {
453
+ problems.push(`値が昇順になっていません(値の取り違え): ${outOfOrder.join(', ')}`);
454
+ }
455
+ }
456
+
457
+ // 他のトークンに合わせて「プレーン :root + @theme inline の自己参照」へ
458
+ // 直したくなるが、それをやると container query バリアントが壊れる。
459
+ // 善意のリファクタで静かに壊れる箇所なので、機械で止める。
460
+ // en: Refactoring these into the `:root` + `@theme inline` shape used by every
461
+ // other token silently breaks the container-query variants — block it.
462
+ const misplaced = [];
463
+ for (const [label, marker] of [
464
+ ['プリミティブ :root', PRIMITIVE_MARKER],
465
+ ['セマンティック :root', SEMANTIC_MARKER],
466
+ ['@theme inline', THEME_INLINE_MARKER],
467
+ ]) {
468
+ const found = declaredNames(blockAfter(marker), 'container-[a-zA-Z0-9-]+');
469
+ if (found.length) misplaced.push(`${label}: ${summarize(found)}`);
470
+ }
471
+ if (misplaced.length) {
472
+ problems.push(
473
+ `--container-* は @theme static にだけ書いてください(${misplaced.join(' / ')})。プレーン :root だと \`@sm:\` などの container query バリアントが Tailwind のデフォルト値のまま据え置かれ、@theme inline の自己参照だとそのバリアント自体が生成されなくなります`
474
+ );
475
+ }
476
+
477
+ // 空振り検出は最後に置く。先頭で expect() すると、置き場所を移した場合に
478
+ // 「10 件しか検査できませんでした」という**原因を取り違えたメッセージ**だけが
479
+ // 出て、上で組み立てた「@theme static に書いてください」が表に出ない。
480
+ // en: Run the vacuity guard last — asserting up front would replace the real
481
+ // diagnosis ("wrong block") with a misleading "the check found nothing" error.
482
+ if (problems.length === 0) {
483
+ expect(declared.length, FIGMA_MAX_WIDTH_KEYS.length, '--container-* の宣言');
484
+ }
485
+
486
+ return problems.length ? problems.join(' / ') : null;
487
+ });
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // 6. テンプレートに未定義変数への参照が無いこと
491
+ // ---------------------------------------------------------------------------
492
+ // primary をどれに切り替えても解決できることを、全 primary 候補で確認する。
493
+ check('全 primary 候補で dangling な var(--*) 参照が無いこと', () => {
494
+ const primaries = Object.keys(gray).filter((h) => h in colors);
495
+ expect(primaries.length, 5, 'primary 候補');
496
+
497
+ const problems = [];
498
+ for (const primary of primaries) {
499
+ const declared = new Set();
500
+ for (const [name, value] of Object.entries(colors)) {
501
+ if (value && typeof value === 'object') {
502
+ for (const level of Object.keys(value)) declared.add(`--color-${name}-${level}`);
503
+ } else {
504
+ declared.add(`--color-${name}`);
505
+ }
506
+ }
507
+ for (const level of Object.keys(gray[primary])) {
508
+ declared.add(`--color-gray-${GRAY_LEVEL_MAP[level]}`);
509
+ }
510
+
511
+ const resolved = templateNoComments
512
+ .replace(/\{\{PRIMARY\}\}/g, primary)
513
+ .replace(/\{\{RADIUS\}\}/g, RADIUS_ROWS[0][0])
514
+ .replace(/\{\{FONT_FAMILY_PRO\}\}/g, 'Inter')
515
+ .replace(/\{\{FONT_FAMILY_MONO\}\}/g, 'monospace');
516
+
517
+ for (const m of resolved.matchAll(/^\s*(--[a-zA-Z0-9_-]+)\s*:/gm)) declared.add(m[1]);
518
+
519
+ const references = [...new Set([...resolved.matchAll(varRefPattern())].map((m) => m[1]))];
520
+ expect(references.length, 100, `primary=${primary} の var() 参照`);
521
+
522
+ const dangling = references.filter((ref) => !declared.has(ref));
523
+ if (dangling.length) problems.push(`primary=${primary}: ${summarize(dangling)}`);
524
+ }
525
+ return problems.length ? problems.join(' / ') : null;
526
+ });
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // 7. セマンティック層が 2 ブロックで揃っていること
530
+ // ---------------------------------------------------------------------------
531
+ // テンプレートは同じセマンティック変数を「プレーンな :root」と「@theme inline」
532
+ // の 2 箇所に持つ(issue #229 の設計)。片方にだけ追加すると、ランタイム上書きが
533
+ // 効かない/utility が生成されない、という気付きにくい欠落になる。
534
+ // 対象は color / radius / shadow の全系統。接頭辞を絞ると、その接頭辞を
535
+ // リネームしただけで検査が丸ごと無効化される。
536
+ check('セマンティック層が :root と @theme inline で揃っていること', () => {
537
+ const themeIndex = template.indexOf(THEME_INLINE_MARKER);
538
+ if (themeIndex < 0) return `テンプレートに ${THEME_INLINE_MARKER} が見つかりません`;
539
+
540
+ const semanticStart = template.indexOf(SEMANTIC_MARKER);
541
+ if (semanticStart < 0) return `テンプレートに ${SEMANTIC_MARKER} が見つかりません`;
542
+
543
+ const pattern = '(?:color|radius|shadow)-[a-zA-Z0-9-]+';
544
+ const plain = new Set(
545
+ declaredNames(stripComments(template.slice(semanticStart, themeIndex)), pattern)
546
+ );
547
+ const themed = new Set(
548
+ declaredNames(stripComments(template.slice(themeIndex)), pattern)
549
+ );
550
+
551
+ expect(plain.size, 100, 'プレーン :root のセマンティック宣言');
552
+ expect(themed.size, 100, '@theme inline のセマンティック宣言');
553
+
554
+ const onlyPlain = [...plain].filter((n) => !themed.has(n));
555
+ const onlyThemed = [...themed].filter((n) => !plain.has(n));
556
+ if (onlyPlain.length || onlyThemed.length) {
557
+ return `:root のみ: ${summarize(onlyPlain) || 'なし'} / @theme inline のみ: ${summarize(onlyThemed) || 'なし'}`;
558
+ }
559
+ return null;
560
+ });
561
+
562
+ // 同じブロック内に同名変数が 2 回あると後勝ちでサイレント上書きされる。
563
+ // colors.json と gray.json の gray 二重定義で実際に踏んだのと同じ事故クラス。
564
+ check('同一ブロック内に重複した変数宣言が無いこと', () => {
565
+ const blocks = {
566
+ 'プリミティブ :root': blockAfter(PRIMITIVE_MARKER),
567
+ 'セマンティック :root': blockAfter(SEMANTIC_MARKER),
568
+ '@theme static': blockAfter(THEME_MARKER),
569
+ '@theme inline': blockAfter(THEME_INLINE_MARKER),
570
+ };
571
+ const problems = [];
572
+ let inspected = 0;
573
+ for (const [label, block] of Object.entries(blocks)) {
574
+ const counts = new Map();
575
+ for (const name of declaredNames(block)) {
576
+ counts.set(name, (counts.get(name) ?? 0) + 1);
577
+ inspected += 1;
578
+ }
579
+ const dupes = [...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name);
580
+ if (dupes.length) problems.push(`${label}: ${summarize(dupes)}`);
581
+ }
582
+ expect(inspected, 300, '変数宣言');
583
+ return problems.length ? `重複宣言(後勝ちでサイレント上書きされます)— ${problems.join(' / ')}` : null;
584
+ });
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // 8. タイポグラフィのクラスが自分の変数だけを参照していること
588
+ // ---------------------------------------------------------------------------
589
+ // 72 クラス × 5 プロパティを手作業で変数参照に貼り替えた変更なので、
590
+ // 「別クラスの変数を参照してしまう」取り違えが最も起きやすい。宣言名と
591
+ // 参照名の集合一致だけでは、2 クラスが互いの変数を参照し合っていても通る。
592
+ check('character-* / icon-* クラスが自分の変数だけを参照していること', () => {
593
+ const problems = [];
594
+ let inspectedClasses = 0;
595
+ let inspectedRefs = 0;
596
+
597
+ for (const m of templateNoComments.matchAll(/\.((?:character|icon)-[a-z0-9-]+)\s*\{([^}]*)\}/g)) {
598
+ const className = m[1];
599
+ inspectedClasses += 1;
600
+ for (const ref of m[2].matchAll(/var\(\s*(--(?:character|icon)-[a-z0-9-]+)\s*[,)]/g)) {
601
+ inspectedRefs += 1;
602
+ if (!ref[1].startsWith(`--${className}-`)) {
603
+ problems.push(`.${className} が ${ref[1]} を参照`);
604
+ }
605
+ }
606
+ }
607
+ expect(inspectedClasses, 60, 'タイポグラフィのクラス');
608
+ expect(inspectedRefs, 200, 'タイポグラフィ変数の参照');
609
+
610
+ // 宣言したのに誰も参照しない変数(=貼り替え漏れ)も拾う
611
+ const declaredVars = new Set(declaredNames(templateNoComments, '(?:character|icon)-[a-z0-9-]+'));
612
+ const referenced = new Set(
613
+ [...templateNoComments.matchAll(/var\(\s*(--(?:character|icon)-[a-z0-9-]+)\s*[,)]/g)].map(
614
+ (r) => r[1]
615
+ )
616
+ );
617
+ const unused = [...declaredVars].filter((v) => !referenced.has(v));
618
+ const undeclared = [...referenced].filter((v) => !declaredVars.has(v));
619
+ if (unused.length) problems.push(`参照されていない変数: ${summarize(unused)}`);
620
+ if (undeclared.length) problems.push(`未定義の参照: ${summarize(undeclared)}`);
621
+
622
+ return problems.length ? problems.join(' / ') : null;
623
+ });
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // 9. テンプレートの構造が壊れていないこと
627
+ // ---------------------------------------------------------------------------
628
+ // `--font-size-16: 1rem; /* 16px */` のように、同じ値を rem とコメントの px で
629
+ // 2 度書いている行がテンプレートに 42 行ある。Figma の値は px で来るので、
630
+ // 実務では「コメントの px を先に直して rem を直し忘れる」が普通に起きる。
631
+ // 変数名(--font-size-16 等)も px を名乗っているものが多く、目視だと合って
632
+ // いるように見えてしまう。
633
+ // en: The template states each value twice — as rem and as a `/* Npx */`
634
+ // comment. Figma hands values over in px, so updating one and not the other is
635
+ // a routine mistake that reads as correct.
636
+ // 数値は `[\d.]+` ではなく `\d+(?:\.\d+)?` で厳密に取る。前者だと `1.2.3rem` に
637
+ // マッチしてしまい、`Number.parseFloat` が黙って `1.2` に切り詰めるため、
638
+ // **無効な CSS が検査を通過する**。変換も parseFloat ではなく Number を使い、
639
+ // 末尾のゴミを許さない。
640
+ // en: Strict numeric form — `[\d.]+` would accept `1.2.3rem`, which parseFloat
641
+ // silently truncates, letting invalid CSS pass.
642
+ check('rem の値と行末コメントの px が一致していること', () => {
643
+ const rows = [
644
+ ...template.matchAll(
645
+ /^[ \t]*(--[\w-]+)\s*:\s*(0|\d+(?:\.\d+)?rem)\s*;[ \t]*\/\*\s*(\d+(?:\.\d+)?)px\s*\*\//gm
646
+ ),
647
+ ];
648
+ // 現在 42 行。下限を実数に張り付けず 30 にしているのは、この検査が守りたいのが
649
+ // 「1 行減った」ではなく「検査が丸ごと空振りしている」ことだから。行末コメントの
650
+ // 書式を変えたり接頭辞を一括リネームすれば 0 件に落ちるので、そこで止まる。
651
+ // en: The floor guards against the pattern silently matching nothing, not
652
+ // against losing a single line — hence a floor well below the current 42.
653
+ expect(rows.length, 30, 'px コメント付きの rem 宣言');
654
+
655
+ const mismatched = rows
656
+ .filter(([, , rem, px]) => {
657
+ const asRem = rem === '0' ? 0 : Number(rem.slice(0, -'rem'.length));
658
+ return Math.abs(asRem * 16 - Number(px)) > 1e-9;
659
+ })
660
+ .map(([, name, rem, px]) => `${name}: ${rem} なのにコメントは ${px}px`);
661
+
662
+ return mismatched.length
663
+ ? `値とコメントが食い違っています(16px = 1rem 換算): ${summarize(mismatched)}`
664
+ : null;
665
+ });
666
+
667
+ check('テンプレートの波括弧が釣り合っていること', () => {
668
+ const stripped = template.replace(/\{\{[A-Za-z_]+\}\}/g, '');
669
+ const open = (stripped.match(/\{/g) || []).length;
670
+ const close = (stripped.match(/\}/g) || []).length;
671
+ return open === close ? null : `開き ${open} / 閉じ ${close}`;
672
+ });
673
+
674
+ // CLI はプレースホルダーを **完全一致の正規表現**で置換する。大文字小文字の
675
+ // タイポや、コメントの体裁を整えただけでも置換が no-op になり、
676
+ // 「CLI は成功終了するのにプリミティブカラーが 1 つも出ない」状態になる。
677
+ check('プレースホルダーが CLI の期待どおりの字面であること', () => {
678
+ const KNOWN = new Set([
679
+ '{{FONT_IMPORTS}}',
680
+ '{{COLOR_TOKENS}}',
681
+ '{{FONT_FAMILY_PRO}}',
682
+ '{{FONT_FAMILY_MONO}}',
683
+ '{{FONT_PRO}}',
684
+ '{{FONT_MONO}}',
685
+ '{{FONT_PRO_URL}}',
686
+ '{{FONT_MONO_URL}}',
687
+ '{{PRIMARY}}',
688
+ '{{RADIUS}}',
689
+ ]);
690
+
691
+ const problems = [];
692
+
693
+ // 大文字小文字を問わず `{{...}}` を拾う。`[A-Z_]+` 限定だと `{{primary}}` の
694
+ // ようなタイポが「そもそも検出されない」=合格になってしまう。
695
+ const found = [...new Set([...template.matchAll(/\{\{[A-Za-z0-9_]+\}\}/g)].map((m) => m[0]))];
696
+ expect(found.length, 4, 'プレースホルダー');
697
+ const unknown = found.filter((p) => !KNOWN.has(p));
698
+ if (unknown.length) {
699
+ problems.push(`CLI が知らないプレースホルダー: ${unknown.join(', ')}(大文字小文字も一致させてください)`);
700
+ }
701
+
702
+ // CLI 側の正規表現(lib/constants.js)はコメント内側のスペースを 1 個ずつ
703
+ // 要求する。`/*{{COLOR_TOKENS}}*/` のように整形すると静かに置換されなくなる。
704
+ const CLI_BLOCK_PLACEHOLDERS = [
705
+ { name: '{{COLOR_TOKENS}}', pattern: /[ \t]*\/\* \{\{COLOR_TOKENS\}\} \*\/[ \t]*\n?/ },
706
+ { name: '{{FONT_IMPORTS}}', pattern: /[ \t]*\/\* \{\{FONT_IMPORTS\}\} \*\/[ \t]*\n?/ },
707
+ ];
708
+ for (const { name, pattern } of CLI_BLOCK_PLACEHOLDERS) {
709
+ if (!pattern.test(template)) {
710
+ problems.push(
711
+ `${name} が CLI の置換正規表現にマッチしません(\`/* ${name} */\` の形で、コメント記号の内側にスペースを 1 個ずつ入れてください)`
712
+ );
713
+ }
714
+ }
715
+
716
+ return problems.length ? problems.join(' / ') : null;
717
+ });
718
+
719
+ // ---------------------------------------------------------------------------
720
+ // 結果
721
+ // ---------------------------------------------------------------------------
722
+ for (const { name, ok } of checks) {
723
+ console.log(`${ok ? '✅' : '❌'} ${name}`);
724
+ }
725
+
726
+ if (failures.length) {
727
+ console.error(`\n${failures.length} 件の不整合が見つかりました:\n`);
728
+ for (const { name, detail } of failures) {
729
+ console.error(`❌ ${name}\n ${detail}\n`);
730
+ }
731
+ process.exit(1);
732
+ }
733
+
734
+ console.log(`\n${checks.length} 件すべて OK`);