sparkle-design-cli 1.3.9 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sparkle Design CLI
2
2
 
3
- デザインシステム CSS の生成と、Sparkle Design のアンチパターン検査を行う CLI ツールです。
3
+ Sparkle Design の CSS 生成、導入先プロジェクト向けのアンチパターン検査、AI エージェント向け guard 設定を行う CLI ツールです。
4
4
 
5
5
  ## インストール
6
6
 
@@ -18,6 +18,12 @@ npx sparkle-design-cli generate
18
18
 
19
19
  # アンチパターンを検査
20
20
  npx sparkle-design-cli check src --strict
21
+
22
+ # AI 向けに JSON で検査結果と手動確認項目を取得
23
+ npx sparkle-design-cli check src --format json
24
+
25
+ # Claude / Codex / Cursor 向けの guard 設定を差し込む
26
+ npx sparkle-design-cli setup --assistant claude
21
27
  ```
22
28
 
23
29
  ### 後方互換モード
@@ -85,6 +91,9 @@ sparkle-design-cli check
85
91
  # 特定ディレクトリを strict モードで検査
86
92
  sparkle-design-cli check src/components src/features --strict
87
93
 
94
+ # AI 向けに JSON 出力
95
+ sparkle-design-cli check src --format json
96
+
88
97
  # check のヘルプを表示
89
98
  sparkle-design-cli check --help
90
99
  ```
@@ -93,13 +102,24 @@ sparkle-design-cli check --help
93
102
 
94
103
  - `-h, --help`: ヘルプメッセージを表示
95
104
  - `--strict`: 違反が見つかった場合に exit code 1 で終了
105
+ - `--format <text|json>`: 出力形式。AI 連携では `json` を推奨
96
106
 
97
107
  #### 現在検出するルール
98
108
 
99
109
  - フォーム入力に Dialog を使わない
100
110
  - DialogCancel/DialogAction を Button で二重ラップしない
111
+ - children なしの Button に prefixIcon / suffixIcon を使わない
112
+ - Material Symbols を className 直書きで使わない
101
113
  - shadcn/ui 既定 token を Sparkle Design 内へ持ち込まない
102
114
 
115
+ #### Manual Review Reminders
116
+
117
+ `--format json` では、機械検出できない観点も `manualReviewReminders` として返します。現在は次を含みます。
118
+
119
+ - Badge と Tag の意味的な使い分け
120
+ - CardDescription の typography / color token 明示
121
+ - Dialog と Modal の UX 上の使い分け
122
+
103
123
  #### 導入先での推奨設定
104
124
 
105
125
  ```json
@@ -112,6 +132,39 @@ sparkle-design-cli check --help
112
132
 
113
133
  AI エージェントや CI からこの `lint:sparkle` を呼ぶ運用にすると、ガイドラインの注意書きだけに頼らず機械的に検査できます。
114
134
 
135
+ ### setup: AI ガード設定の差し込み
136
+
137
+ `sparkle-design-cli setup` は、導入先プロジェクトの `package.json` と AI 向け指示ファイルに Sparkle Design guard を追加します。
138
+
139
+ ```bash
140
+ # Claude Code 向け
141
+ sparkle-design-cli setup --assistant claude
142
+
143
+ # Codex / AGENTS.md 向け
144
+ sparkle-design-cli setup --assistant codex
145
+
146
+ # Cursor rules 向け
147
+ sparkle-design-cli setup --assistant cursor
148
+
149
+ # generic な Markdown を生成
150
+ sparkle-design-cli setup --assistant generic
151
+ ```
152
+
153
+ 追加されるもの:
154
+ - `package.json` の `lint:sparkle`
155
+ - `package.json` の `lint:sparkle:json`
156
+ - assistant ごとの指示ファイルへの managed block
157
+
158
+ `--target` を省略した場合は `src`, `src/components`, `src/features`, `app`, `components` の順で自動検出します。既存の `lint:sparkle*` がすでに Sparkle Design 向け script なら再実行時に更新されます。独自 script は保持され、上書きしたい場合だけ `--force-script-update` を付けます。
159
+
160
+ 利用できる assistant:
161
+ - `claude`: `CLAUDE.md`
162
+ - `codex`: `AGENTS.md`
163
+ - `cursor`: `.cursor/rules/sparkle-design-guard.mdc`
164
+ - `generic`: `SPARKLE-DESIGN-AI.md`
165
+
166
+ `setup` は通常実行時も JSON サマリーを stdout に表示します。`--dry-run` を付けると、その JSON を表示したままファイル変更だけを抑止します。
167
+
115
168
  ## 設定ファイル (sparkle.config.json)
116
169
 
117
170
  ### 設定ファイルの作成
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { generateCSS } from '../lib/generate-css.js';
4
4
  import { checkProject } from '../lib/check.js';
5
+ import { setupAssistant } from '../lib/setup.js';
5
6
 
6
- const SUBCOMMANDS = new Set(['generate', 'check']);
7
+ const SUBCOMMANDS = new Set(['generate', 'check', 'setup']);
7
8
 
8
9
  function requireOptionValue(args, index, flags) {
9
10
  const value = args[index + 1];
@@ -43,6 +44,7 @@ function parseCheckOptions(args) {
43
44
  const options = {
44
45
  targets: [],
45
46
  strict: false,
47
+ format: 'text',
46
48
  help: false,
47
49
  };
48
50
 
@@ -53,6 +55,13 @@ function parseCheckOptions(args) {
53
55
  options.help = true;
54
56
  } else if (arg === '--strict') {
55
57
  options.strict = true;
58
+ } else if (arg === '--format') {
59
+ const value = requireOptionValue(args, i, '--format');
60
+ if (value !== 'text' && value !== 'json') {
61
+ throw new Error(`Unsupported format for check: ${value}`);
62
+ }
63
+ options.format = value;
64
+ i += 1;
56
65
  } else if (arg.startsWith('-')) {
57
66
  throw new Error(`Unknown option for check: ${arg}`);
58
67
  } else {
@@ -63,6 +72,38 @@ function parseCheckOptions(args) {
63
72
  return options;
64
73
  }
65
74
 
75
+ function parseSetupOptions(args) {
76
+ const options = {
77
+ assistant: 'generic',
78
+ target: null,
79
+ dryRun: false,
80
+ forceScriptUpdate: false,
81
+ help: false,
82
+ };
83
+
84
+ for (let i = 0; i < args.length; i += 1) {
85
+ const arg = args[i];
86
+
87
+ if (arg === '-h' || arg === '--help') {
88
+ options.help = true;
89
+ } else if (arg === '--assistant') {
90
+ options.assistant = requireOptionValue(args, i, '--assistant');
91
+ i += 1;
92
+ } else if (arg === '--target') {
93
+ options.target = requireOptionValue(args, i, '--target');
94
+ i += 1;
95
+ } else if (arg === '--dry-run') {
96
+ options.dryRun = true;
97
+ } else if (arg === '--force-script-update') {
98
+ options.forceScriptUpdate = true;
99
+ } else {
100
+ throw new Error(`Unknown option for setup: ${arg}`);
101
+ }
102
+ }
103
+
104
+ return options;
105
+ }
106
+
66
107
  function showHelp() {
67
108
  console.log(`
