sparkle-design-cli 2.1.0 → 2.2.1

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
@@ -209,6 +209,46 @@ export default defineAntiPatternPlugin({
209
209
  });
210
210
  ```
211
211
 
212
+ ##### 複雑な検出(`match` とヘルパー)
213
+
214
+ 単純な正規表現では安全に書けない検出(accessible name の有無、prop の組み合わせ、JSX 式を意識した走査など)には、`check.pattern` の代わりに `check.match` を使います。`match` は `(content, helpers)` で呼ばれ、第 2 引数の `helpers` に JSX パースヘルパーが **CLI から注入** されます。各パッケージがパース処理を再実装して同じ false-positive / negative を踏むのを防ぐ仕組みです。
215
+
216
+ ```js
217
+ // sparkle-design-cli を import しない(plain object を default export)
218
+ export default {
219
+ groups: [
220
+ {
221
+ id: 'your-org-avatar-accessible-name',
222
+ check: {
223
+ description: 'Avatar に accessible name がありません。',
224
+ recommendation: 'aria-label か alt を指定してください。',
225
+ // helpers = { matchOpeningTags, hasProp, isMultipleTypeProp, findOpeningTagEnd }
226
+ match: (content, { matchOpeningTags, hasProp }) =>
227
+ matchOpeningTags(
228
+ content,
229
+ 'Avatar',
230
+ (props) =>
231
+ !hasProp(props, 'src') &&
232
+ !hasProp(props, 'aria-label') &&
233
+ !hasProp(props, 'aria-labelledby')
234
+ ),
235
+ },
236
+ },
237
+ ],
238
+ };
239
+ ```
240
+
241
+ 注入されるヘルパー(`check.match` の第 2 引数):
242
+
243
+ | ヘルパー | 用途 |
244
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------ |
245
+ | `matchOpeningTags(content, tagName, predicate)` | `<Tag ...>` / `<Tag ... />` を走査し predicate が真のものを `{ index, text }` で返す |
246
+ | `hasProp(propsBlock, propName)` | prop が指定されているかを厳密判定(`aria-*` / `data-*` を誤検出しない) |
247
+ | `isMultipleTypeProp(propsBlock)` | `type` が静的に `"multiple"` か判定(動的式は `false`) |
248
+ | `findOpeningTagEnd(content, startIdx)` | 低レベル: 開きタグの閉じ `>` のオフセット(文字列 / JSX 式を考慮)。無ければ `-1` |
249
+
250
+ > 💡 `match` を使うプラグインは(上の `pattern` 例の `defineAntiPatternPlugin` import と違い)`sparkle-design-cli` を **import せず** plain object として default export してください。`helpers` はランタイムで CLI が注入するため import は不要で、生 JS のまま配布され npx 経由で実行される consumer 環境でも確実に解決されます。`pattern` と `match` は排他で、どちらか一方のみ指定します。
251
+
212
252
  ##### 自動 discovery
213
253
 
214
254
  `sparkle-design-cli check` 実行時、CLI は consumer プロジェクトの `package.json` の `dependencies` / `devDependencies` / `peerDependencies` / `optionalDependencies` を走査し、`sparkleCli.antiPatterns` を宣言しているパッケージを発見次第ロードしてビルトインのルールにマージします。
@@ -174,7 +174,9 @@ Setup:
174
174
  Generate options:
175
175
  -h, --help このヘルプメッセージを表示
176
176
  -c, --config <path> 設定ファイルのパス (default: ./sparkle.config.json)
177
- -o, --output <path> 出力ファイルのパス (default: ./src/app/sparkle-design.css)
177
+ -o, --output <path> 出力ファイルのパス
178
+ (default: globals-path 指定時はその entry CSS と同じ
179
+ ディレクトリ、未指定時は ./src/app/sparkle-design.css)
178
180
  --globals-path <path> Tailwind エントリポイント CSS のパス (default: 自動検出)
179
181
  --strict globals.css への @source 注入等が失敗したら exit 1
180
182
  (CI 向け。既定は warn + 継続で後方互換を維持)
package/lib/check.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  getManualReviewReminders,
9
9
  } from './anti-pattern-rules.js';
10
10
  import { loadAntiPatternPlugins } from './load-plugins.js';
11
+ import { MATCH_HELPERS } from './plugin-helpers.js';
11
12
  import { REGEX, FONT_DOMAINS } from './constants.js';
12
13
 
13
14
  const DEFAULT_TARGET = 'src';
@@ -163,10 +164,16 @@ function collectFindings(filePath, content, rules = BUILTIN_RULES) {
163
164
  // type) must not crash the whole check pipeline.
164
165
  try {
165
166
  if (typeof rule.match === 'function') {
166
- // rule.match(content) -> Array<{ index: number, text: string }>
167
+ // rule.match(content, helpers) -> Array<{ index: number, text: string }>
167
168
  // 複雑な照合が必要なルール向けの opt-in API。2-pass 方式などで regex 単体の
168
169
  // backtracking リスクを回避したいときに使う。index は content 内の絶対オフセット。
169
- const hits = rule.match(content);
170
+ // 第 2 引数 helpers (MATCH_HELPERS) で JSX パースヘルパー(matchOpeningTags /
171
+ // hasProp / findOpeningTagEnd / isMultipleTypeProp)を注入し、各プラグインが
172
+ // 同じパース実装を再実装して同じ false-positive/negative を踏むのを防ぐ。
173
+ // 第 2 引数を無視する既存の match(content) も後方互換でそのまま動く。
174
+ // en: helpers (2nd arg) injects shared JSX parsing utilities so plugins don't
175
+ // re-derive the regex edge cases. Existing match(content) stays compatible.
176
+ const hits = rule.match(content, MATCH_HELPERS);
170
177
  if (!Array.isArray(hits)) {
171
178
  throw new TypeError(`rule.match must return an array, got ${typeof hits}`);
172
179
  }
@@ -259,9 +259,10 @@ function reconstructGlobalsCss(globalsContent, sparkleImportBlock, tailwindInfo)
259
259
  * @param {string} globalsPath globals.cssのパス
260
260
  * @param {Array<string>|null} sourcePackages 追加パッケージ名の配列(null の場合は @source を生成しない)
261
261
  * @param {string|null} customCssPath custom-css ファイルの相対パス
262
- * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string }}
262
+ * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string, globalsPath?: string, sourcePackages?: Array<string>|null }}
263
263
  * skipped: 触る理由がない (fonts/sourcePackages/customCss すべて空)
264
- * updated: globals.css を正常に書き換えた
264
+ * updated: globals.css を正常に書き換えた(patch した globalsPath と挿入した
265
+ * sourcePackages を併せて返す — issue #56 のサマリ表示用)
265
266
  * failed: 書き換え対象だったが失敗した (TAILWIND_IMPORT 欠落、write 失敗等)
266
267
  */
267
268
  export function updateGlobalsWithFonts(
@@ -342,7 +343,11 @@ export function updateGlobalsWithFonts(
342
343
  // 6. 更新したglobals.cssを書き込む
343
344
  fs.writeFileSync(globalsPath, reconstructedContent, 'utf8');
344
345
  console.log(MESSAGES.GLOBALS_UPDATED(globalsPath));
345
- return { status: 'updated' };
346
+ // `globalsPath` / `sourcePackages` は generate のサマリ出力(issue #56)が
347
+ // 「どの entry CSS に何の @source を挿入したか」を表示するために返す。
348
+ // en: Surface which entry CSS was patched and with which @source packages
349
+ // so the caller can render the generate summary (issue #56).
350
+ return { status: 'updated', globalsPath, sourcePackages };
346
351
  } catch (error) {
347
352
  // `E_UNSAFE_RELATIVE_PATH`(custom-css の path traversal 等)や
348
353
  // `E_TAILWIND_IMPORT_PREPEND_FAILED`(内部不整合)は silent fallback
@@ -564,7 +569,9 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
564
569
  * @param {string|null} customCssPath custom-css ファイルの相対パス
565
570
  * @param {string|null} globalsPathOverride 明示的に指定された globals パス
566
571
  * @param {{ strict?: boolean }} [options] strict=true のとき失敗を throw する
567
- * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string }}
572
+ * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string, globalsPath?: string, sourcePackages?: Array<string>|null }}
573
+ * updated のときは patch した entry CSS の globalsPath と挿入した sourcePackages
574
+ * を含む(issue #56 の generate サマリ用)。
568
575
  */
569
576
  export function manageFontImports(
570
577
  sparkleDesignPath,
@@ -23,6 +23,7 @@ import {
23
23
  updateGlobalsWithFonts,
24
24
  isViteProject,
25
25
  } from './font-manager.js';
26
+ import { assertSafeRelativePath, toPosixPath } from './path-utils.js';
26
27
 
27
28
  /**
28
29
  * config の extend セクションを解決する
@@ -344,6 +345,7 @@ function writeSparkleHead(content, sparkleDesignCssPath) {
344
345
  fs.writeFileSync(outputPath, content, 'utf8');
345
346
  console.log(`✅ SparkleHead.tsx を生成しました: ${outputPath}`);
346
347
  console.log(' → ルートレイアウトの <head> 内に <SparkleHead /> を追加してください');
348
+ return outputPath;
347
349
  }
348
350
 
349
351
  const VITE_INDEX_HTML_BLOCK_START = '<!-- sparkle-design-cli:fonts:start -->';
@@ -602,6 +604,73 @@ function detectSourcePackagesFromPackageJson(cwd = process.cwd()) {
602
604
  return null;
603
605
  }
604
606
 
607
+ /**
608
+ * `--output` 未指定時に、明示された globals-path(CLI `--globals-path` または
609
+ * `extend.globals-path`)と同じディレクトリを出力先に使えるか判定する。
610
+ *
611
+ * 以前は framework に依らず常に `DEFAULT_OUTPUT_DIR = src/app/` に書き出していた
612
+ * ため、Vite の `src/index.css` 構成で `globals-path: src/index.css` を指定しても
613
+ * `sparkle-design.css` が `src/app/` に落ち、本来不要な `src/app/` ディレクトリが
614
+ * 生成されてレビュー / クリーンアップを混乱させていた(issue #56 確定バグ)。
615
+ *
616
+ * - globals-path が未指定なら null(従来どおり src/app/ を使う)
617
+ * - 不正な path(traversal / shell メタ文字 / 絶対パス)は `assertSafeRelativePath`
618
+ * が throw(後段 `resolveGlobalsPath` でも同様に弾かれるので早期 fail-loud)
619
+ * - 解決した path が実在しなければ null。実在しない globals-path は
620
+ * `manageFontImports` が `E_EXPLICIT_GLOBALS_PATH_NOT_FOUND` で loud に throw
621
+ * するので、ここで typo 由来の誤ディレクトリを掘らないよう存在チェックする
622
+ * - 実在しても**通常ファイルでない**(ディレクトリ等)なら null。例えば
623
+ * `globals-path: src` のようにディレクトリを誤指定すると、存在チェックだけ
624
+ * では通過し `path.dirname('.../src')` がプロジェクト root を指して出力先が
625
+ * root に誤誘導される。entry CSS は CSS ファイル前提なので `isFile()` で弾く。
626
+ *
627
+ * en: When `--output` is omitted, return the directory of an explicit
628
+ * globals-path so sparkle-design.css lands next to the entry CSS instead of
629
+ * always under src/app/. Returns null (→ keep src/app/ default) when no
630
+ * globals-path is given, it doesn't exist yet, or it isn't a regular file
631
+ * (e.g. a directory was passed by mistake).
632
+ *
633
+ * @param {string|null} globalsPathOverride
634
+ * @returns {string|null} entry CSS が通常ファイルとして実在する場合その絶対ディレクトリ、なければ null
635
+ */
636
+ function resolveGlobalsDirForOutput(globalsPathOverride) {
637
+ if (!globalsPathOverride) {
638
+ return null;
639
+ }
640
+ assertSafeRelativePath(globalsPathOverride, 'globals-path');
641
+ const resolved = path.resolve(process.cwd(), globalsPathOverride);
642
+ let stat;
643
+ try {
644
+ stat = fs.statSync(resolved);
645
+ } catch {
646
+ // 実在しない(ENOENT 等)。出力先は src/app/ 既定に委ね、entry CSS の
647
+ // not-found は manageFontImports が loud に throw する。
648
+ return null;
649
+ }
650
+ if (!stat.isFile()) {
651
+ return null;
652
+ }
653
+ return path.dirname(resolved);
654
+ }
655
+
656
+ /**
657
+ * sparkle-design.css の出力先絶対パスを決定する。
658
+ * 優先順位: `--output` 明示 > globals-path と同じディレクトリ > `src/app/`(既定)
659
+ * @param {string|null} outputPath `--output` の値
660
+ * @param {string|null} globalsPathOverride globals-path(CLI > config)
661
+ * @returns {string} 出力先の絶対パス
662
+ */
663
+ function resolveOutputPath(outputPath, globalsPathOverride) {
664
+ if (outputPath) {
665
+ return path.resolve(outputPath);
666
+ }
667
+ const globalsDir = resolveGlobalsDirForOutput(globalsPathOverride);
668
+ if (globalsDir) {
669
+ return path.join(globalsDir, 'sparkle-design.css');
670
+ }
671
+ return path.resolve(process.cwd(), ...PATHS.DEFAULT_OUTPUT_DIR, 'sparkle-design.css');
672
+ }
673
+
605
674
  function writeCSS(cssContent, outputPath = null) {
606
675
  // カスタムパスが指定されていない場合は実行場所からsrc/appディレクトリに出力
607
676
  const defaultOutputPath = path.resolve(
@@ -626,6 +695,79 @@ function writeCSS(cssContent, outputPath = null) {
626
695
  }
627
696
  }
628
697
 
698
+ /**
699
+ * `@source` ブロックに実際に書き出されるパッケージ名一覧を組み立てる。
700
+ * `createSourceBlock`(font-manager.js)と同じく default の `sparkle-design` を
701
+ * 先頭に入れ、重複を除いた配列を返す。サマリ表示専用。
702
+ * en: Mirror createSourceBlock's package list (default sparkle-design first)
703
+ * for the summary output.
704
+ * @param {Array<string>|null|undefined} sourcePackages
705
+ * @returns {Array<string>|null} null は @source 未挿入
706
+ */
707
+ function describeInsertedSourcePackages(sourcePackages) {
708
+ if (sourcePackages === null || sourcePackages === undefined) {
709
+ return null;
710
+ }
711
+ const defaultPackage = 'sparkle-design';
712
+ return [defaultPackage, ...sourcePackages.filter((p) => p !== defaultPackage)];
713
+ }
714
+
715
+ /**
716
+ * generate 完了時に「何を・どこに書いたか」を 1 ブロックで要約出力する。
717
+ * issue #56 の可観測性要求への対応。とくに entry CSS と出力先のディレクトリが
718
+ * 乖離している場合(Vite で `src/index.css` を patch しつつ出力が `src/app/` に
719
+ * 落ちる等)に警告を出し、`@source` が想定どおり挿入されたかを目視確認できる
720
+ * ようにする。
721
+ * en: Print a one-block summary of what `generate` patched / wrote and warn
722
+ * when the entry CSS dir and the output dir diverge (issue #56 observability).
723
+ *
724
+ * @param {Object} args
725
+ * @param {string} args.outputPath sparkle-design.css の絶対パス
726
+ * @param {string} args.sparkleHeadPath SparkleHead.tsx の絶対パス
727
+ * @param {{ status: string, reason?: string, globalsPath?: string, sourcePackages?: Array<string>|null }} args.globalsResult
728
+ */
729
+ function printGenerateSummary({ outputPath, sparkleHeadPath, globalsResult }) {
730
+ const cwd = process.cwd();
731
+ const rel = (p) => (p ? path.relative(cwd, p) || '.' : null);
732
+ const lines = ['', '📋 生成サマリ (issue #56):'];
733
+ lines.push(` • sparkle-design.css: ${rel(outputPath)}`);
734
+ lines.push(` • SparkleHead.tsx: ${rel(sparkleHeadPath)}`);
735
+
736
+ if (globalsResult.status === 'updated' && globalsResult.globalsPath) {
737
+ const entryPath = globalsResult.globalsPath;
738
+ lines.push(` • パッチした entry CSS: ${rel(entryPath)}`);
739
+
740
+ const inserted = describeInsertedSourcePackages(globalsResult.sourcePackages);
741
+ if (inserted && inserted.length > 0) {
742
+ lines.push(` • 挿入した @source: ${inserted.join(', ')}`);
743
+ } else {
744
+ lines.push(' • 挿入した @source: なし(source-packages 未指定)');
745
+ }
746
+
747
+ const entryDir = path.dirname(entryPath);
748
+ const outputDir = path.dirname(outputPath);
749
+ if (path.resolve(entryDir) === path.resolve(outputDir)) {
750
+ lines.push(` • entry CSS と出力先 dir: 一致 (${rel(entryDir)})`);
751
+ } else {
752
+ const suggestedOutput = toPosixPath(path.join(rel(entryDir), 'sparkle-design.css'));
753
+ lines.push(
754
+ ` ⚠️ entry CSS dir (${rel(entryDir)}) と出力先 dir (${rel(outputDir)}) が異なります。`
755
+ );
756
+ lines.push(
757
+ ` @import は相対パスで解決されるため動作はしますが、出力先を揃えるには ` +
758
+ `--output ${suggestedOutput} の指定、または extend.globals-path と同じ ` +
759
+ `ディレクトリへの出力を検討してください。`
760
+ );
761
+ }
762
+ } else if (globalsResult.status === 'skipped') {
763
+ lines.push(` • entry CSS パッチ: スキップ (${globalsResult.reason ?? 'no-work'})`);
764
+ } else if (globalsResult.status === 'failed') {
765
+ lines.push(` • entry CSS パッチ: ❌ 失敗 (${globalsResult.reason ?? 'unknown'})`);
766
+ }
767
+
768
+ console.log(lines.join('\n'));
769
+ }
770
+
629
771
  /**
630
772
  * メイン処理
631
773
  * @param {string|null} configPath カスタム設定ファイルのパス(オプション)
@@ -666,18 +808,21 @@ export function generateCSS(
666
808
  colors
667
809
  );
668
810
 
811
+ // globals-path(CLI `--globals-path` > `extend.globals-path`)を先に解決する。
812
+ // 出力先デフォルトの決定とフォント管理(@source 挿入)の両方で同じ値を使う。
813
+ // en: Resolve globals-path once (CLI over config); both the default output
814
+ // location and the font-import patcher consume it.
815
+ const globalsPathOverride = globalsPath || config['globals-path'] || null;
816
+
669
817
  // 6. CSSファイルを書き出し
670
- const defaultOutputPath = path.resolve(
671
- process.cwd(),
672
- ...PATHS.DEFAULT_OUTPUT_DIR,
673
- 'sparkle-design.css'
674
- );
675
- const resolvedOutputPath = outputPath ? path.resolve(outputPath) : defaultOutputPath;
676
- writeCSS(processedCSS, outputPath);
818
+ // `--output` 未指定時は globals-path と同じディレクトリ(実在する場合)に出す。
819
+ // 詳細は resolveOutputPath / resolveGlobalsDirForOutput の comment 参照(#56)。
820
+ const resolvedOutputPath = resolveOutputPath(outputPath, globalsPathOverride);
821
+ writeCSS(processedCSS, resolvedOutputPath);
677
822
 
678
823
  // 7. SparkleHead.tsx を生成(processTemplate で解決済みの resolvedFonts を再利用)
679
824
  const sparkleHeadContent = generateSparkleHeadContent(resolvedFonts);
680
- writeSparkleHead(sparkleHeadContent, resolvedOutputPath);
825
+ const sparkleHeadPath = writeSparkleHead(sparkleHeadContent, resolvedOutputPath);
681
826
 
682
827
  // 7.5 Vite プロジェクトの index.html に Sparkle のフォント <link> タグを upsert
683
828
  // する。Vite は <head> が index.html 側にあるため、React コンポーネントの
@@ -740,7 +885,6 @@ export function generateCSS(
740
885
  ? [...new Set([...(detectedPackages ?? []), ...(explicitPackages ?? [])])]
741
886
  : null;
742
887
  const customCssPath = config['custom-css'] || null;
743
- const globalsPathOverride = globalsPath || config['globals-path'] || null;
744
888
  const globalsResult = manageFontImports(
745
889
  resolvedOutputPath,
746
890
  sourcePackages,
@@ -749,6 +893,11 @@ export function generateCSS(
749
893
  { strict: Boolean(options.strict) }
750
894
  );
751
895
 
896
+ // 9. 生成サマリ(可観測性 / issue #56)。何を・どこに書いたか、@source が
897
+ // 挿入されたか、entry CSS と出力先の dir が乖離していないかを 1 ブロックで
898
+ // 出力する。設定は正しいのに @source が silent に落ちる事象を即検知できる。
899
+ printGenerateSummary({ outputPath: resolvedOutputPath, sparkleHeadPath, globalsResult });
900
+
752
901
  console.log(MESSAGES.SUCCESS);
753
902
  return { globalsResult };
754
903
  }
package/lib/plugin-api.js CHANGED
@@ -31,11 +31,31 @@
31
31
  * recommendation: string, // shown in `check` findings
32
32
  * pattern?: RegExp, // simple regex check. MUST have the global (`g`) flag.
33
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.)
34
+ * match?: (content: string, helpers: MatchHelpers) => Array<{ index: number, text: string }>,
35
+ * // opt-in API for complex matching (2-pass, AST, etc.).
36
+ * // `helpers` (2nd arg) is injected by the CLI — see ## MatchHelpers.
36
37
  * },
37
38
  * }
38
39
  *
40
+ * ## MatchHelpers
41
+ *
42
+ * `check.match` receives a frozen `helpers` object as its second argument. Prefer these over
43
+ * hand-rolling JSX parsing — they are maintained and tested in the CLI, so plugins stay free of a
44
+ * `sparkle-design-cli` dependency (plugins ship as plain JS and are loaded by the CLI) and always
45
+ * run on the same parser as the CLI at runtime (no version skew across plugins).
46
+ *
47
+ * {
48
+ * // Iterate `<Tag ...>` / `<Tag ... />` openings; return `{ index, text }` for predicate hits.
49
+ * matchOpeningTags(content: string, tagName: string,
50
+ * predicate: (propsBlock: string) => boolean): Array<{ index: number, text: string }>,
51
+ * // Strict-equality check for a prop assignment (handles `aria-*` / `data-*` without `\b` bugs).
52
+ * hasProp(propsBlock: string, propName: string): boolean,
53
+ * // Statically resolve whether the `type` prop is "multiple" (dynamic exprs -> false).
54
+ * isMultipleTypeProp(propsBlock: string): boolean,
55
+ * // Low-level: offset of an opening tag's closing `>` (string / JSX-expr aware), or -1.
56
+ * findOpeningTagEnd(content: string, startIdx: number): number,
57
+ * }
58
+ *
39
59
  * ## JSDocTarget
40
60
  *
41
61
  * {
@@ -228,10 +248,45 @@ See the JSDoc in \`lib/plugin-api.js\` for the full type contract:
228
248
  - \`AntiPatternGroup.check\`: \`{ description, recommendation, pattern | match }\`
229
249
  - \`pattern\` is a RegExp and **must include the global flag** (\`/foo/g\`); otherwise
230
250
  the rule is rejected at load time.
251
+ - \`match\` is called as \`match(content, helpers)\`; \`helpers\` (matchOpeningTags / hasProp /
252
+ isMultipleTypeProp / findOpeningTagEnd) is injected by the CLI, so plugins need no import.
231
253
  - \`pattern\` and \`match\` are mutually exclusive — provide exactly one.
232
254
  - \`JSDocTarget\`: \`{ file, targetName, section: { bullets, example? } }\`
233
255
  - \`ManualReviewReminder\`: \`{ id, message }\`
234
256
 
257
+ ## Complex matching with injected helpers
258
+
259
+ For matches a single regex can't express safely (accessible-name presence, prop combinations,
260
+ JSX-expression-aware scanning), use \`check.match\` instead of \`check.pattern\`. \`match\` receives the
261
+ file \`content\` and a frozen \`helpers\` bundle as its **second argument**, so the plugin never imports
262
+ \`sparkle-design-cli\` — it ships as plain JS and the running CLI injects its own tested parser. This
263
+ keeps plugins dependency-free and guarantees every plugin uses the same parser at runtime.
264
+
265
+ \`\`\`js
266
+ // No import of sparkle-design-cli — default-export a plain object.
267
+ export default {
268
+ groups: [
269
+ {
270
+ id: 'internal-avatar-without-accessible-name',
271
+ check: {
272
+ description: 'Avatar に accessible name がありません。',
273
+ recommendation: 'aria-label か alt を指定してください。',
274
+ // helpers = { matchOpeningTags, hasProp, isMultipleTypeProp, findOpeningTagEnd }
275
+ match: (content, { matchOpeningTags, hasProp }) =>
276
+ matchOpeningTags(
277
+ content,
278
+ 'Avatar',
279
+ (props) =>
280
+ !hasProp(props, 'src') &&
281
+ !hasProp(props, 'aria-label') &&
282
+ !hasProp(props, 'aria-labelledby')
283
+ ),
284
+ },
285
+ },
286
+ ],
287
+ };
288
+ \`\`\`
289
+
235
290
  ## ID namespace
236
291
 
237
292
  Plugin rule IDs share a namespace with built-in rules and with each other. Choose a stable prefix
@@ -0,0 +1,165 @@
1
+ /**
2
+ * sparkle-design-cli が anti-pattern プラグインの `check.match` に注入する JSX パース
3
+ * ヘルパー群。プラグイン側はこれらを import せず、`match(content, helpers)` の第 2 引数
4
+ * として受け取る。npx 実行された CLI 自身が自分の実装を渡すため、プラグインパッケージは
5
+ * sparkle-design-cli への依存を一切持たずに済み(生 JS のまま配布できる)、かつ全プラグインが
6
+ * 常に実行中 CLI と同一のパース実装で動く(バージョンずれによる検出差が原理的に起きない)。
7
+ *
8
+ * ここに集約する狙いは、各プラグインが正規表現ベース JSX 解析の罠
9
+ * (文字列リテラル / JSX 式 `{}` のバランス、escape backslash、`\b` 境界)を再実装して
10
+ * 同じ false-positive / negative を踏むのを防ぐこと。修正履歴は下の各 JSDoc に残す。
11
+ *
12
+ * en: JSX parsing helpers injected into a plugin's `check.match` as the second argument.
13
+ * Plugins never import these — the running CLI passes its own implementation, so plugins
14
+ * stay dependency-free and always run on the same parser as the CLI (no version skew).
15
+ * Centralizing them keeps every plugin on one tested implementation instead of re-deriving
16
+ * the regex edge cases (string / JSX-expression `{}` balance, escaped backslashes, `\b`).
17
+ */
18
+
19
+ /**
20
+ * 開きタグの「最後の `>` の位置」を返すスキャナ。`[^>]*?` だと JSX prop 値中の
21
+ * `=>` / `>` / 子 JSX (`<X />`) で早期打ち切りになるため、文字列リテラル
22
+ * (`'`, `"`, `` ` ``)と JSX expression `{ ... }` の中括弧バランスを
23
+ * 意識した state machine で `>` を探す。
24
+ *
25
+ * 文字列内の closing quote は **直前の連続 backslash の数が偶数のときだけ**
26
+ * 終端として認める。`<Avatar title="C:\\" />` のように奇数個 backslash で
27
+ * 終わる場合に「閉じ quote が escape されたまま」と誤判定して全体を skip
28
+ * する false negative を防ぐ。
29
+ *
30
+ * en: Scanner returning the closing `>` of an opening JSX tag. Tracks string
31
+ * literals and `{}` nesting to avoid early termination on `=>` / `>` inside
32
+ * prop values or nested JSX. The closing quote is honored only when the count
33
+ * of preceding consecutive backslashes is even, so attribute values ending in
34
+ * a literal backslash (`"C:\\"`) don't make the scanner walk past EOF.
35
+ *
36
+ * @param {string} content
37
+ * @param {number} startIdx 走査開始オフセット(`<Tag` の直後を渡す)
38
+ * @returns {number} 開きタグを閉じる `>` の絶対オフセット。見つからなければ -1。
39
+ */
40
+ function findOpeningTagEnd(content, startIdx) {
41
+ let depth = 0;
42
+ let inStr = null;
43
+ for (let i = startIdx; i < content.length; i += 1) {
44
+ const ch = content[i];
45
+ if (inStr) {
46
+ if (ch === inStr) {
47
+ // 直前の連続 backslash 数を数える。偶数(0 含む)なら closing quote。
48
+ // en: Count preceding consecutive backslashes; even means unescaped.
49
+ let backslashes = 0;
50
+ for (let j = i - 1; j >= startIdx && content[j] === '\\'; j -= 1) {
51
+ backslashes += 1;
52
+ }
53
+ if (backslashes % 2 === 0) inStr = null;
54
+ }
55
+ continue;
56
+ }
57
+ if (ch === '"' || ch === "'" || ch === '`') {
58
+ inStr = ch;
59
+ continue;
60
+ }
61
+ if (ch === '{') depth += 1;
62
+ else if (ch === '}') depth -= 1;
63
+ else if (ch === '>' && depth === 0) return i;
64
+ }
65
+ return -1;
66
+ }
67
+
68
+ /**
69
+ * 与えられた tagName の開きタグ `<Tag ...>` / `<Tag ... />` を全て走査し、
70
+ * 各 propsBlock で `predicate` が真になったものを CLI の `rule.match` API
71
+ * 形式 `{ index, text }` で返す。
72
+ *
73
+ * anchor は `<Tag` の直後が `\s` / `/` / `>` のいずれかであることを要求するため、
74
+ * `<Tagged` / `<TagOther` / `<Tag.Image` のような名前の prefix 違いには hit しない。
75
+ * 一方で JS line comment 中の `// <Avatar />` のような疑似 JSX には素直に hit する
76
+ * が、ノイズは低く `// sparkle-disable-*` で個別に suppress できるため許容している。
77
+ *
78
+ * en: Iterate all `<Tag ...>` / `<Tag ... />` openings of the given tag name
79
+ * and call `predicate(propsBlock)` for each. Returns `{ index, text }` items
80
+ * for hits in the shape expected by the CLI's `rule.match` API. The anchor
81
+ * requires `\s` / `/` / `>` right after `<Tag` so prefix-different names like
82
+ * `<Tagged` or `<Tag.Image` are not picked up. JS line comments aren't filtered
83
+ * out — `// sparkle-disable-*` covers that escape hatch.
84
+ *
85
+ * @param {string} content
86
+ * @param {string} tagName 例: `'Avatar'`
87
+ * @param {(propsBlock: string) => boolean} predicate propsBlock を受け取り hit 判定を返す
88
+ * @returns {Array<{ index: number, text: string }>}
89
+ */
90
+ function matchOpeningTags(content, tagName, predicate) {
91
+ const anchor = new RegExp(`<${tagName}(?=[\\s/>])`, 'g');
92
+ const hits = [];
93
+ for (const opener of content.matchAll(anchor)) {
94
+ const start = opener.index ?? 0;
95
+ const tagBodyStart = start + opener[0].length;
96
+ const end = findOpeningTagEnd(content, tagBodyStart);
97
+ if (end === -1) continue;
98
+ const propsBlock = content.slice(tagBodyStart, end);
99
+ if (predicate(propsBlock)) {
100
+ hits.push({ index: start, text: `<${tagName}${propsBlock}>` });
101
+ }
102
+ }
103
+ return hits;
104
+ }
105
+
106
+ /**
107
+ * propsBlock 内に prop 名 `propName` が代入されているかを厳密一致で判定する。
108
+ *
109
+ * 旧実装の `\b${propName}\s*=` は `\b` が letter と `-` の境界でも発火するため
110
+ * - `hasProp(..., 'label')` が `aria-label=` を拾う(label 未指定の見逃し)
111
+ * - `hasProp(..., 'initials')` が `data-initials=` を拾う(initials なしを HIT)
112
+ * の両方向の bug を抱えていた。prop 名の直前を `^` か whitespace か `{`
113
+ * のいずれかでアンカーすることで両方解消する。
114
+ *
115
+ * en: Strict-equality check for a prop assignment in `propsBlock`. The previous
116
+ * `\b${propName}\s*=` was broken in both directions because `\b` fires between
117
+ * a letter and `-`. Anchoring the prop name at start-of-block or a non-identifier
118
+ * character (whitespace / `{`) fixes both `aria-label` ↔ `label` and
119
+ * `data-initials` ↔ `initials` confusions.
120
+ *
121
+ * @param {string} propsBlock matchOpeningTags が渡す開きタグ内部のテキスト
122
+ * @param {string} propName 探す prop 名(`aria-label` など `-` を含んでも可)
123
+ * @returns {boolean}
124
+ */
125
+ function hasProp(propsBlock, propName) {
126
+ const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
127
+ return new RegExp(`(?:^|[\\s{])${escaped}\\s*=`).test(propsBlock);
128
+ }
129
+
130
+ /**
131
+ * propsBlock の `type` prop が静的に `"multiple"` と判定できるかを返す。
132
+ *
133
+ * 静的に "multiple" と判定できる 6 形式に対応(bare 3 + JSX 式中 3。`[`"']` が
134
+ * backtick / double / single の 3 quote をカバーする):
135
+ * bare: type="multiple" / type='multiple' / type=`multiple`
136
+ * JSX 式中: type={"multiple"} / type={'multiple'} / type={`multiple`}
137
+ * `type={someExpr}` のような動的指定は判定不能なので安全側に倒して `false`
138
+ * (= 非 multiple 扱い = rule HIT 候補)を返す。bare backtick は JSX としては不正だが、
139
+ * 正規表現上はマッチする(実害なく、誤検出は安全側)。
140
+ *
141
+ * en: Recognize all statically-resolvable "multiple" forms — bare and
142
+ * JSX-expression, each across the three quote styles (`/"/'). Dynamic
143
+ * expressions stay as HIT candidates (returns `false`) so we don't silently
144
+ * miss bugs.
145
+ *
146
+ * @param {string} propsBlock
147
+ * @returns {boolean}
148
+ */
149
+ function isMultipleTypeProp(propsBlock) {
150
+ return /type\s*=\s*(?:[`"']multiple[`"']|\{\s*[`"']multiple[`"']\s*\})/.test(propsBlock);
151
+ }
152
+
153
+ /**
154
+ * `check.match(content, helpers)` の第 2 引数としてプラグインに注入されるヘルパー集合。
155
+ * 凍結して渡すことで、プラグインが誤って実装を差し替えるのを防ぐ。
156
+ * en: Frozen helper bundle injected as the second argument of `check.match`.
157
+ */
158
+ const MATCH_HELPERS = Object.freeze({
159
+ findOpeningTagEnd,
160
+ matchOpeningTags,
161
+ hasProp,
162
+ isMultipleTypeProp,
163
+ });
164
+
165
+ export { findOpeningTagEnd, matchOpeningTags, hasProp, isMultipleTypeProp, MATCH_HELPERS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
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",