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 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 の導入に必要な作業をまとめて行います:
@@ -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 sparkle.config.json から CSS を生成
139
- check Sparkle Design のアンチパターンを検査
140
- setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
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 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() {
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 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/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 check <target> --strict || exit 2`
598
- * - exit 2 にエスカレーションすることで、ブロック対応する agent では
599
- * 応答終了を止めて findings 修正に向かわせる
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 `lint:sparkle --strict || exit 2`.
605
- * Each agent ships its own hook config format; these helpers emit the right
606
- * shape while preserving existing user content and staying idempotent on rerun.
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
- return `npx --yes sparkle-design-cli check ${target} --strict || exit 2`;
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
- const alreadyPresent = stopGroups.some(
689
- (group) =>
690
- Array.isArray(group?.hooks) &&
691
- group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
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
- stopGroups.push({
708
- hooks: [{ type: 'command', command: managedCommand }],
709
- });
710
- hooks.Stop = stopGroups;
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
- const alreadyPresent = stopEntries.some((entry) => entry?.command === managedCommand);
744
- if (alreadyPresent) {
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
- stopEntries.push({ command: managedCommand });
756
- hooks.stop = stopEntries;
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
- const alreadyPresent = stopGroups.some(
790
- (group) =>
791
- Array.isArray(group?.hooks) &&
792
- group.hooks.some((entry) => entry?.type === 'command' && entry?.command === managedCommand)
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
- // 有効化にはユーザー側の opt-in が必要な旨を summary に載せる。
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
- stopGroups.push({
811
- hooks: [{ type: 'command', command: managedCommand }],
812
- });
813
- hooks.Stop = stopGroups;
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`)に `lint:sparkle --strict` を
922
- // 走らせる stop / Stop hook を自動設定する。instruction 頼みではなく hook
923
- // で強制することで lint:sparkle の実行漏れを防ぐ。generic は hook なし。
924
- // en: For supported assistants, install a stop hook so `lint:sparkle --strict`
925
- // becomes a hard gate. Generic assistant has no hook target.
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}。応答を終える直前に \`lint:sparkle --strict\` が走り、findings があれば exit 2 で停止がブロックされます。`,
1051
- ` Installed a ${meta.name} ${meta.event} hook in ${meta.file}: it runs \`lint:sparkle --strict\` before the turn ends and exits with code 2 when findings exist.`
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}`);
@@ -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.7",
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
  },