sparkle-design-cli 2.1.0 → 2.2.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
@@ -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` を宣言しているパッケージを発見次第ロードしてビルトインのルールにマージします。
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
  }
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.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",