68
109
  Sparkle Design CLI
@@ -73,6 +114,7 @@ Sparkle Design CLI
73
114
  Commands:
74
115
  generate sparkle.config.json から CSS を生成
75
116
  check Sparkle Design のアンチパターンを検査
117
+ setup AI エージェント向けの guard 設定を導入
76
118
 
77
119
  後方互換:
78
120
  sparkle-design-cli [generate options]
@@ -88,8 +130,15 @@ Generate:
88
130
  Check:
89
131
  sparkle-design-cli check
90
132
  sparkle-design-cli check src --strict
133
+ sparkle-design-cli check src --format json
91
134
  sparkle-design-cli check src/components src/features
92
135
 
136
+ Setup:
137
+ sparkle-design-cli setup --assistant claude
138
+ sparkle-design-cli setup --assistant codex --target src
139
+ sparkle-design-cli setup --assistant cursor --dry-run
140
+ sparkle-design-cli setup --assistant claude --force-script-update
141
+
93
142
  Generate options:
94
143
  -h, --help このヘルプメッセージを表示
95
144
  -c, --config <path> 設定ファイルのパス (default: ./sparkle.config.json)
@@ -98,11 +147,23 @@ Generate options:
98
147
  Check options:
99
148
  -h, --help このヘルプメッセージを表示
100
149
  --strict 違反があれば exit code 1 で終了
150
+ --format <text|json> 出力形式 (default: text)
151
+
152
+ Setup options:
153
+ -h, --help このヘルプメッセージを表示
154
+ --assistant <name> claude / codex / cursor / generic (default: generic)
155
+ --target <path> lint 対象パス (default: auto-detect)
156
+ --dry-run ファイルを変更せず結果だけ表示
157
+ --force-script-update 既存の lint:sparkle 系 script も上書きする
101
158
 
102
159
  Check で検出する主なパターン:
103
160
  - フォーム入力に Dialog を使っている
104
161
  - DialogCancel / DialogAction を <Button> で二重ラップしている
162
+ - children なしの Button に prefixIcon / suffixIcon を使っている
163
+ - Material Symbols を className 直書きしている
105
164
  - text-muted-foreground / bg-background / border-border を持ち込んでいる
165
+
166
+ JSON 出力では manualReviewReminders も返すため、AI から実行する場合は --format json を推奨
106
167
  `);
107
168
  }
108
169
 
@@ -148,13 +209,28 @@ function main() {
148
209
  process.exit(0);
149
210
  }
150
211
 
151
- const hasFindings = checkProject(options.targets, { strict: options.strict });
212
+ const hasFindings = checkProject(options.targets, {
213
+ strict: options.strict,
214
+ format: options.format,
215
+ });
152
216
  if (hasFindings && options.strict) {
153
217
  process.exit(1);
154
218
  }
155
219
  return;
156
220
  }
157
221
 
222
+ if (command === 'setup') {
223
+ const options = parseSetupOptions(args.slice(1));
224
+
225
+ if (options.help) {
226
+ showHelp();
227
+ process.exit(0);
228
+ }
229
+
230
+ setupAssistant(options);
231
+ return;
232
+ }
233
+
158
234
  if (!SUBCOMMANDS.has(command) && command.startsWith('-')) {
159
235
  showGenerateDeprecationNotice();
160
236
  const options = parseGenerateOptions(args);
@@ -23,6 +23,25 @@ function renderJSDocSection(section) {
23
23
  return output.map((line) => (line ? ` * ${line}` : ' *')).join('\n');
24
24
  }
25
25
 
26
+
27
+ const MANUAL_REVIEW_REMINDERS = [
28
+ {
29
+ id: 'badge-tag-semantics',
30
+ message:
31
+ 'Badge と Tag の使い分けが意味的に正しいか確認してください。数値情報は Badge、ラベリングやステータス表示は Tag を使います。',
32
+ },
33
+ {
34
+ id: 'card-description-typography',
35
+ message:
36
+ 'CardDescription に typography / color token が明示されているか確認してください。補足テキストには className で token を指定します。',
37
+ },
38
+ {
39
+ id: 'dialog-modal-ux',
40
+ message:
41
+ 'Dialog が確認用途、Modal が入力・詳細表示用途になっているか確認してください。文脈判断が必要なケースは機械検出されません。',
42
+ },
43
+ ];
44
+
26
45
  const ANTI_PATTERN_GROUPS = [
27
46
  {
28
47
  id: 'card-description',
@@ -49,9 +68,43 @@ const ANTI_PATTERN_GROUPS = [
49
68
  '</CardHeader>',
50
69
  '```',
