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 +54 -1
- package/bin/sparkle-design.js +78 -2
- package/lib/anti-pattern-rules.js +217 -14
- package/lib/check.js +86 -16
- package/lib/setup.js +260 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sparkle Design CLI
|
|
2
2
|
|
|
3
|
-
|
|
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
|
### 設定ファイルの作成
|
package/bin/sparkle-design.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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
|
|
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 -
|
|
265
|
-
'<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 -
|
|
295
|
-
'<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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
+
function printTextReport(report, options = {}) {
|
|
116
|
+
if (report.findings.length === 0) {
|
|
83
117
|
console.log('sparkle-design-cli check: no findings');
|
|
84
|
-
|
|
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(
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
+
}
|