sparkle-design-cli 2.0.8 → 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 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 の導入に必要な作業をまとめて行います:
@@ -4,8 +4,10 @@ import { generateCSS } from '../lib/generate-css.js';
4
4
  import { checkProject } from '../lib/check.js';
5
5
  import { setupAssistant } from '../lib/setup.js';
6
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';
7
9
 
8
- const SUBCOMMANDS = new Set(['generate', 'check', 'setup', 'stop-hook']);
10
+ const SUBCOMMANDS = new Set(['generate', 'check', 'setup', 'stop-hook', 'plugin-spec']);
9
11
 
10
12
  function requireOptionValue(args, index, flags) {
11
13
  const value = args[index + 1];
@@ -136,10 +138,11 @@ Sparkle Design CLI
136
138
  sparkle-design-cli <command> [options]
137
139
 
138
140
  Commands:
139
- generate sparkle.config.json から CSS を生成
140
- check Sparkle Design のアンチパターンを検査
141
- setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
142
- stop-hook AI assistant の Stop hook 用 internal subcommand(findings 検出時に exit 2 で 1 度だけ停止をブロック / 再発火は自動回避)
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 で導入済みプラグインを表示)
143
146
 
144
147
  Generate:
145
148
  sparkle-design-cli generate
@@ -153,6 +156,10 @@ Check:
153
156
  sparkle-design-cli check src --format json
154
157
  sparkle-design-cli check src/components src/features
155
158
 
159
+ Plugin spec:
160
+ sparkle-design-cli plugin-spec # アンチパターン拡張プラグインの契約仕様を出力
161
+ sparkle-design-cli plugin-spec --list # 現在のプロジェクトで発見されたプラグインを表示
162
+
156
163
  Setup:
157
164
  sparkle-design-cli setup # フルセットアップ(推奨・新規導入時)
158
165
  sparkle-design-cli setup --assistant claude # Claude 向けガードも同時にセットアップ
@@ -250,7 +257,31 @@ generate で生成されるファイル:
250
257
  `);
251
258
  }
252
259
 
253
- function main() {
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() {
254
285
  const args = process.argv.slice(2);
255
286
  const command = args[0];
256
287
 
@@ -282,7 +313,7 @@ function main() {
282
313
  process.exit(0);
283
314
  }
284
315
 
285
- const hasFindings = checkProject(options.targets, {
316
+ const hasFindings = await checkProject(options.targets, {
286
317
  strict: options.strict,
287
318
  format: options.format,
288
319
  });
@@ -310,10 +341,15 @@ function main() {
310
341
  // en: Internal subcommand invoked by agent stop hooks. The single positional
311
342
  // arg is the lint target path determined at setup time.
312
343
  const target = args[1];
313
- const exitCode = runStopHook(target);
344
+ const exitCode = await runStopHook(target);
314
345
  process.exit(exitCode);
315
346
  }
316
347
 
348
+ if (command === 'plugin-spec') {
349
+ await runPluginSpec(args.slice(1));
350
+ return;
351
+ }
352
+
317
353
  // 未知のコマンド: エラー終了ではなく note + help 表示にする(typo 時の迷子を防ぐ)
318
354
  // en: Unknown command: show a short note + help instead of throwing
319
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 MANUAL_REVIEW_REMINDERS = [
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 ANTI_PATTERN_GROUPS = [
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
- function getCheckRules() {
1204
- const order = [
1205
- 'dialog-form',
1206
- 'dialog-button-wrap',
1207
- 'button-icon-only',
1208
- 'material-symbols-direct',
1209
- 'shadcn-token',
1210
- 'tailwind-typography',
1211
- 'card-title-typography',
1212
- 'card-control-non-button',
1213
- 'card-padding-override',
1214
- 'aschild-with-icon-props',
1215
- 'disabled-vs-is-disabled',
1216
- 'button-prefixicon-jsx',
1217
- 'icon-children-text',
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
- return ANTI_PATTERN_GROUPS.filter((group) => group.check)
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 = order.indexOf(left.id);
1227
- const rightIndex = order.indexOf(right.id);
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 ANTI_PATTERN_GROUPS.flatMap((group) => group.jsdocTargets);
1238
+ function getAllJSDocTargets(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
1239
+ return groups.flatMap((group) => group.jsdocTargets ?? []);
1236
1240
  }
1237
1241
 
1238
- function renderFeatureSections() {
1239
- return ANTI_PATTERN_GROUPS.map((group) => group.featureSection).join('\n\n');
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 MANUAL_REVIEW_REMINDERS;
1249
+ function getManualReviewReminders(reminders = BUILTIN_MANUAL_REVIEW_REMINDERS) {
1250
+ return reminders;
1244
1251
  }
1245
1252
 
1246
1253
  export {
1247
- ANTI_PATTERN_GROUPS,
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 { getCheckRules, getManualReviewReminders } from './anti-pattern-rules.js';
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
- const RULES = getCheckRules();
11
- const BASE_MANUAL_REVIEW_REMINDERS = getManualReviewReminders();
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 = /(?:\/\/|\/\*|\{\/\*)\s*sparkle-disable-line\s+([A-Za-z0-9,\s_-]+?)\s*(?:\*\/\}?|$)/;
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 RULES) {
150
- if (typeof rule.match === 'function') {
151
- // rule.match(content) -> Array<{ index: number, text: string }>
152
- // 複雑な照合が必要なルール向けの opt-in API。2-pass 方式などで regex 単体の
153
- // backtracking リスクを回避したいときに使う。index は content 内の絶対オフセット。
154
- for (const hit of rule.match(content)) {
155
- pushFinding(rule, hit.index ?? 0, formatSnippet(hit.text ?? ''));
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
- continue;
158
- }
159
- for (const match of content.matchAll(rule.pattern)) {
160
- pushFinding(rule, match.index ?? 0, formatMatch(match));
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 = [...BASE_MANUAL_REVIEW_REMINDERS];
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
- 'These are judgment calls the linter cannot detect. For every item below,'
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
- const report = createCheckReport(targets);
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
- BASE_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
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/stop-hook.js CHANGED
@@ -19,7 +19,7 @@ import { checkProject } from './check.js';
19
19
  * Claude Code sets `stop_hook_active: true` on re-fires so the hook can
20
20
  * exit cleanly instead of looping forever (see Claude Code hooks docs).
21
21
  */
22
- export function runStopHook(target) {
22
+ export async function runStopHook(target) {
23
23
  const payload = readStdinJsonSafely();
24
24
 
25
25
  if (payload && payload.stop_hook_active === true) {
@@ -32,7 +32,7 @@ export function runStopHook(target) {
32
32
  }
33
33
 
34
34
  const targets = target ? [target] : [];
35
- const hasFindings = checkProject(targets, { strict: true, format: 'text' });
35
+ const hasFindings = await checkProject(targets, { strict: true, format: 'text' });
36
36
 
37
37
  if (hasFindings) {
38
38
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.0.8",
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
  },