51
70
  '',
52
- 'CardTitle 内の補足情報や件数は CardDescription を使い、必要な typography / color token は className で明示する。',
71
+ 'CardTitle 内の補足情報や件数は CardDescription を使い、必要な typography / color token は className で明示する。長い説明文は CardContent に配置する。',
53
72
  ]),
54
- jsdocTargets: [],
73
+ jsdocTargets: [
74
+ {
75
+ file: 'src/components/ui/card/index.tsx',
76
+ targetName: 'CardDescription',
77
+ section: {
78
+ bullets: [
79
+ {
80
+ ja: '`CardDescription` は CardTitle 内の短い補足テキスト用です。長い説明文は `CardContent` に配置してください。',
81
+ en: '`CardDescription` is for short supporting text inside CardTitle. Put long descriptive copy inside `CardContent`.',
82
+ },
83
+ {
84
+ ja: 'Typography や text color は用途に応じて className で明示してください。',
85
+ en: 'Specify typography and text color explicitly with className based on the use case.',
86
+ },
87
+ ],
88
+ example: lines([
89
+ '// ✅ Correct',
90
+ '<CardTitle>',
91
+ ' プロジェクト一覧',
92
+ ' <CardDescription className="character-3-regular-pro text-text-low">',
93
+ ' 全 12 件',
94
+ ' </CardDescription>',
95
+ '</CardTitle>',
96
+ '',
97
+ '// ❌ Wrong - 長い説明文を CardTitle 内に入れない',
98
+ '<CardTitle>',
99
+ ' プロジェクト一覧',
100
+ ' <CardDescription>',
101
+ ' このカードはダッシュボードで重要な進捗と担当者の状態を表示します。',
102
+ ' </CardDescription>',
103
+ '</CardTitle>',
104
+ ]),
105
+ },
106
+ },
107
+ ],
55
108
  },
56
109
  {
57
110
  id: 'size-alignment',
@@ -72,7 +125,7 @@ const ANTI_PATTERN_GROUPS = [
72
125
  '</div>',
73
126
  '```',
74
127
  '',
75
- 'Input / Select / Textarea のデフォルトサイズは md。横並びの Button も原則 md に合わせる。テーブル内アクションなど独立した Button は sm でよい。',
128
+ 'Input / Select / Textarea のデフォルトサイズは md。横並びの Button も原則 md に合わせる。省スペース UI では Input と Button を揃えて sm にしてよい。テーブル内アクションなど独立した Button は sm でよい。',
76
129
  ]),
