sparkle-design-cli 2.0.7 → 2.1.0
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 +66 -0
- package/bin/sparkle-design.js +54 -6
- package/lib/anti-pattern-rules.js +39 -28
- package/lib/check.js +70 -32
- package/lib/load-plugins.js +136 -0
- package/lib/plugin-api.js +253 -0
- package/lib/setup.js +145 -49
- package/lib/stop-hook.js +60 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -167,6 +167,72 @@ sparkle-design-cli check --help
|
|
|
167
167
|
|
|
168
168
|
AI エージェントや CI からこの `lint:sparkle` を呼ぶ運用にすると、ガイドラインの注意書きだけに頼らず機械的に検査できます。
|
|
169
169
|
|
|
170
|
+
#### アンチパターン検知の拡張(プラグイン)
|
|
171
|
+
|
|
172
|
+
他のコンポーネントライブラリ(社内拡張など)に固有のアンチパターン検知を、`sparkle-design-cli` 本体に変更を加えず追加できる拡張機構があります。
|
|
173
|
+
|
|
174
|
+
##### 仕組み
|
|
175
|
+
|
|
176
|
+
プラグインを提供するパッケージは、自身の `package.json` に検知ルールのエントリを宣言します:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"name": "@your-org/your-design-extensions",
|
|
181
|
+
"sparkleCli": {
|
|
182
|
+
"antiPatterns": "./anti-patterns/index.js"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
エントリファイルは `defineAntiPatternPlugin` を使ってプラグインを default export します:
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
import { defineAntiPatternPlugin } from 'sparkle-design-cli/plugin';
|
|
191
|
+
|
|
192
|
+
export default defineAntiPatternPlugin({
|
|
193
|
+
groups: [
|
|
194
|
+
{
|
|
195
|
+
id: 'your-org-foocard-nesting',
|
|
196
|
+
check: {
|
|
197
|
+
description: 'FooCard を別の FooCard でラップしないでください。',
|
|
198
|
+
recommendation: '入れ子表示が必要なら FooStack を使ってください。',
|
|
199
|
+
pattern: /<FooCard[^>]*>[\s\S]*?<FooCard/g,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
manualReviewReminders: [
|
|
204
|
+
{
|
|
205
|
+
id: 'your-org-foocard-color',
|
|
206
|
+
message: 'FooCard の color token が brand に揃っているか確認してください。',
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
##### 自動 discovery
|
|
213
|
+
|
|
214
|
+
`sparkle-design-cli check` 実行時、CLI は consumer プロジェクトの `package.json` の `dependencies` / `devDependencies` / `peerDependencies` / `optionalDependencies` を走査し、`sparkleCli.antiPatterns` を宣言しているパッケージを発見次第ロードしてビルトインのルールにマージします。
|
|
215
|
+
|
|
216
|
+
- プラグインパッケージが install されていなければ何も起きません
|
|
217
|
+
- ロードや評価で失敗したプラグインは warn を出してスキップし、残りのルールで check は継続されます
|
|
218
|
+
- ルール ID はビルトイン・他プラグインと名前空間を共有するため、`your-org-` のようなプレフィックスを付けて衝突を避けてください
|
|
219
|
+
|
|
220
|
+
> ⚠️ **信頼境界**: プラグインのエントリファイルは `import()` で **任意の JavaScript を実行** します。これは Node.js の通常のパッケージ依存と同じ性質ですが、`sparkle-design-cli check` 実行時に走るコードが増えるという点で意識しておく必要があります。プラグインは信頼できる org / 著者のパッケージのみインストールしてください。
|
|
221
|
+
|
|
222
|
+
##### 契約仕様の確認
|
|
223
|
+
|
|
224
|
+
最新の契約仕様は CLI から直接出力できます:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
# 契約 shape のドキュメントを出力
|
|
228
|
+
npx --yes sparkle-design-cli plugin-spec
|
|
229
|
+
|
|
230
|
+
# 現在のプロジェクトで discover されたプラグインを一覧
|
|
231
|
+
npx --yes sparkle-design-cli plugin-spec --list
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
完全な型定義は `node_modules/sparkle-design-cli/lib/plugin-api.js` の JSDoc を参照してください。
|
|
235
|
+
|
|
170
236
|
### setup: プロジェクトのフルセットアップ
|
|
171
237
|
|
|
172
238
|
`sparkle-design-cli setup` は、Sparkle Design の導入に必要な作業をまとめて行います:
|
package/bin/sparkle-design.js
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
import { generateCSS } from '../lib/generate-css.js';
|
|
4
4
|
import { checkProject } from '../lib/check.js';
|
|
5
5
|
import { setupAssistant } from '../lib/setup.js';
|
|
6
|
+
import { runStopHook } from '../lib/stop-hook.js';
|
|
7
|
+
import { PLUGIN_SPEC_MARKDOWN } from '../lib/plugin-api.js';
|
|
8
|
+
import { loadAntiPatternPlugins } from '../lib/load-plugins.js';
|
|
6
9
|
|
|
7
|
-
const SUBCOMMANDS = new Set(['generate', 'check', 'setup']);
|
|
10
|
+
const SUBCOMMANDS = new Set(['generate', 'check', 'setup', 'stop-hook', 'plugin-spec']);
|
|
8
11
|
|
|
9
12
|
function requireOptionValue(args, index, flags) {
|
|
10
13
|
const value = args[index + 1];
|
|
@@ -135,9 +138,11 @@ Sparkle Design CLI
|
|
|
135
138
|
sparkle-design-cli <command> [options]
|
|
136
139
|
|
|
137
140
|
Commands:
|
|
138
|
-
generate
|
|
139
|
-
check
|
|
140
|
-
setup
|
|
141
|
+
generate sparkle.config.json から CSS を生成
|
|
142
|
+
check Sparkle Design のアンチパターンを検査
|
|
143
|
+
setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
|
|
144
|
+
stop-hook AI assistant の Stop hook 用 internal subcommand(findings 検出時に exit 2 で 1 度だけ停止をブロック / 再発火は自動回避)
|
|
145
|
+
plugin-spec アンチパターン拡張プラグインの契約仕様を出力(--list で導入済みプラグインを表示)
|
|
141
146
|
|
|
142
147
|
Generate:
|
|
143
148
|
sparkle-design-cli generate
|
|
@@ -151,6 +156,10 @@ Check:
|
|
|
151
156
|
sparkle-design-cli check src --format json
|
|
152
157
|
sparkle-design-cli check src/components src/features
|
|
153
158
|
|
|
159
|
+
Plugin spec:
|
|
160
|
+
sparkle-design-cli plugin-spec # アンチパターン拡張プラグインの契約仕様を出力
|
|
161
|
+
sparkle-design-cli plugin-spec --list # 現在のプロジェクトで発見されたプラグインを表示
|
|
162
|
+
|
|
154
163
|
Setup:
|
|
155
164
|
sparkle-design-cli setup # フルセットアップ(推奨・新規導入時)
|
|
156
165
|
sparkle-design-cli setup --assistant claude # Claude 向けガードも同時にセットアップ
|
|
@@ -248,7 +257,31 @@ generate で生成されるファイル:
|
|
|
248
257
|
`);
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
function
|
|
260
|
+
async function runPluginSpec(args) {
|
|
261
|
+
// `--list` prints the discovered plugins; default prints the contract markdown so authors
|
|
262
|
+
// can `npx sparkle-design-cli plugin-spec > PLUGIN.md` to seed their docs.
|
|
263
|
+
// en: With --list, summarize what was discovered in the cwd; otherwise print the spec.
|
|
264
|
+
if (args.includes('--list')) {
|
|
265
|
+
const { discovered } = await loadAntiPatternPlugins();
|
|
266
|
+
if (discovered.length === 0) {
|
|
267
|
+
console.log('No anti-pattern plugins discovered in the current project.');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for (const record of discovered) {
|
|
271
|
+
const status = record.status === 'loaded' ? 'ok' : `error: ${record.error}`;
|
|
272
|
+
console.log(`- ${record.packageName} (${status})`);
|
|
273
|
+
console.log(` entry: ${record.entry}`);
|
|
274
|
+
console.log(` resolved: ${record.resolvedFile}`);
|
|
275
|
+
if (record.groupIds && record.groupIds.length > 0) {
|
|
276
|
+
console.log(` rules: ${record.groupIds.join(', ')}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
console.log(PLUGIN_SPEC_MARKDOWN);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function main() {
|
|
252
285
|
const args = process.argv.slice(2);
|
|
253
286
|
const command = args[0];
|
|
254
287
|
|
|
@@ -280,7 +313,7 @@ function main() {
|
|
|
280
313
|
process.exit(0);
|
|
281
314
|
}
|
|
282
315
|
|
|
283
|
-
const hasFindings = checkProject(options.targets, {
|
|
316
|
+
const hasFindings = await checkProject(options.targets, {
|
|
284
317
|
strict: options.strict,
|
|
285
318
|
format: options.format,
|
|
286
319
|
});
|
|
@@ -302,6 +335,21 @@ function main() {
|
|
|
302
335
|
return;
|
|
303
336
|
}
|
|
304
337
|
|
|
338
|
+
if (command === 'stop-hook') {
|
|
339
|
+
// setup で各 agent の hook 設定ファイルから呼ばれる internal subcommand。
|
|
340
|
+
// 第 2 引数は lint 対象 path(setup 時に決まったもの)。option flag は未対応。
|
|
341
|
+
// en: Internal subcommand invoked by agent stop hooks. The single positional
|
|
342
|
+
// arg is the lint target path determined at setup time.
|
|
343
|
+
const target = args[1];
|
|
344
|
+
const exitCode = await runStopHook(target);
|
|
345
|
+
process.exit(exitCode);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (command === 'plugin-spec') {
|
|
349
|
+
await runPluginSpec(args.slice(1));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
305
353
|
// 未知のコマンド: エラー終了ではなく note + help 表示にする(typo 時の迷子を防ぐ)
|
|
306
354
|
// en: Unknown command: show a short note + help instead of throwing
|
|
307
355
|
console.warn(
|
|
@@ -23,7 +23,7 @@ function renderJSDocSection(section) {
|
|
|
23
23
|
return output.map((line) => (line ? ` * ${line}` : ' *')).join('\n');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const BUILTIN_MANUAL_REVIEW_REMINDERS = [
|
|
27
27
|
{
|
|
28
28
|
id: 'badge-tag-semantics',
|
|
29
29
|
message:
|
|
@@ -46,7 +46,7 @@ const MANUAL_REVIEW_REMINDERS = [
|
|
|
46
46
|
},
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
-
const
|
|
49
|
+
const BUILTIN_ANTI_PATTERN_GROUPS = [
|
|
50
50
|
{
|
|
51
51
|
id: 'card-description',
|
|
52
52
|
featureSection: lines([
|
|
@@ -1200,51 +1200,62 @@ const ANTI_PATTERN_GROUPS = [
|
|
|
1200
1200
|
},
|
|
1201
1201
|
];
|
|
1202
1202
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1203
|
+
// Built-in groups always come before plugin-supplied groups in this priority order.
|
|
1204
|
+
// Plugin-supplied IDs that aren't in this list fall to the end (stable insertion order).
|
|
1205
|
+
// en: Determines `check` finding emission order so the most-important built-in rules surface first.
|
|
1206
|
+
const BUILTIN_CHECK_ORDER = [
|
|
1207
|
+
'dialog-form',
|
|
1208
|
+
'dialog-button-wrap',
|
|
1209
|
+
'button-icon-only',
|
|
1210
|
+
'material-symbols-direct',
|
|
1211
|
+
'shadcn-token',
|
|
1212
|
+
'tailwind-typography',
|
|
1213
|
+
'card-title-typography',
|
|
1214
|
+
'card-control-non-button',
|
|
1215
|
+
'card-padding-override',
|
|
1216
|
+
'aschild-with-icon-props',
|
|
1217
|
+
'disabled-vs-is-disabled',
|
|
1218
|
+
'button-prefixicon-jsx',
|
|
1219
|
+
'icon-children-text',
|
|
1220
|
+
];
|
|
1219
1221
|
|
|
1220
|
-
|
|
1222
|
+
function getCheckRules(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
|
|
1223
|
+
return groups
|
|
1224
|
+
.filter((group) => group.check)
|
|
1221
1225
|
.map((group) => ({
|
|
1222
1226
|
id: group.id,
|
|
1223
1227
|
...group.check,
|
|
1224
1228
|
}))
|
|
1225
1229
|
.sort((left, right) => {
|
|
1226
|
-
const leftIndex =
|
|
1227
|
-
const rightIndex =
|
|
1230
|
+
const leftIndex = BUILTIN_CHECK_ORDER.indexOf(left.id);
|
|
1231
|
+
const rightIndex = BUILTIN_CHECK_ORDER.indexOf(right.id);
|
|
1228
1232
|
const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
|
1229
1233
|
const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
|
1230
1234
|
return normalizedLeftIndex - normalizedRightIndex;
|
|
1231
1235
|
});
|
|
1232
1236
|
}
|
|
1233
1237
|
|
|
1234
|
-
function getAllJSDocTargets() {
|
|
1235
|
-
return
|
|
1238
|
+
function getAllJSDocTargets(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
|
|
1239
|
+
return groups.flatMap((group) => group.jsdocTargets ?? []);
|
|
1236
1240
|
}
|
|
1237
1241
|
|
|
1238
|
-
function renderFeatureSections() {
|
|
1239
|
-
return
|
|
1242
|
+
function renderFeatureSections(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
|
|
1243
|
+
return groups
|
|
1244
|
+
.map((group) => group.featureSection)
|
|
1245
|
+
.filter(Boolean)
|
|
1246
|
+
.join('\n\n');
|
|
1240
1247
|
}
|
|
1241
1248
|
|
|
1242
|
-
function getManualReviewReminders() {
|
|
1243
|
-
return
|
|
1249
|
+
function getManualReviewReminders(reminders = BUILTIN_MANUAL_REVIEW_REMINDERS) {
|
|
1250
|
+
return reminders;
|
|
1244
1251
|
}
|
|
1245
1252
|
|
|
1246
1253
|
export {
|
|
1247
|
-
|
|
1254
|
+
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
1255
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
1256
|
+
// Backward-compatible aliases — keep until external callers (sync-anti-pattern-docs, tests) migrate.
|
|
1257
|
+
BUILTIN_ANTI_PATTERN_GROUPS as ANTI_PATTERN_GROUPS,
|
|
1258
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
1248
1259
|
COMMON_INTRO,
|
|
1249
1260
|
getAllJSDocTargets,
|
|
1250
1261
|
getCheckRules,
|
package/lib/check.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
6
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
7
|
+
getCheckRules,
|
|
8
|
+
getManualReviewReminders,
|
|
9
|
+
} from './anti-pattern-rules.js';
|
|
10
|
+
import { loadAntiPatternPlugins } from './load-plugins.js';
|
|
5
11
|
import { REGEX, FONT_DOMAINS } from './constants.js';
|
|
6
12
|
|
|
7
13
|
const DEFAULT_TARGET = 'src';
|
|
8
14
|
const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
9
15
|
const CSS_EXTENSIONS = new Set(['.css']);
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
|
|
17
|
+
// Built-ins resolved at module load so test code that imports { RULES } directly still works.
|
|
18
|
+
// en: Synchronous built-in snapshot for backward-compatible export below.
|
|
19
|
+
const BUILTIN_RULES = getCheckRules(BUILTIN_ANTI_PATTERN_GROUPS);
|
|
12
20
|
|
|
13
21
|
const SPARKLE_HEAD_JSX_PATTERN = /<SparkleHead\s*\/?\s*>/;
|
|
14
22
|
// CSP key が「実際の設定 key として記述されている」コンテキストに絞る。
|
|
@@ -102,7 +110,8 @@ function formatSnippet(text) {
|
|
|
102
110
|
* out when character-* / Sparkle tokens don't map cleanly (e.g. sub-token
|
|
103
111
|
* font sizes). Same-line or previous-line comment is honored.
|
|
104
112
|
*/
|
|
105
|
-
const SUPPRESS_LINE =
|
|
113
|
+
const SUPPRESS_LINE =
|
|
114
|
+
/(?:\/\/|\/\*|\{\/\*)\s*sparkle-disable-line\s+([A-Za-z0-9,\s_-]+?)\s*(?:\*\/\}?|$)/;
|
|
106
115
|
const SUPPRESS_NEXT_LINE =
|
|
107
116
|
/(?:\/\/|\/\*|\{\/\*)\s*sparkle-disable-next-line\s+([A-Za-z0-9,\s_-]+?)\s*(?:\*\/\}?|$)/;
|
|
108
117
|
|
|
@@ -130,7 +139,7 @@ function isSuppressed(ruleId, contentLines, lineNumber) {
|
|
|
130
139
|
return false;
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
function collectFindings(filePath, content) {
|
|
142
|
+
function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
134
143
|
const findings = [];
|
|
135
144
|
const contentLines = content.split(/\r?\n/);
|
|
136
145
|
const pushFinding = (rule, index, snippet) => {
|
|
@@ -146,18 +155,40 @@ function collectFindings(filePath, content) {
|
|
|
146
155
|
});
|
|
147
156
|
};
|
|
148
157
|
|
|
149
|
-
for (const rule of
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
for (const rule of rules) {
|
|
159
|
+
// rule ごとに try/catch で隔離する。1 つのプラグイン rule の throw / malformed
|
|
160
|
+
// RegExp で sparkle-design-cli check 全体が落ちるのを防ぐ。loadAntiPatternPlugins の
|
|
161
|
+
// 「warn して skip」と同じ failure mode に揃える。
|
|
162
|
+
// en: Isolate each rule. A broken plugin rule (throw / non-global regex / wrong
|
|
163
|
+
// type) must not crash the whole check pipeline.
|
|
164
|
+
try {
|
|
165
|
+
if (typeof rule.match === 'function') {
|
|
166
|
+
// rule.match(content) -> Array<{ index: number, text: string }>
|
|
167
|
+
// 複雑な照合が必要なルール向けの opt-in API。2-pass 方式などで regex 単体の
|
|
168
|
+
// backtracking リスクを回避したいときに使う。index は content 内の絶対オフセット。
|
|
169
|
+
const hits = rule.match(content);
|
|
170
|
+
if (!Array.isArray(hits)) {
|
|
171
|
+
throw new TypeError(`rule.match must return an array, got ${typeof hits}`);
|
|
172
|
+
}
|
|
173
|
+
for (const hit of hits) {
|
|
174
|
+
pushFinding(rule, hit?.index ?? 0, formatSnippet(hit?.text ?? ''));
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
156
177
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
178
|
+
if (!(rule.pattern instanceof RegExp)) {
|
|
179
|
+
throw new TypeError('rule.pattern must be a RegExp (or define rule.match instead)');
|
|
180
|
+
}
|
|
181
|
+
if (!rule.pattern.global) {
|
|
182
|
+
throw new TypeError('rule.pattern must include the global flag (e.g. /foo/g)');
|
|
183
|
+
}
|
|
184
|
+
for (const match of content.matchAll(rule.pattern)) {
|
|
185
|
+
pushFinding(rule, match.index ?? 0, formatMatch(match));
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const message = error?.message ?? String(error);
|
|
189
|
+
console.warn(
|
|
190
|
+
`⚠️ sparkle-design-cli: rule "${rule.id ?? '(unknown)'}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
|
|
191
|
+
);
|
|
161
192
|
}
|
|
162
193
|
}
|
|
163
194
|
|
|
@@ -267,7 +298,9 @@ function collectNextjsCspFindings(cwd) {
|
|
|
267
298
|
return findings;
|
|
268
299
|
}
|
|
269
300
|
|
|
270
|
-
function createCheckReport(targets = []) {
|
|
301
|
+
function createCheckReport(targets = [], options = {}) {
|
|
302
|
+
const rules = options.rules ?? BUILTIN_RULES;
|
|
303
|
+
const baseReminders = options.baseReminders ?? BUILTIN_MANUAL_REVIEW_REMINDERS;
|
|
271
304
|
const resolvedTargets = targets.length > 0 ? targets : [DEFAULT_TARGET];
|
|
272
305
|
const textFiles = new Set();
|
|
273
306
|
const cssFiles = new Set();
|
|
@@ -289,7 +322,7 @@ function createCheckReport(targets = []) {
|
|
|
289
322
|
}
|
|
290
323
|
|
|
291
324
|
const findings = [...fileContents.entries()]
|
|
292
|
-
.flatMap(([filePath, content]) => collectFindings(filePath, content))
|
|
325
|
+
.flatMap(([filePath, content]) => collectFindings(filePath, content, rules))
|
|
293
326
|
.map((finding) => ({
|
|
294
327
|
...finding,
|
|
295
328
|
filePath: toRelativeReportPath(finding.filePath),
|
|
@@ -308,7 +341,7 @@ function createCheckReport(targets = []) {
|
|
|
308
341
|
return left.id.localeCompare(right.id);
|
|
309
342
|
});
|
|
310
343
|
|
|
311
|
-
const manualReviewReminders = [...
|
|
344
|
+
const manualReviewReminders = [...baseReminders];
|
|
312
345
|
if (!hasSparkleHeadUsage(fileContents)) {
|
|
313
346
|
manualReviewReminders.push({
|
|
314
347
|
id: 'sparkle-head-missing',
|
|
@@ -352,15 +385,9 @@ function printTextReport(report, options = {}) {
|
|
|
352
385
|
// exit code — reminders are judgment calls, not hard failures.
|
|
353
386
|
console.log('');
|
|
354
387
|
console.log('=== Manual review reminders (must acknowledge each ID in your response) ===');
|
|
355
|
-
console.log(
|
|
356
|
-
|
|
357
|
-
);
|
|
358
|
-
console.log(
|
|
359
|
-
'explicitly state the reminder ID in your reply together with whether the'
|
|
360
|
-
);
|
|
361
|
-
console.log(
|
|
362
|
-
'current code already satisfies it, or what change is needed. Silence = skipped.'
|
|
363
|
-
);
|
|
388
|
+
console.log('These are judgment calls the linter cannot detect. For every item below,');
|
|
389
|
+
console.log('explicitly state the reminder ID in your reply together with whether the');
|
|
390
|
+
console.log('current code already satisfies it, or what change is needed. Silence = skipped.');
|
|
364
391
|
console.log('');
|
|
365
392
|
for (const reminder of reminders) {
|
|
366
393
|
console.log(`- [${reminder.id}] ${reminder.message}`);
|
|
@@ -413,8 +440,19 @@ function printJsonReport(report, options = {}) {
|
|
|
413
440
|
);
|
|
414
441
|
}
|
|
415
442
|
|
|
416
|
-
export function checkProject(targets = [], options = {}) {
|
|
417
|
-
|
|
443
|
+
export async function checkProject(targets = [], options = {}) {
|
|
444
|
+
// Plugins are discovered from the consumer project's package.json deps. Errors are
|
|
445
|
+
// already warn-and-skip inside loadAntiPatternPlugins, so we just consume the result.
|
|
446
|
+
// en: Auto-discover plugins from cwd; loader handles its own failure reporting.
|
|
447
|
+
const { groups: pluginGroups, reminders: pluginReminders } = await loadAntiPatternPlugins();
|
|
448
|
+
const mergedGroups = [...BUILTIN_ANTI_PATTERN_GROUPS, ...pluginGroups];
|
|
449
|
+
const rules = getCheckRules(mergedGroups);
|
|
450
|
+
const baseReminders = getManualReviewReminders([
|
|
451
|
+
...BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
452
|
+
...pluginReminders,
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
const report = createCheckReport(targets, { rules, baseReminders });
|
|
418
456
|
|
|
419
457
|
if (options.format === 'json') {
|
|
420
458
|
printJsonReport(report, options);
|
|
@@ -426,8 +464,8 @@ export function checkProject(targets = [], options = {}) {
|
|
|
426
464
|
}
|
|
427
465
|
|
|
428
466
|
export {
|
|
429
|
-
RULES,
|
|
467
|
+
BUILTIN_RULES as RULES,
|
|
430
468
|
collectFindings,
|
|
431
469
|
createCheckReport,
|
|
432
|
-
|
|
470
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
433
471
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
PLUGIN_PACKAGE_FIELD,
|
|
8
|
+
PLUGIN_ANTI_PATTERNS_KEY,
|
|
9
|
+
validatePluginShape,
|
|
10
|
+
} from './plugin-api.js';
|
|
11
|
+
|
|
12
|
+
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
13
|
+
|
|
14
|
+
function readJsonSafe(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectDependencyNames(consumerPkg) {
|
|
23
|
+
const names = new Set();
|
|
24
|
+
for (const field of DEP_FIELDS) {
|
|
25
|
+
const block = consumerPkg[field];
|
|
26
|
+
if (block && typeof block === 'object') {
|
|
27
|
+
for (const name of Object.keys(block)) {
|
|
28
|
+
names.add(name);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return [...names];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolvePluginPackageJson(depName, cwd) {
|
|
36
|
+
// cwd を基点に dep の package.json を解決する。tsup / pkg-roll で生成される strict な
|
|
37
|
+
// `exports` フィールドのみのパッケージは subpath `./package.json` が export されておらず
|
|
38
|
+
// require.resolve が `ERR_PACKAGE_PATH_NOT_EXPORTED` を投げる。その場合は node_modules
|
|
39
|
+
// 配下の `package.json` を fs で直接読みに行く fallback を取る(pnpm の symlink は OS 透過)。
|
|
40
|
+
// en: Primary resolve via Node's resolver; if the plugin package's `exports` does not list
|
|
41
|
+
// `./package.json`, fall back to a direct fs read so plugins are not silently skipped.
|
|
42
|
+
const requireFromCwd = createRequire(path.join(cwd, 'package.json'));
|
|
43
|
+
try {
|
|
44
|
+
return requireFromCwd.resolve(`${depName}/package.json`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
|
47
|
+
const fallback = path.join(cwd, 'node_modules', ...depName.split('/'), 'package.json');
|
|
48
|
+
if (fs.existsSync(fallback)) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractPluginEntry(pkg) {
|
|
57
|
+
const block = pkg?.[PLUGIN_PACKAGE_FIELD];
|
|
58
|
+
if (!block || typeof block !== 'object') return null;
|
|
59
|
+
const entry = block[PLUGIN_ANTI_PATTERNS_KEY];
|
|
60
|
+
return typeof entry === 'string' && entry.length > 0 ? entry : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Discover and load anti-pattern plugins from the consumer's package.json deps.
|
|
65
|
+
*
|
|
66
|
+
* Returns an object of the shape:
|
|
67
|
+
* {
|
|
68
|
+
* groups: AntiPatternGroup[],
|
|
69
|
+
* reminders: ManualReviewReminder[],
|
|
70
|
+
* discovered: Array<{
|
|
71
|
+
* packageName: string,
|
|
72
|
+
* entry: string, // path declared in package.json (relative)
|
|
73
|
+
* resolvedFile: string, // absolute resolved file path
|
|
74
|
+
* status: 'loaded' | 'error',
|
|
75
|
+
* groupIds?: string[], // populated when status === 'loaded'
|
|
76
|
+
* error?: string,
|
|
77
|
+
* }>,
|
|
78
|
+
* }
|
|
79
|
+
*
|
|
80
|
+
* The function never throws — broken or missing plugins are reported in `discovered`
|
|
81
|
+
* and surfaced to stderr as warnings so the rest of the check can proceed.
|
|
82
|
+
*/
|
|
83
|
+
export async function loadAntiPatternPlugins({ cwd = process.cwd() } = {}) {
|
|
84
|
+
const consumerPkgPath = path.join(cwd, 'package.json');
|
|
85
|
+
const consumerPkg = readJsonSafe(consumerPkgPath);
|
|
86
|
+
if (!consumerPkg) {
|
|
87
|
+
return { groups: [], reminders: [], discovered: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const depNames = collectDependencyNames(consumerPkg);
|
|
91
|
+
const discovered = [];
|
|
92
|
+
const groups = [];
|
|
93
|
+
const reminders = [];
|
|
94
|
+
|
|
95
|
+
for (const depName of depNames) {
|
|
96
|
+
const depPkgPath = resolvePluginPackageJson(depName, cwd);
|
|
97
|
+
if (!depPkgPath) continue;
|
|
98
|
+
|
|
99
|
+
const depPkg = readJsonSafe(depPkgPath);
|
|
100
|
+
if (!depPkg) continue;
|
|
101
|
+
|
|
102
|
+
const entry = extractPluginEntry(depPkg);
|
|
103
|
+
if (!entry) continue;
|
|
104
|
+
|
|
105
|
+
const resolvedFile = path.resolve(path.dirname(depPkgPath), entry);
|
|
106
|
+
const record = { packageName: depName, entry, resolvedFile, status: 'loaded' };
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(resolvedFile)) {
|
|
110
|
+
throw new Error(`plugin entry file not found at ${resolvedFile}`);
|
|
111
|
+
}
|
|
112
|
+
const mod = await import(pathToFileURL(resolvedFile).href);
|
|
113
|
+
const candidate = mod?.default ?? mod;
|
|
114
|
+
// shape の検証は plugin-api.js と共有する。`defineAntiPatternPlugin` を呼ばずに
|
|
115
|
+
// plain object を export する plugin にも同じ厳しさを適用したい。
|
|
116
|
+
// en: Share shape validation with `defineAntiPatternPlugin` so plugins that skip
|
|
117
|
+
// the helper are still rejected at load time.
|
|
118
|
+
validatePluginShape(candidate, `${depName} (${entry})`);
|
|
119
|
+
groups.push(...candidate.groups);
|
|
120
|
+
record.groupIds = candidate.groups.map((group) => group.id);
|
|
121
|
+
if (Array.isArray(candidate.manualReviewReminders)) {
|
|
122
|
+
reminders.push(...candidate.manualReviewReminders);
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
record.status = 'error';
|
|
126
|
+
record.error = error?.message ?? String(error);
|
|
127
|
+
console.warn(
|
|
128
|
+
`⚠️ sparkle-design-cli: failed to load anti-pattern plugin from ${depName} (entry: ${entry}, error: ${record.error})`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
discovered.push(record);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { groups, reminders, discovered };
|
|
136
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sparkle-design-cli anti-pattern plugin contract.
|
|
3
|
+
*
|
|
4
|
+
* Plugin packages declare themselves in their own package.json:
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* "name": "@your-org/your-design-extensions",
|
|
8
|
+
* "sparkleCli": {
|
|
9
|
+
* "antiPatterns": "./anti-patterns/index.js"
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* The referenced file must export a plugin object as the default export,
|
|
14
|
+
* typically produced by `defineAntiPatternPlugin({ ... })`.
|
|
15
|
+
*
|
|
16
|
+
* ## Plugin shape
|
|
17
|
+
*
|
|
18
|
+
* defineAntiPatternPlugin({
|
|
19
|
+
* groups: AntiPatternGroup[],
|
|
20
|
+
* manualReviewReminders?: ManualReviewReminder[],
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* ## AntiPatternGroup
|
|
24
|
+
*
|
|
25
|
+
* {
|
|
26
|
+
* id: string, // unique stable identifier (also used in --disable-line comments)
|
|
27
|
+
* featureSection?: string, // markdown emitted into the published anti-pattern doc (esa)
|
|
28
|
+
* jsdocTargets?: JSDocTarget[], // optional — JSDoc injection targets in the plugin's own source
|
|
29
|
+
* check?: {
|
|
30
|
+
* description: string, // shown in `check` findings
|
|
31
|
+
* recommendation: string, // shown in `check` findings
|
|
32
|
+
* pattern?: RegExp, // simple regex check. MUST have the global (`g`) flag.
|
|
33
|
+
* // mutually exclusive with `match`.
|
|
34
|
+
* match?: (content: string) => Array<{ index: number, text: string }>,
|
|
35
|
+
* // opt-in API for complex matching (2-pass, AST, etc.)
|
|
36
|
+
* },
|
|
37
|
+
* }
|
|
38
|
+
*
|
|
39
|
+
* ## JSDocTarget
|
|
40
|
+
*
|
|
41
|
+
* {
|
|
42
|
+
* file: string, // path relative to the plugin package root
|
|
43
|
+
* targetName: string, // symbol whose JSDoc block receives the anti-pattern section
|
|
44
|
+
* section: {
|
|
45
|
+
* bullets: Array<{ ja: string, en: string }>,
|
|
46
|
+
* example?: string, // tsx snippet
|
|
47
|
+
* },
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* ## ManualReviewReminder
|
|
51
|
+
*
|
|
52
|
+
* {
|
|
53
|
+
* id: string, // unique stable identifier echoed by the AI in acknowledgments
|
|
54
|
+
* message: string, // human-readable instruction
|
|
55
|
+
* }
|
|
56
|
+
*
|
|
57
|
+
* ## Notes
|
|
58
|
+
*
|
|
59
|
+
* - Built-in rules for the public `@goodpatch/sparkle-design` library are bundled with the CLI
|
|
60
|
+
* and not exposed as a plugin. Plugins are merged on top of the built-ins.
|
|
61
|
+
* - Plugins are auto-discovered from the consumer's `package.json` dependencies, so a plugin
|
|
62
|
+
* is active only when the consuming project actually installs the plugin package.
|
|
63
|
+
* - Plugin IDs share a namespace with built-ins. Pick a prefix (e.g. `internal-`) to avoid clashes.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
const PLUGIN_PACKAGE_FIELD = 'sparkleCli';
|
|
67
|
+
const PLUGIN_ANTI_PATTERNS_KEY = 'antiPatterns';
|
|
68
|
+
|
|
69
|
+
function isNonEmptyString(value) {
|
|
70
|
+
return typeof value === 'string' && value.length > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateGroup(group, context) {
|
|
74
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
75
|
+
throw new TypeError(`${context}: group entry must be a non-null object`);
|
|
76
|
+
}
|
|
77
|
+
if (!isNonEmptyString(group.id)) {
|
|
78
|
+
throw new TypeError(`${context}: group.id must be a non-empty string`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (group.check !== undefined) {
|
|
82
|
+
const { check } = group;
|
|
83
|
+
if (!check || typeof check !== 'object') {
|
|
84
|
+
throw new TypeError(`${context} (group "${group.id}"): check must be an object`);
|
|
85
|
+
}
|
|
86
|
+
if (!isNonEmptyString(check.description)) {
|
|
87
|
+
throw new TypeError(
|
|
88
|
+
`${context} (group "${group.id}"): check.description must be a non-empty string`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!isNonEmptyString(check.recommendation)) {
|
|
92
|
+
throw new TypeError(
|
|
93
|
+
`${context} (group "${group.id}"): check.recommendation must be a non-empty string`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const hasPattern = check.pattern !== undefined;
|
|
97
|
+
const hasMatch = check.match !== undefined;
|
|
98
|
+
if (hasPattern === hasMatch) {
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`${context} (group "${group.id}"): check must define exactly one of \`pattern\` (RegExp) or \`match\` (function)`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (hasPattern) {
|
|
104
|
+
if (!(check.pattern instanceof RegExp)) {
|
|
105
|
+
throw new TypeError(`${context} (group "${group.id}"): check.pattern must be a RegExp`);
|
|
106
|
+
}
|
|
107
|
+
// matchAll は global flag が無い RegExp で TypeError を投げるので、ここで明示的に弾く。
|
|
108
|
+
// en: Bail early so the check pipeline never reaches `content.matchAll(nonGlobal)`.
|
|
109
|
+
if (!check.pattern.global) {
|
|
110
|
+
throw new TypeError(
|
|
111
|
+
`${context} (group "${group.id}"): check.pattern must include the global flag (e.g. /foo/g)`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} else if (typeof check.match !== 'function') {
|
|
115
|
+
throw new TypeError(`${context} (group "${group.id}"): check.match must be a function`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateReminders(reminders, context) {
|
|
121
|
+
if (reminders === undefined) return;
|
|
122
|
+
if (!Array.isArray(reminders)) {
|
|
123
|
+
throw new TypeError(`${context}: manualReviewReminders must be an array if present`);
|
|
124
|
+
}
|
|
125
|
+
for (const [index, reminder] of reminders.entries()) {
|
|
126
|
+
if (!reminder || typeof reminder !== 'object') {
|
|
127
|
+
throw new TypeError(`${context}: manualReviewReminders[${index}] must be a non-null object`);
|
|
128
|
+
}
|
|
129
|
+
if (!isNonEmptyString(reminder.id)) {
|
|
130
|
+
throw new TypeError(
|
|
131
|
+
`${context}: manualReviewReminders[${index}].id must be a non-empty string`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!isNonEmptyString(reminder.message)) {
|
|
135
|
+
throw new TypeError(
|
|
136
|
+
`${context}: manualReviewReminders[${index}].message must be a non-empty string`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate that the given object matches the documented plugin shape. Throws TypeError
|
|
144
|
+
* with an actionable message on the first violation. Exported so the loader can apply
|
|
145
|
+
* the same checks to plugins that don't go through `defineAntiPatternPlugin`.
|
|
146
|
+
*
|
|
147
|
+
* @param {unknown} plugin
|
|
148
|
+
* @param {string} [context] source identifier shown in error messages (e.g. `@org/pkg (./file.js)`)
|
|
149
|
+
*/
|
|
150
|
+
function validatePluginShape(plugin, context = 'plugin') {
|
|
151
|
+
if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) {
|
|
152
|
+
throw new TypeError(`${context}: plugin must be a non-null object`);
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(plugin.groups)) {
|
|
155
|
+
throw new TypeError(`${context}: plugin.groups must be an array`);
|
|
156
|
+
}
|
|
157
|
+
for (const [index, group] of plugin.groups.entries()) {
|
|
158
|
+
validateGroup(group, `${context} groups[${index}]`);
|
|
159
|
+
}
|
|
160
|
+
validateReminders(plugin.manualReviewReminders, context);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function defineAntiPatternPlugin(plugin) {
|
|
164
|
+
validatePluginShape(plugin, 'defineAntiPatternPlugin');
|
|
165
|
+
return plugin;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const PLUGIN_SPEC_MARKDOWN = `# sparkle-design-cli anti-pattern plugin spec
|
|
169
|
+
|
|
170
|
+
Plugin packages extend \`sparkle-design-cli check\` with additional anti-pattern rules.
|
|
171
|
+
Built-in rules cover the public \`@goodpatch/sparkle-design\` library; plugins are auto-discovered
|
|
172
|
+
from the consuming project's dependencies, so private or org-specific component libraries can ship
|
|
173
|
+
their own detection rules without bundling them into the public CLI.
|
|
174
|
+
|
|
175
|
+
## Declaring a plugin
|
|
176
|
+
|
|
177
|
+
In the plugin package's own \`package.json\`:
|
|
178
|
+
|
|
179
|
+
\`\`\`json
|
|
180
|
+
{
|
|
181
|
+
"name": "@your-org/your-design-extensions",
|
|
182
|
+
"sparkleCli": {
|
|
183
|
+
"antiPatterns": "./anti-patterns/index.js"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
\`\`\`
|
|
187
|
+
|
|
188
|
+
The referenced file must default-export a plugin object:
|
|
189
|
+
|
|
190
|
+
\`\`\`js
|
|
191
|
+
import { defineAntiPatternPlugin } from 'sparkle-design-cli/plugin';
|
|
192
|
+
|
|
193
|
+
export default defineAntiPatternPlugin({
|
|
194
|
+
groups: [
|
|
195
|
+
{
|
|
196
|
+
id: 'internal-foo-misuse',
|
|
197
|
+
featureSection: '### FooCard はネスト禁止\\n...',
|
|
198
|
+
check: {
|
|
199
|
+
description: 'FooCard を別の FooCard でラップしないでください。',
|
|
200
|
+
recommendation: '入れ子表示が必要なら FooStack を使ってください。',
|
|
201
|
+
pattern: /<FooCard[^>]*>[\\s\\S]*?<FooCard/g,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
manualReviewReminders: [
|
|
206
|
+
{
|
|
207
|
+
id: 'internal-foo-color',
|
|
208
|
+
message: 'FooCard の color token が brand に揃っているか確認してください。',
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
\`\`\`
|
|
213
|
+
|
|
214
|
+
## Discovery
|
|
215
|
+
|
|
216
|
+
When \`sparkle-design-cli\` runs in a project, it reads that project's \`package.json\`,
|
|
217
|
+
walks \`dependencies\` / \`devDependencies\` / \`peerDependencies\` / \`optionalDependencies\`,
|
|
218
|
+
and resolves each entry's own \`package.json\`. Packages whose \`package.json\` declares
|
|
219
|
+
\`sparkleCli.antiPatterns\` are loaded and merged into the check rules.
|
|
220
|
+
|
|
221
|
+
End users do not have to configure anything — installing a plugin package is enough to activate it.
|
|
222
|
+
|
|
223
|
+
## Shape reference
|
|
224
|
+
|
|
225
|
+
See the JSDoc in \`lib/plugin-api.js\` for the full type contract:
|
|
226
|
+
|
|
227
|
+
- \`AntiPatternGroup\`: \`{ id, featureSection?, jsdocTargets?, check? }\`
|
|
228
|
+
- \`AntiPatternGroup.check\`: \`{ description, recommendation, pattern | match }\`
|
|
229
|
+
- \`pattern\` is a RegExp and **must include the global flag** (\`/foo/g\`); otherwise
|
|
230
|
+
the rule is rejected at load time.
|
|
231
|
+
- \`pattern\` and \`match\` are mutually exclusive — provide exactly one.
|
|
232
|
+
- \`JSDocTarget\`: \`{ file, targetName, section: { bullets, example? } }\`
|
|
233
|
+
- \`ManualReviewReminder\`: \`{ id, message }\`
|
|
234
|
+
|
|
235
|
+
## ID namespace
|
|
236
|
+
|
|
237
|
+
Plugin rule IDs share a namespace with built-in rules and with each other. Choose a stable prefix
|
|
238
|
+
(e.g. \`internal-\`, \`acme-\`) so suppression comments like \`// sparkle-disable-next-line internal-foo-misuse\`
|
|
239
|
+
remain unambiguous.
|
|
240
|
+
|
|
241
|
+
## Failure mode
|
|
242
|
+
|
|
243
|
+
Errors loading or evaluating a plugin are logged as warnings; the rest of the check continues with
|
|
244
|
+
the rules that did load. The CLI never fails because a plugin is missing or broken.
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
export {
|
|
248
|
+
PLUGIN_PACKAGE_FIELD,
|
|
249
|
+
PLUGIN_ANTI_PATTERNS_KEY,
|
|
250
|
+
PLUGIN_SPEC_MARKDOWN,
|
|
251
|
+
defineAntiPatternPlugin,
|
|
252
|
+
validatePluginShape,
|
|
253
|
+
};
|
package/lib/setup.js
CHANGED
|
@@ -594,16 +594,20 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
|
|
|
594
594
|
*
|
|
595
595
|
* 各 agent が持つ hook システムの schema は異なるため、agent ごとに以下の
|
|
596
596
|
* ヘルパーを持つ。共通ポイントは:
|
|
597
|
-
* - 実行コマンドは `npx --yes sparkle-design-cli
|
|
598
|
-
*
|
|
599
|
-
*
|
|
597
|
+
* - 実行コマンドは `npx --yes sparkle-design-cli stop-hook <target>`
|
|
598
|
+
* (内部で check --strict を回し、初回 findings 検出時のみ exit 2 で停止を
|
|
599
|
+
* ブロックする。Claude Code の `stop_hook_active` を読んで再発火時は
|
|
600
|
+
* exit 0 で抜けることでセッション内ループを防ぐ)
|
|
600
601
|
* - 既存の hook 設定ファイルは非破壊マージし、同一 command が含まれていれば skip
|
|
601
|
-
*
|
|
602
|
+
* (冪等)。2.0.7 までの旧コマンド `check ... --strict || exit 2` を見つけたら
|
|
603
|
+
* 新 subcommand へ自動移行する
|
|
602
604
|
* - 壊れた JSON は silent 上書きせず、ユーザーに修正を促すエラーを出す
|
|
603
605
|
*
|
|
604
|
-
* en: Install an agent-specific hook that runs `
|
|
605
|
-
*
|
|
606
|
-
*
|
|
606
|
+
* en: Install an agent-specific hook that runs the `stop-hook` subcommand,
|
|
607
|
+
* which surfaces findings once and avoids the infinite re-fire loop the
|
|
608
|
+
* pre-2.0.8 form had. Each agent ships its own hook config format; these
|
|
609
|
+
* helpers emit the right shape, stay idempotent on rerun, and migrate
|
|
610
|
+
* legacy 2.0.7 commands when found.
|
|
607
611
|
*
|
|
608
612
|
* References (2026-04 時点):
|
|
609
613
|
* - Claude Code : https://docs.claude.com/en/docs/claude-code/hooks
|
|
@@ -611,7 +615,26 @@ function runAssistantGuard(cwd, packageJsonPath, options, assistantConfig) {
|
|
|
611
615
|
* - Codex : https://developers.openai.com/codex/hooks
|
|
612
616
|
*/
|
|
613
617
|
function buildManagedHookCommand(target) {
|
|
614
|
-
|
|
618
|
+
// Stop / stop hook は専用 subcommand に集約する。subcommand 側で
|
|
619
|
+
// `stop_hook_active` を見て初回だけ exit 2、再発火時は exit 0 で抜ける
|
|
620
|
+
// ことでセッション内ループを防ぐ。
|
|
621
|
+
// en: Route through the dedicated `stop-hook` subcommand so we can detect
|
|
622
|
+
// re-fires (Claude Code's `stop_hook_active`) and avoid infinite loops.
|
|
623
|
+
return `npx --yes sparkle-design-cli stop-hook ${target}`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 2.0.7 までは `npx --yes sparkle-design-cli check <target> --strict || exit 2` を
|
|
628
|
+
* 直接 hook に書いていたが、findings が残っていると Stop hook が再発火し続けて
|
|
629
|
+
* セッションが無限ループする問題があった。`stop-hook` subcommand に切り替える
|
|
630
|
+
* ため、既存 hook 設定にこの旧コマンドが残っている場合は新コマンドへ置換する。
|
|
631
|
+
*
|
|
632
|
+
* en: Detect legacy hook commands installed by 2.0.7 and earlier so setup
|
|
633
|
+
* reruns can heal them by swapping in the loop-safe `stop-hook` subcommand.
|
|
634
|
+
*/
|
|
635
|
+
function isLegacyManagedHookCommand(command) {
|
|
636
|
+
if (typeof command !== 'string') return false;
|
|
637
|
+
return /sparkle-design-cli\s+check\s+\S+\s+--strict\s*\|\|\s*exit\s+2/.test(command);
|
|
615
638
|
}
|
|
616
639
|
|
|
617
640
|
function loadHookJson(filePath, label) {
|
|
@@ -685,14 +708,40 @@ function runClaudeHook(cwd, target, dryRun) {
|
|
|
685
708
|
typeof nextSettings.hooks === 'object' && nextSettings.hooks ? { ...nextSettings.hooks } : {};
|
|
686
709
|
const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
|
|
687
710
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
711
|
+
// 1st pass: legacy エントリを発見。alreadyPresent / migrated を判定する。
|
|
712
|
+
// 2 回目以降の setup や、ユーザーが手動で新コマンドを足した状態でも
|
|
713
|
+
// 重複が出ないよう、変換は 2nd pass で行う。
|
|
714
|
+
// en: First pass detects state; second pass mutates without creating dupes.
|
|
715
|
+
let alreadyPresent = false;
|
|
716
|
+
let hasLegacy = false;
|
|
717
|
+
for (const group of stopGroups) {
|
|
718
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
719
|
+
for (const entry of group.hooks) {
|
|
720
|
+
if (entry?.type !== 'command') continue;
|
|
721
|
+
if (entry.command === managedCommand) alreadyPresent = true;
|
|
722
|
+
else if (isLegacyManagedHookCommand(entry.command)) hasLegacy = true;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const migrated = hasLegacy;
|
|
726
|
+
|
|
727
|
+
const updatedGroups = stopGroups.map((group) => {
|
|
728
|
+
if (!Array.isArray(group?.hooks)) return group;
|
|
729
|
+
const updatedHooks = group.hooks
|
|
730
|
+
.map((entry) => {
|
|
731
|
+
if (entry?.type === 'command' && isLegacyManagedHookCommand(entry.command)) {
|
|
732
|
+
// legacy + 新コマンドが両方あったら legacy を削除(dedupe)。
|
|
733
|
+
// legacy のみなら新コマンドへ in-place 置換。
|
|
734
|
+
// en: Drop legacy when the new command coexists; otherwise rewrite.
|
|
735
|
+
return alreadyPresent ? null : { ...entry, command: managedCommand };
|
|
736
|
+
}
|
|
737
|
+
return entry;
|
|
738
|
+
})
|
|
739
|
+
.filter((entry) => entry !== null);
|
|
740
|
+
return { ...group, hooks: updatedHooks };
|
|
741
|
+
});
|
|
693
742
|
|
|
694
743
|
const mismatch = detectClaudeSessionRootMismatch(cwd);
|
|
695
|
-
if (alreadyPresent) {
|
|
744
|
+
if (alreadyPresent && !migrated) {
|
|
696
745
|
return {
|
|
697
746
|
assistant: 'claude',
|
|
698
747
|
changed: false,
|
|
@@ -704,10 +753,12 @@ function runClaudeHook(cwd, target, dryRun) {
|
|
|
704
753
|
};
|
|
705
754
|
}
|
|
706
755
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
756
|
+
let nextStopGroups = updatedGroups;
|
|
757
|
+
if (!migrated && !alreadyPresent) {
|
|
758
|
+
nextStopGroups = [...updatedGroups, { hooks: [{ type: 'command', command: managedCommand }] }];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
hooks.Stop = nextStopGroups;
|
|
711
762
|
nextSettings.hooks = hooks;
|
|
712
763
|
|
|
713
764
|
if (!dryRun) {
|
|
@@ -719,7 +770,7 @@ function runClaudeHook(cwd, target, dryRun) {
|
|
|
719
770
|
changed: true,
|
|
720
771
|
existed,
|
|
721
772
|
path: settingsPath,
|
|
722
|
-
reason: existed ? 'appended' : 'created',
|
|
773
|
+
reason: migrated ? 'migrated' : existed ? 'appended' : 'created',
|
|
723
774
|
command: managedCommand,
|
|
724
775
|
sessionRootMismatch: mismatch,
|
|
725
776
|
};
|
|
@@ -740,8 +791,24 @@ function runCursorHook(cwd, target, dryRun) {
|
|
|
740
791
|
typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
|
|
741
792
|
const stopEntries = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
|
|
742
793
|
|
|
743
|
-
|
|
744
|
-
|
|
794
|
+
let alreadyPresent = false;
|
|
795
|
+
let hasLegacy = false;
|
|
796
|
+
for (const entry of stopEntries) {
|
|
797
|
+
if (entry?.command === managedCommand) alreadyPresent = true;
|
|
798
|
+
else if (isLegacyManagedHookCommand(entry?.command)) hasLegacy = true;
|
|
799
|
+
}
|
|
800
|
+
const migrated = hasLegacy;
|
|
801
|
+
|
|
802
|
+
const updatedEntries = stopEntries
|
|
803
|
+
.map((entry) => {
|
|
804
|
+
if (isLegacyManagedHookCommand(entry?.command)) {
|
|
805
|
+
return alreadyPresent ? null : { ...entry, command: managedCommand };
|
|
806
|
+
}
|
|
807
|
+
return entry;
|
|
808
|
+
})
|
|
809
|
+
.filter((entry) => entry !== null);
|
|
810
|
+
|
|
811
|
+
if (alreadyPresent && !migrated) {
|
|
745
812
|
return {
|
|
746
813
|
assistant: 'cursor',
|
|
747
814
|
changed: false,
|
|
@@ -752,8 +819,12 @@ function runCursorHook(cwd, target, dryRun) {
|
|
|
752
819
|
};
|
|
753
820
|
}
|
|
754
821
|
|
|
755
|
-
|
|
756
|
-
|
|
822
|
+
let nextStopEntries = updatedEntries;
|
|
823
|
+
if (!migrated && !alreadyPresent) {
|
|
824
|
+
nextStopEntries = [...updatedEntries, { command: managedCommand }];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
hooks.stop = nextStopEntries;
|
|
757
828
|
nextConfig.hooks = hooks;
|
|
758
829
|
|
|
759
830
|
if (!dryRun) {
|
|
@@ -766,7 +837,7 @@ function runCursorHook(cwd, target, dryRun) {
|
|
|
766
837
|
changed: true,
|
|
767
838
|
existed,
|
|
768
839
|
path: hooksPath,
|
|
769
|
-
reason: existed ? 'appended' : 'created',
|
|
840
|
+
reason: migrated ? 'migrated' : existed ? 'appended' : 'created',
|
|
770
841
|
command: managedCommand,
|
|
771
842
|
};
|
|
772
843
|
}
|
|
@@ -786,13 +857,37 @@ function runCodexHook(cwd, target, dryRun) {
|
|
|
786
857
|
typeof nextConfig.hooks === 'object' && nextConfig.hooks ? { ...nextConfig.hooks } : {};
|
|
787
858
|
const stopGroups = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
|
|
788
859
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
860
|
+
let alreadyPresent = false;
|
|
861
|
+
let hasLegacy = false;
|
|
862
|
+
for (const group of stopGroups) {
|
|
863
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
864
|
+
for (const entry of group.hooks) {
|
|
865
|
+
if (entry?.type !== 'command') continue;
|
|
866
|
+
if (entry.command === managedCommand) alreadyPresent = true;
|
|
867
|
+
else if (isLegacyManagedHookCommand(entry.command)) hasLegacy = true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const migrated = hasLegacy;
|
|
871
|
+
|
|
872
|
+
const updatedGroups = stopGroups.map((group) => {
|
|
873
|
+
if (!Array.isArray(group?.hooks)) return group;
|
|
874
|
+
const updatedHooks = group.hooks
|
|
875
|
+
.map((entry) => {
|
|
876
|
+
if (entry?.type === 'command' && isLegacyManagedHookCommand(entry.command)) {
|
|
877
|
+
return alreadyPresent ? null : { ...entry, command: managedCommand };
|
|
878
|
+
}
|
|
879
|
+
return entry;
|
|
880
|
+
})
|
|
881
|
+
.filter((entry) => entry !== null);
|
|
882
|
+
return { ...group, hooks: updatedHooks };
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// 有効化にはユーザー側の opt-in が必要な旨を summary に載せる。
|
|
886
|
+
// en: Codex hooks are behind an opt-in feature flag; surface that in summary.
|
|
887
|
+
const featureFlagNote =
|
|
888
|
+
'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。';
|
|
794
889
|
|
|
795
|
-
if (alreadyPresent) {
|
|
890
|
+
if (alreadyPresent && !migrated) {
|
|
796
891
|
return {
|
|
797
892
|
assistant: 'codex',
|
|
798
893
|
changed: false,
|
|
@@ -800,17 +895,16 @@ function runCodexHook(cwd, target, dryRun) {
|
|
|
800
895
|
path: hooksPath,
|
|
801
896
|
reason: 'already-present',
|
|
802
897
|
command: managedCommand,
|
|
803
|
-
|
|
804
|
-
// en: Codex hooks are behind an opt-in feature flag; surface that in summary.
|
|
805
|
-
featureFlagNote:
|
|
806
|
-
'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
|
|
898
|
+
featureFlagNote,
|
|
807
899
|
};
|
|
808
900
|
}
|
|
809
901
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
902
|
+
let nextStopGroups = updatedGroups;
|
|
903
|
+
if (!migrated && !alreadyPresent) {
|
|
904
|
+
nextStopGroups = [...updatedGroups, { hooks: [{ type: 'command', command: managedCommand }] }];
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
hooks.Stop = nextStopGroups;
|
|
814
908
|
nextConfig.hooks = hooks;
|
|
815
909
|
|
|
816
910
|
if (!dryRun) {
|
|
@@ -823,10 +917,9 @@ function runCodexHook(cwd, target, dryRun) {
|
|
|
823
917
|
changed: true,
|
|
824
918
|
existed,
|
|
825
919
|
path: hooksPath,
|
|
826
|
-
reason: existed ? 'appended' : 'created',
|
|
920
|
+
reason: migrated ? 'migrated' : existed ? 'appended' : 'created',
|
|
827
921
|
command: managedCommand,
|
|
828
|
-
featureFlagNote
|
|
829
|
-
'Codex で有効化するには `~/.codex/config.toml` に `[features]` セクションを作り `codex_hooks = true` を設定してください。',
|
|
922
|
+
featureFlagNote,
|
|
830
923
|
};
|
|
831
924
|
}
|
|
832
925
|
|
|
@@ -918,11 +1011,12 @@ export function setupAssistant(options = {}) {
|
|
|
918
1011
|
assistantConfig
|
|
919
1012
|
);
|
|
920
1013
|
// assistant 別に hook 設定ファイル(`.claude/settings.json` /
|
|
921
|
-
// `.cursor/hooks.json` / `.codex/hooks.json`)に `
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
1014
|
+
// `.cursor/hooks.json` / `.codex/hooks.json`)に `stop-hook` subcommand を
|
|
1015
|
+
// 設定する。instruction 頼みではなく hook で強制することで lint:sparkle の
|
|
1016
|
+
// 実行漏れを防ぐ。subcommand 側で `stop_hook_active` を見て再発火を抑止し、
|
|
1017
|
+
// findings があるときの 1 回だけ exit 2 でブロックする。generic は hook なし。
|
|
1018
|
+
// en: Install the loop-safe `stop-hook` subcommand so lint:sparkle becomes a
|
|
1019
|
+
// hard gate. Generic assistant has no hook target.
|
|
926
1020
|
const hook = runAssistantHook(assistant, cwd, guard.target, dryRun);
|
|
927
1021
|
const generate = runGenerate({
|
|
928
1022
|
skipGenerate: Boolean(options.skipGenerate),
|
|
@@ -1045,10 +1139,12 @@ function printPostSetupReminder(target, packageManager, { hook, legacyCursorGuar
|
|
|
1045
1139
|
? '(既存のまま)'
|
|
1046
1140
|
: hook.reason === 'created'
|
|
1047
1141
|
? '(新規作成)'
|
|
1048
|
-
: '
|
|
1142
|
+
: hook.reason === 'migrated'
|
|
1143
|
+
? '(旧 || exit 2 形式から自動移行)'
|
|
1144
|
+
: '(既存設定に追記)';
|
|
1049
1145
|
lines.push(
|
|
1050
|
-
` 4. ${meta.name} の ${meta.event} hook を ${meta.file} に設定しました ${label}。応答を終える直前に \`
|
|
1051
|
-
` Installed a ${meta.name} ${meta.event} hook in ${meta.file}: it runs \`
|
|
1146
|
+
` 4. ${meta.name} の ${meta.event} hook を ${meta.file} に設定しました ${label}。応答を終える直前に \`sparkle-design-cli stop-hook\` が走り、findings があれば 1 度だけ exit 2 で停止をブロックします(再発火は \`stop_hook_active\` を見て自動回避)。`,
|
|
1147
|
+
` Installed a ${meta.name} ${meta.event} hook in ${meta.file}: it runs \`sparkle-design-cli stop-hook\` before the turn ends, blocks once with exit 2 when findings exist, and skips re-blocking on stop_hook_active.`
|
|
1052
1148
|
);
|
|
1053
1149
|
if (hook.featureFlagNote) {
|
|
1054
1150
|
lines.push(` ⚠️ ${hook.featureFlagNote}`);
|
package/lib/stop-hook.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { checkProject } from './check.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AI assistant の Stop / stop hook 用エントリポイント。
|
|
6
|
+
*
|
|
7
|
+
* 単に `lint:sparkle --strict || exit 2` を毎回走らせると、Claude Code
|
|
8
|
+
* のように exit 2 を「もう一度ターンを継続させる」シグナルとして解釈する
|
|
9
|
+
* agent では、findings が残っている限り Stop hook が再発火し続けて
|
|
10
|
+
* セッションが無限ループに陥る。
|
|
11
|
+
*
|
|
12
|
+
* Claude Code は再発火時に stdin JSON へ `stop_hook_active: true` を
|
|
13
|
+
* 渡してくるので、そのフラグが立っていたらここで block せず exit 0 で
|
|
14
|
+
* 抜ける。これで「最初の 1 回だけ exit 2 で停止をブロックして findings
|
|
15
|
+
* を通知し、以降はユーザーの判断に委ねる」フローになる。
|
|
16
|
+
*
|
|
17
|
+
* en: Run `check --strict` once per session to surface findings, but stop
|
|
18
|
+
* blocking on subsequent invocations within the same Stop hook chain.
|
|
19
|
+
* Claude Code sets `stop_hook_active: true` on re-fires so the hook can
|
|
20
|
+
* exit cleanly instead of looping forever (see Claude Code hooks docs).
|
|
21
|
+
*/
|
|
22
|
+
export async function runStopHook(target) {
|
|
23
|
+
const payload = readStdinJsonSafely();
|
|
24
|
+
|
|
25
|
+
if (payload && payload.stop_hook_active === true) {
|
|
26
|
+
process.stderr.write(
|
|
27
|
+
'sparkle-design-cli stop-hook: stop_hook_active=true を検知したため再ブロックしません。' +
|
|
28
|
+
'前回の exit 2 で findings は通知済みです。' +
|
|
29
|
+
' / Detected stop_hook_active=true; not re-blocking. Findings were already surfaced on the first call.\n'
|
|
30
|
+
);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const targets = target ? [target] : [];
|
|
35
|
+
const hasFindings = await checkProject(targets, { strict: true, format: 'text' });
|
|
36
|
+
|
|
37
|
+
if (hasFindings) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
'\nsparkle-design-cli stop-hook: findings を検出したため応答終了を 1 度だけブロックしました。' +
|
|
40
|
+
'上記の findings を修正するか、対応しない判断であればユーザーに確認してから再度応答を完了してください。' +
|
|
41
|
+
' / Blocked once because findings were detected. Fix them or confirm with the user before completing the response again.\n'
|
|
42
|
+
);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readStdinJsonSafely() {
|
|
50
|
+
// stdin が tty / 空 / 非 JSON の場合は「初回呼び出し」として扱う。
|
|
51
|
+
// en: Treat missing or non-JSON stdin as a first invocation.
|
|
52
|
+
try {
|
|
53
|
+
if (process.stdin.isTTY) return null;
|
|
54
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
55
|
+
if (!raw || !raw.trim()) return null;
|
|
56
|
+
return JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkle-design-cli",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Sparkle Design CLI — プロジェクトセットアップ、CSS・フォント生成、アンチパターン検査、AI エージェント(Claude Code / Cursor / Codex)向けのガードと hook 設定まで一括で行う sparkle-design 公式 CLI。",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "lib/generate-css.js",
|
|
10
10
|
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/generate-css.js",
|
|
13
|
+
"./plugin": "./lib/plugin-api.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
11
16
|
"bin": {
|
|
12
17
|
"sparkle-design-cli": "./bin/sparkle-design.js"
|
|
13
18
|
},
|