77
130
  jsdocTargets: [
78
131
  {
@@ -85,8 +138,12 @@ const ANTI_PATTERN_GROUPS = [
85
138
  en: 'Do not manually place an IconButton next to Input. Use `isTrigger` / `triggerIcon` props instead.',
86
139
  },
87
140
  {
88
- ja: 'Input と横並びの Button は原則同じサイズにしてください。デフォルトの Input に対しては `Button size="md"` を使ってください。',
89
- en: 'Keep Button size aligned when placing it next to Input. Use `Button size="md"` with the default Input size.',
141
+ ja: 'Input と横並びの Button は原則同じサイズにしてください。デフォルトの Input には `Button size="md"`、省スペース UI では `Input size="sm"` と `Button size="sm"` を使ってください。',
142
+ en: 'Keep Button size aligned when placing it next to Input. Use `Button size="md"` with the default Input, or pair `Input size="sm"` with `Button size="sm"` in compact UIs.',
143
+ },
144
+ {
145
+ ja: '無効状態は `isDisabled` を優先して使ってください。HTML 標準の `disabled` も互換のため受け付けますが、Sparkle Design のコードでは `isDisabled` に統一します。',
146
+ en: 'Prefer `isDisabled` for disabled state. The native `disabled` prop is still supported for compatibility, but Sparkle Design code should standardize on `isDisabled`.',
90
147
  },
91
148
  ],
92
149
  example: lines([
@@ -102,6 +159,11 @@ const ANTI_PATTERN_GROUPS = [
102
159
  ' <Input placeholder="検索..." />',
103
160
  ' <Button size="md">検索</Button>',
104
161
  ' </div>',
162
+ ' <div className="flex gap-2">',
163
+ ' <Input size="sm" placeholder="メッセージ" />',
164
+ ' <Button size="sm">送信</Button>',
165
+ ' </div>',
166
+ ' <Input isDisabled placeholder="無効状態" />',
105
167
  '</>',
106
168
  '',
107
169
  '// ❌ Wrong - 手動配置',
@@ -261,13 +323,22 @@ const ANTI_PATTERN_GROUPS = [
261
323
  '// ❌ Wrong - Icon を children に入れない(レイアウト崩れ・ローディング時に非表示にならない)',
262
324
  '<Button><Icon icon="check" /> 確定</Button>',
263
325
  '',
264
- '// ❌ Wrong - アイコンのみのボタンは IconButton を使う',
265
- '<Button><Icon icon="edit" /></Button>',
326
+ '// ❌ Wrong - children なしの Button + prefixIcon は IconButton に置き換える',
327
+ '<Button prefixIcon="close" aria-label="閉じる" />',
328
+ '',
329
+ '// ❌ Wrong - asChild では prefixIcon / suffixIcon / isLoading は反映されない',
330
+ '<Button asChild prefixIcon="add">',
331
+ ' <Link href="/items/new">新規作成</Link>',
332
+ '</Button>',
333
+ '',
266
334
  '// ✅ Correct',
267
335
  '<IconButton icon="edit" aria-label="編集" />',
336
+ '<Button asChild>',
337
+ ' <Link href="/items/new">新規作成</Link>',
338
+ '</Button>',
268
339
  '```',
269
340
  '',
270
- '> Button は size に応じてアイコンサイズを自動設定する(sm→4, md→5, lg→6)。isLoading 時にアイコンを自動で非表示にする。',
341
+ '> Button は size に応じてアイコンサイズを自動設定する(sm→4, md→5, lg→6)。isLoading 時にアイコンを自動で非表示にする。`asChild` 使用時はアイコンやローディング表現をスロット先で組み立てる。',
271
342
  ]),
272
343
  jsdocTargets: [
273
344
  {
@@ -283,6 +354,10 @@ const ANTI_PATTERN_GROUPS = [
283
354
  ja: 'アイコンのみのボタンには `IconButton` を使用してください。',
284
355
  en: 'Use `IconButton` for icon-only buttons.',
285
356
  },
357
+ {
358
+ ja: '`asChild` 使用時は `prefixIcon` / `suffixIcon` / `isLoading` が反映されません。必要ならスロット先でアイコンやローディング表現を構成してください。',
359
+ en: 'In `asChild` mode, `prefixIcon`, `suffixIcon`, and `isLoading` are ignored. Render icons and loading states inside the slotted child when needed.',
360
+ },
286
361
  ],
287
362
  example: lines([
288
363
  '// ✅ Correct',
@@ -291,10 +366,19 @@ const ANTI_PATTERN_GROUPS = [
291
366
  '// ❌ Wrong - Icon を children に入れない',
292
367
  '<Button><Icon icon="check" /> 確定</Button>',
293
368
  '',
294
- '// ❌ Wrong - アイコンのみは IconButton を使う',
295
- '<Button><Icon icon="edit" /></Button>',
369
+ '// ❌ Wrong - children なしの Button + prefixIcon は IconButton に置き換える',
370
+ '<Button prefixIcon="close" aria-label="閉じる" />',
371
+ '',
372
+ '// ❌ Wrong - asChild では prefixIcon は反映されない',
373
+ '<Button asChild prefixIcon="add">',
374
+ ' <Link href="/items/new">新規作成</Link>',
375
+ '</Button>',
376
+ '',
296
377
  '// ✅ Correct',
297
378
  '<IconButton icon="edit" aria-label="編集" />',
379
+ '<Button asChild>',
380
+ ' <Link href="/items/new">新規作成</Link>',
381
+ '</Button>',
298
382
  ]),
299
383
  },
300
384
  },
@@ -310,7 +394,8 @@ const ANTI_PATTERN_GROUPS = [
310
394
  '<CardHeader>',
311
395
  ' <CardTitle>タイトル</CardTitle>',
312
396
  ' <CardControl>',
313
- ' <Button>アクション</Button>',
397
+ ' <Button theme="neutral" variant="outline">キャンセル</Button>',
398
+ ' <Button>保存</Button>',
314
399
  ' </CardControl>',
315
400
  '</CardHeader>',
316
401
  '',
@@ -322,6 +407,8 @@ const ANTI_PATTERN_GROUPS = [
322
407
  ' </div>',
323
408
  '</CardHeader>',
324
409
  '```',
410
+ '',
411
+ '`CardControl` は既定で `flex items-center gap-2` を持つ。複数アクションでも追加の layout class は不要。',
325
412
  ]),
326
413
  jsdocTargets: [
327
414
  {
@@ -337,6 +424,10 @@ const ANTI_PATTERN_GROUPS = [
337
424
  ja: 'CardTitle の補足情報や件数は、`span` などを直書きせず `CardDescription` を使ってください。',
338
425
  en: 'For supporting text or counts inside CardTitle, do not inline a `span`; use `CardDescription`.',
339
426
  },
427
+ {
428
+ ja: '`CardDescription` はタイトルの補足テキスト用です。長い説明文は `CardContent` に配置してください。',
429
+ en: 'Use `CardDescription` for short supporting text in CardTitle. Put long descriptive copy inside `CardContent`.',
430
+ },
340
431
  ],
341
432
  example: lines([
342
433
  '// ✅ Correct',
@@ -348,7 +439,8 @@ const ANTI_PATTERN_GROUPS = [
348
439
  ' </CardDescription>',
349
440
  ' </CardTitle>',
350
441
  ' <CardControl>',
351
- ' <Button>アクション</Button>',
442
+ ' <Button theme="neutral" variant="outline">キャンセル</Button>',
443
+ ' <Button>保存</Button>',
352
444
  ' </CardControl>',
353
445
  '</CardHeader>',
354
446
  '',
@@ -356,7 +448,8 @@ const ANTI_PATTERN_GROUPS = [
356
448
  '<CardHeader>',
357
449
  ' <div className="flex justify-between">',
358
450
  ' <CardTitle>タイトル</CardTitle>',
359
- ' <Button>アクション</Button>',
451
+ ' <Button theme="neutral" variant="outline">キャンセル</Button>',
452
+ ' <Button>保存</Button>',
360
453
  ' </div>',
361
454
  '</CardHeader>',
362
455
  '',
@@ -370,6 +463,25 @@ const ANTI_PATTERN_GROUPS = [
370
463
  ]),
371
464
  },
372
465
  },
466
+ {
467
+ file: 'src/components/ui/card/index.tsx',
468
+ targetName: 'CardControl',
469
+ section: {
470
+ bullets: [
471
+ {
472
+ ja: 'CardHeader の右側アクションは `CardControl` に集約してください。複数ボタンでも追加の layout class は不要です。',
473
+ en: 'Place right-side actions in `CardControl`. No extra layout classes are needed even with multiple buttons.',
474
+ },
475
+ ],
476
+ example: lines([
477
+ '// ✅ Correct',
478
+ '<CardControl>',
479
+ ' <Button theme="neutral" variant="outline">キャンセル</Button>',
480
+ ' <Button>保存</Button>',
481
+ '</CardControl>',
482
+ ]),
483
+ },
484
+ },
373
485
  ],
374
486
  },
375
487
  {
@@ -428,6 +540,92 @@ const ANTI_PATTERN_GROUPS = [
428
540
  },
429
541
  ],
430
542
  },
543
+ {
544
+ id: 'button-icon-only',
545
+ check: {
546
+ description: 'children なしの Button に prefixIcon / suffixIcon を使わない',
547
+ recommendation:
548
+ 'アイコンのみのアクションは IconButton を使ってください。Button に prefixIcon / suffixIcon だけを渡さないでください。',
549
+ pattern:
550
+ /<Button\b(?=[^>]*\b(?:prefixIcon|suffixIcon)=)[^>]*\/\s*>|<Button\b(?=[^>]*\b(?:prefixIcon|suffixIcon)=)[^>]*>\s*<\/Button>/g,
551
+ },
552
+ featureSection: lines([
553
+ '### children なしの Button + prefixIcon / suffixIcon を使わない',
554
+ '',
555
+ '```tsx',
556
+ '// ✅ Correct',
557
+ '<IconButton icon="close" aria-label="閉じる" />',
558
+ '',
559
+ '// ❌ Wrong - children なしの Button + prefixIcon',
560
+ '<Button prefixIcon="close" aria-label="閉じる" />',
561
+ '```',
562
+ ]),
563
+ jsdocTargets: [],
564
+ },
565
+ {
566
+ id: 'material-symbols-direct',
567
+ check: {
568
+ description: 'Material Symbols を className 直書きで使わない',
569
+ recommendation:
570
+ 'Material Symbols は Icon / IconButton コンポーネント経由で使ってください。`material-symbols-rounded` を直接書かないでください。',
571
+ pattern:
572
+ /<[^>]+\bclassName\s*=\s*(?:"[^"]*\bmaterial-symbols-rounded\b[^"]*"|'[^']*\bmaterial-symbols-rounded\b[^']*'|\{[^}]*\bmaterial-symbols-rounded\b[^}]*\})[^>]*>/g,
573
+ },
574
+ featureSection: lines([
575
+ '### Material Symbols を直書きしない',
576
+ '',
577
+ '```tsx',
578
+ '// ✅ Correct',
579
+ '<Icon icon="content_copy" size={4} />',
580
+ '<IconButton icon="content_copy" size="xs" aria-label="コピー" />',
581
+ '',
582
+ '// ❌ Wrong - material-symbols-rounded を直書きしない',
583
+ '<span className="material-symbols-rounded text-sm">content_copy</span>',
584
+ '```',
585
+ '',
586
+ 'Material Symbols は `Icon` / `IconButton` 経由で使用する。直書きするとフォント読み込みタイミングでアイコン名テキストが見えることがある。',
587
+ ]),
588
+ jsdocTargets: [
589
+ {
590
+ file: 'src/components/ui/icon/index.tsx',
591
+ targetName: 'Icon',
592
+ section: {
593
+ bullets: [
594
+ {
595
+ ja: '`material-symbols-rounded` を `span` に直書きせず、`Icon` を使ってください。',
596
+ en: 'Use `Icon` instead of writing `material-symbols-rounded` directly on a span.',
597
+ },
598
+ ],
599
+ example: lines([
600
+ '// ✅ Correct',
601
+ '<Icon icon="content_copy" size={4} />',
602
+ '',
603
+ '// ❌ Wrong - className を直書きしない',
604
+ '<span className="material-symbols-rounded text-sm">content_copy</span>',
605
+ ]),
606
+ },
607
+ },
608
+ {
609
+ file: 'src/components/ui/icon-button/index.tsx',
610
+ targetName: 'IconButton',
611
+ section: {
612
+ bullets: [
613
+ {
614
+ ja: 'アイコンだけのアクションでは `Button` + `prefixIcon` ではなく `IconButton` を使ってください。',
615
+ en: 'Use `IconButton` for icon-only actions instead of `Button` with `prefixIcon`.',
616
+ },
617
+ ],
618
+ example: lines([
619
+ '// ✅ Correct',
620
+ '<IconButton icon="content_copy" aria-label="コピー" />',
621
+ '',
622
+ '// ❌ Wrong - Icon-only action を Button で表現しない',
623
+ '<Button prefixIcon="content_copy" aria-label="コピー" />',
624
+ ]),
625
+ },
626
+ },
627
+ ],
628
+ },
431
629
  {
432
630
  id: 'dialog-button-wrap',
433
631
  check: {
@@ -664,7 +862,7 @@ const ANTI_PATTERN_GROUPS = [
664
862
  ];
665
863
 
666
864
  function getCheckRules() {
667
- const order = ['dialog-form', 'dialog-button-wrap', 'shadcn-token'];
865
+ const order = ['dialog-form', 'dialog-button-wrap', 'button-icon-only', 'material-symbols-direct', 'shadcn-token'];
668
866
 
669
867
  return ANTI_PATTERN_GROUPS.filter((group) => group.check)
670
868
  .map((group) => ({
@@ -688,11 +886,16 @@ function renderFeatureSections() {
688
886
  return ANTI_PATTERN_GROUPS.map((group) => group.featureSection).join('\n\n');
689
887
  }
690
888
 
889
+ function getManualReviewReminders() {
890
+ return MANUAL_REVIEW_REMINDERS;
891
+ }
892
+
691
893
  export {
692
894
  ANTI_PATTERN_GROUPS,
693
895
  COMMON_INTRO,
694
896
  getAllJSDocTargets,
695
897
  getCheckRules,
898
+ getManualReviewReminders,
696
899
  renderFeatureSections,
697
900
  renderJSDocSection,
698
901
  };
package/lib/check.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
- import { getCheckRules } from './anti-pattern-rules.js';
4
+ import { getCheckRules, getManualReviewReminders } from './anti-pattern-rules.js';
5
5
 
6
6
  const DEFAULT_TARGET = 'src';
7
7
  const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
8
8
  const RULES = getCheckRules();
9
+ const MANUAL_REVIEW_REMINDERS = getManualReviewReminders();
10
+
11
+ function toRelativeReportPath(filePath) {
12
+ return path.relative(process.cwd(), filePath).split(path.sep).join('/');
13
+ }
9
14
 
10
15
  function collectFiles(targetPath, files, visited) {
11
16
  if (!fs.existsSync(targetPath)) {
@@ -68,7 +73,7 @@ function collectFindings(filePath) {
68
73
  return findings;
69
74
  }
70
75
 
71
- export function checkProject(targets = [], options = {}) {
76
+ function createCheckReport(targets = []) {
72
77
  const resolvedTargets = targets.length > 0 ? targets : [DEFAULT_TARGET];
73
78
  const files = new Set();
74
79
  const visited = new Set();
@@ -77,28 +82,93 @@ export function checkProject(targets = [], options = {}) {
77
82
  collectFiles(path.resolve(process.cwd(), target), files, visited);
78
83
  }
79
84
 
80
- const findings = [...files].flatMap(collectFindings);
85
+ const checkedFiles = [...files]
86
+ .map((filePath) => toRelativeReportPath(filePath))
87
+ .sort((left, right) => left.localeCompare(right));
88
+ const findings = [...files]
89
+ .flatMap(collectFindings)
90
+ .map((finding) => ({
91
+ ...finding,
92
+ filePath: toRelativeReportPath(finding.filePath),
93
+ }))
94
+ .sort((left, right) => {
95
+ const fileComparison = left.filePath.localeCompare(right.filePath);
96
+ if (fileComparison !== 0) {
97
+ return fileComparison;
98
+ }
99
+
100
+ if (left.line !== right.line) {
101
+ return left.line - right.line;
102
+ }
103
+
104
+ return left.id.localeCompare(right.id);
105
+ });
106
+
107
+ return {
108
+ targets: resolvedTargets,
109
+ checkedFiles,
110
+ findings,
111
+ manualReviewReminders: MANUAL_REVIEW_REMINDERS,
112
+ };
113
+ }
81
114
 
82
- if (findings.length === 0) {
115
+ function printTextReport(report, options = {}) {
116
+ if (report.findings.length === 0) {
83
117
  console.log('sparkle-design-cli check: no findings');
84
- return false;
118
+ } else {
119
+ console.log(`sparkle-design-cli check: ${report.findings.length} finding(s)\n`);
120
+
121
+ for (const finding of report.findings) {
122
+ console.log(`${finding.filePath}:${finding.line} [${finding.id}] ${finding.description}`);
123
+ console.log(` Recommendation: ${finding.recommendation}`);
124
+ console.log(` Snippet: ${finding.snippet}`);
125
+ console.log('');
126
+ }
85
127
  }
86
128
 
87
- console.log(`sparkle-design-cli check: ${findings.length} finding(s)\n`);
88
-
89
- for (const finding of findings) {
90
- const relativePath = path.relative(process.cwd(), finding.filePath);
91
- console.log(`${relativePath}:${finding.line} [${finding.id}] ${finding.description}`);
92
- console.log(` Recommendation: ${finding.recommendation}`);
93
- console.log(` Snippet: ${finding.snippet}`);
94
- console.log('');
129
+ console.log('Manual review reminders:');
130
+ for (const reminder of report.manualReviewReminders) {
131
+ console.log(`- [${reminder.id}] ${reminder.message}`);
95
132
  }
96
133
 
97
- if (options.strict) {
134
+ if (options.strict && report.findings.length > 0) {
98
135
  console.error('sparkle-design-cli check: failed because --strict was specified');
99
136
  }
137
+ }
138
+
139
+ function printJsonReport(report, options = {}) {
140
+ const strictMode = Boolean(options.strict);
141
+ const passed = !(strictMode && report.findings.length > 0);
142
+
143
+ console.log(
144
+ JSON.stringify(
145
+ {
146
+ summary: {
147
+ targetCount: report.targets.length,
148
+ checkedFileCount: report.checkedFiles.length,
149
+ findingCount: report.findings.length,
150
+ strictMode,
151
+ passed,
152
+ exitCode: passed ? 0 : 1,
153
+ },
154
+ ...report,
155
+ },
156
+ null,
157
+ 2
158
+ )
159
+ );
160
+ }
161
+
162
+ export function checkProject(targets = [], options = {}) {
163
+ const report = createCheckReport(targets);
164
+
165
+ if (options.format === 'json') {
166
+ printJsonReport(report, options);
167
+ } else {
168
+ printTextReport(report, options);
169
+ }
100
170
 
101
- return true;
171
+ return report.findings.length > 0;
102
172
  }
103
173
 
104
- export { RULES, collectFindings };
174
+ export { RULES, collectFindings, createCheckReport, MANUAL_REVIEW_REMINDERS };
package/lib/setup.js ADDED
@@ -0,0 +1,260 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const ASSISTANT_CONFIG = {
5
+ claude: {
6
+ path: 'CLAUDE.md',
7
+ },
8
+ codex: {
9
+ path: 'AGENTS.md',
10
+ },
11
+ cursor: {
12
+ path: '.cursor/rules/sparkle-design-guard.mdc',
13
+ },
14
+ generic: {
15
+ path: 'SPARKLE-DESIGN-AI.md',
16
+ },
17
+ };
18
+
19
+ const BLOCK_START = '<!-- sparkle-design-cli:setup:start -->';
20
+ const BLOCK_END = '<!-- sparkle-design-cli:setup:end -->';
21
+ const TARGET_CANDIDATES = ['src', 'src/components', 'src/features', 'app', 'components'];
22
+ const UNSAFE_TARGET_PATTERN = /[\s"'`;$&|<>()[\]{}\\]/;
23
+
24
+
25
+ function escapeRegExp(value) {
26
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ }
28
+
29
+ function ensureDir(filePath) {
30
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
31
+ }
32
+
33
+ function readJson(filePath) {
34
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
35
+ }
36
+
37
+ function writeJson(filePath, value) {
38
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
39
+ }
40
+
41
+ function normalizeTarget(cwd, target, sourceLabel) {
42
+ const trimmed = target.trim();
43
+
44
+ if (!trimmed) {
45
+ throw new Error(`Target path is empty (${sourceLabel})`);
46
+ }
47
+
48
+ if (path.isAbsolute(trimmed)) {
49
+ throw new Error(`Target path must be relative to the project root: ${trimmed}`);
50
+ }
51
+
52
+ if (UNSAFE_TARGET_PATTERN.test(trimmed)) {
53
+ throw new Error(
54
+ `Target path contains unsupported characters: ${trimmed}. Use a simple relative path without spaces or shell metacharacters.`
55
+ );
56
+ }
57
+
58
+ const normalized = path.posix.normalize(trimmed.replace(/\\/g, '/'));
59
+
60
+ if (normalized === '.' || normalized === '') {
61
+ throw new Error(`Target path is empty (${sourceLabel})`);
62
+ }
63
+
64
+ if (normalized.startsWith('../')) {
65
+ throw new Error(`Target path must stay inside the project root: ${trimmed}`);
66
+ }
67
+
68
+ const absolutePath = path.resolve(cwd, normalized);
69
+ if (!fs.existsSync(absolutePath)) {
70
+ throw new Error(`Target path does not exist: ${normalized}`);
71
+ }
72
+
73
+ return normalized;
74
+ }
75
+
76
+ function detectTarget(cwd) {
77
+ for (const candidate of TARGET_CANDIDATES) {
78
+ const absolutePath = path.resolve(cwd, candidate);
79
+ if (fs.existsSync(absolutePath)) {
80
+ return candidate;
81
+ }
82
+ }
83
+
84
+ throw new Error(
85
+ 'Could not auto-detect a lint target. Pass --target with an existing relative path such as src or app.'
86
+ );
87
+ }
88
+
89
+ function isManagedSparkleScript(value, mode) {
90
+ if (!value || typeof value !== 'string') {
91
+ return false;
92
+ }
93
+
94
+ const tokens = value.trim().split(/\s+/);
95
+ if (tokens.length < 3) {
96
+ return false;
97
+ }
98
+
99
+ if (tokens[0] !== 'sparkle-design-cli' || tokens[1] !== 'check') {
100
+ return false;
101
+ }
102
+
103
+ const hasStrict = tokens.includes('--strict');
104
+ const formatIndex = tokens.indexOf('--format');
105
+ const formatValue = formatIndex >= 0 ? tokens[formatIndex + 1] : null;
106
+
107
+ if (mode === 'strict') {
108
+ return hasStrict && formatIndex === -1;
109
+ }
110
+
111
+ if (mode === 'json') {
112
+ return formatIndex >= 0 && formatValue === 'json' && !hasStrict;
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ function buildInstructionBlock(target, assistant) {
119
+ const heading = assistant === 'cursor' ? '# Sparkle Design Guard' : '## Sparkle Design Guard';
120
+
121
+ return [
122
+ BLOCK_START,
123
+ heading,
124
+ '',
125
+ '- Run `lint:sparkle` when Sparkle Design changes are involved.',
126
+ `- For AI review, prefer \`lint:sparkle:json\` or run \`npx --yes sparkle-design-cli check ${target} --format json\` directly.`,
127
+ '- Always inspect both `findings` and `manualReviewReminders` from the JSON output.',
128
+ '- If semantic guidance is still needed, consult Sparkle Design docs and JSDoc examples.',
129
+ BLOCK_END,
130
+ ].join('\n');
131
+ }
132
+
133
+ function updateInstructionFile(filePath, block) {
134
+ const exists = fs.existsSync(filePath);
135
+ const current = exists ? fs.readFileSync(filePath, 'utf8') : '';
136
+
137
+ if (current.includes(BLOCK_START) && current.includes(BLOCK_END)) {
138
+ const next = current.replace(new RegExp(`${BLOCK_START}[\\s\\S]*?${BLOCK_END}`), block);
139
+ return { changed: next !== current, content: next, existed: exists };
140
+ }
141
+
142
+ const separator = current.trim().length > 0 ? '\n\n' : '';
143
+ return {
144
+ changed: true,
145
+ content: `${current}${separator}${block}\n`,
146
+ existed: exists,
147
+ };
148
+ }
149
+
150
+ function resolveScriptUpdate(existingValue, nextValue, mode, force) {
151
+ if (!existingValue) {
152
+ return { value: nextValue, changed: true, conflict: false, reason: 'created' };
153
+ }
154
+
155
+ if (existingValue === nextValue) {
156
+ return { value: existingValue, changed: false, conflict: false, reason: 'unchanged' };
157
+ }
158
+
159
+ if (force || isManagedSparkleScript(existingValue, mode)) {
160
+ return { value: nextValue, changed: true, conflict: false, reason: force ? 'forced' : 'updated' };
161
+ }
162
+
163
+ return { value: existingValue, changed: false, conflict: true, reason: 'preserved-custom' };
164
+ }
165
+
166
+ function updatePackageJson(filePath, target, force) {
167
+ const pkg = readJson(filePath);
168
+ const scripts = { ...(pkg.scripts ?? {}) };
169
+
170
+ const strictResult = resolveScriptUpdate(
171
+ scripts['lint:sparkle'],
172
+ `sparkle-design-cli check ${target} --strict`,
173
+ 'strict',
174
+ force
175
+ );
176
+ const jsonResult = resolveScriptUpdate(
177
+ scripts['lint:sparkle:json'],
178
+ `sparkle-design-cli check ${target} --format json`,
179
+ 'json',
180
+ force
181
+ );
182
+
183
+ scripts['lint:sparkle'] = strictResult.value;
184
+ scripts['lint:sparkle:json'] = jsonResult.value;
185
+
186
+ return {
187
+ changed: strictResult.changed || jsonResult.changed || !pkg.scripts,
188
+ packageJson: {
189
+ ...pkg,
190
+ scripts,
191
+ },
192
+ scriptResults: {
193
+ 'lint:sparkle': strictResult,
194
+ 'lint:sparkle:json': jsonResult,
195
+ },
196
+ };
197
+ }
198
+
199
+ export function setupAssistant(options = {}) {
200
+ const assistant = options.assistant ?? 'generic';
201
+ const cwd = process.cwd();
202
+ const dryRun = Boolean(options.dryRun);
203
+ const force = Boolean(options.forceScriptUpdate);
204
+ const assistantConfig = ASSISTANT_CONFIG[assistant];
205
+
206
+ if (!assistantConfig) {
207
+ throw new Error(`Unsupported assistant: ${assistant}`);
208
+ }
209
+
210
+ const targetSource = options.target ? 'explicit' : 'auto';
211
+ const target = options.target
212
+ ? normalizeTarget(cwd, options.target, 'explicit')
213
+ : normalizeTarget(cwd, detectTarget(cwd), 'auto');
214
+
215
+ const packageJsonPath = path.resolve(cwd, 'package.json');
216
+ if (!fs.existsSync(packageJsonPath)) {
217
+ throw new Error(`package.json not found in ${cwd}`);
218
+ }
219
+
220
+ const instructionPath = path.resolve(cwd, assistantConfig.path);
221
+ const packageResult = updatePackageJson(packageJsonPath, target, force);
222
+ const instructionBlock = buildInstructionBlock(target, assistant);
223
+ const instructionResult = updateInstructionFile(instructionPath, instructionBlock);
224
+
225
+ if (!dryRun) {
226
+ if (packageResult.changed) {
227
+ writeJson(packageJsonPath, packageResult.packageJson);
228
+ }
229
+
230
+ if (instructionResult.changed) {
231
+ ensureDir(instructionPath);
232
+ fs.writeFileSync(instructionPath, instructionResult.content, 'utf8');
233
+ }
234
+ }
235
+
236
+ const summary = {
237
+ assistant,
238
+ target,
239
+ targetSource,
240
+ dryRun,
241
+ forceScriptUpdate: force,
242
+ packageJson: {
243
+ path: path.relative(cwd, packageJsonPath),
244
+ changed: packageResult.changed,
245
+ scripts: {
246
+ 'lint:sparkle': packageResult.packageJson.scripts?.['lint:sparkle'] ?? null,
247
+ 'lint:sparkle:json': packageResult.packageJson.scripts?.['lint:sparkle:json'] ?? null,
248
+ },
249
+ results: packageResult.scriptResults,
250
+ },
251
+ instructions: {
252
+ path: path.relative(cwd, instructionPath),
253
+ changed: instructionResult.changed,
254
+ existed: instructionResult.existed,
255
+ },
256
+ };
257
+
258
+ console.log(JSON.stringify(summary, null, 2));
259
+ return summary;
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "1.3.9",
3
+ "version": "1.3.10",
4
4
  "description": "Sparkle Design CSS Generator - デザインシステムCSSを設定ファイルから生成するツール",
5
5
  "main": "lib/generate-css.js",
6
6
  "type": "module",