sparkle-design-cli 2.0.8 → 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 +106 -0
- package/bin/sparkle-design.js +44 -8
- package/lib/anti-pattern-rules.js +39 -28
- package/lib/check.js +77 -32
- package/lib/load-plugins.js +136 -0
- package/lib/plugin-api.js +308 -0
- package/lib/plugin-helpers.js +165 -0
- package/lib/stop-hook.js +2 -2
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -167,6 +167,112 @@ 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
|
+
##### 複雑な検出(`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
|
+
|
|
252
|
+
##### 自動 discovery
|
|
253
|
+
|
|
254
|
+
`sparkle-design-cli check` 実行時、CLI は consumer プロジェクトの `package.json` の `dependencies` / `devDependencies` / `peerDependencies` / `optionalDependencies` を走査し、`sparkleCli.antiPatterns` を宣言しているパッケージを発見次第ロードしてビルトインのルールにマージします。
|
|
255
|
+
|
|
256
|
+
- プラグインパッケージが install されていなければ何も起きません
|
|
257
|
+
- ロードや評価で失敗したプラグインは warn を出してスキップし、残りのルールで check は継続されます
|
|
258
|
+
- ルール ID はビルトイン・他プラグインと名前空間を共有するため、`your-org-` のようなプレフィックスを付けて衝突を避けてください
|
|
259
|
+
|
|
260
|
+
> ⚠️ **信頼境界**: プラグインのエントリファイルは `import()` で **任意の JavaScript を実行** します。これは Node.js の通常のパッケージ依存と同じ性質ですが、`sparkle-design-cli check` 実行時に走るコードが増えるという点で意識しておく必要があります。プラグインは信頼できる org / 著者のパッケージのみインストールしてください。
|
|
261
|
+
|
|
262
|
+
##### 契約仕様の確認
|
|
263
|
+
|
|
264
|
+
最新の契約仕様は CLI から直接出力できます:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# 契約 shape のドキュメントを出力
|
|
268
|
+
npx --yes sparkle-design-cli plugin-spec
|
|
269
|
+
|
|
270
|
+
# 現在のプロジェクトで discover されたプラグインを一覧
|
|
271
|
+
npx --yes sparkle-design-cli plugin-spec --list
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
完全な型定義は `node_modules/sparkle-design-cli/lib/plugin-api.js` の JSDoc を参照してください。
|
|
275
|
+
|
|
170
276
|
### setup: プロジェクトのフルセットアップ
|
|
171
277
|
|
|
172
278
|
`sparkle-design-cli setup` は、Sparkle Design の導入に必要な作業をまとめて行います:
|
package/bin/sparkle-design.js
CHANGED
|
@@ -4,8 +4,10 @@ import { generateCSS } from '../lib/generate-css.js';
|
|
|
4
4
|
import { checkProject } from '../lib/check.js';
|
|
5
5
|
import { setupAssistant } from '../lib/setup.js';
|
|
6
6
|
import { runStopHook } from '../lib/stop-hook.js';
|
|
7
|
+
import { PLUGIN_SPEC_MARKDOWN } from '../lib/plugin-api.js';
|
|
8
|
+
import { loadAntiPatternPlugins } from '../lib/load-plugins.js';
|
|
7
9
|
|
|
8
|
-
const SUBCOMMANDS = new Set(['generate', 'check', 'setup', 'stop-hook']);
|
|
10
|
+
const SUBCOMMANDS = new Set(['generate', 'check', 'setup', 'stop-hook', 'plugin-spec']);
|
|
9
11
|
|
|
10
12
|
function requireOptionValue(args, index, flags) {
|
|
11
13
|
const value = args[index + 1];
|
|
@@ -136,10 +138,11 @@ Sparkle Design CLI
|
|
|
136
138
|
sparkle-design-cli <command> [options]
|
|
137
139
|
|
|
138
140
|
Commands:
|
|
139
|
-
generate
|
|
140
|
-
check
|
|
141
|
-
setup
|
|
142
|
-
stop-hook
|
|
141
|
+
generate sparkle.config.json から CSS を生成
|
|
142
|
+
check Sparkle Design のアンチパターンを検査
|
|
143
|
+
setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
|
|
144
|
+
stop-hook AI assistant の Stop hook 用 internal subcommand(findings 検出時に exit 2 で 1 度だけ停止をブロック / 再発火は自動回避)
|
|
145
|
+
plugin-spec アンチパターン拡張プラグインの契約仕様を出力(--list で導入済みプラグインを表示)
|
|
143
146
|
|
|
144
147
|
Generate:
|
|
145
148
|
sparkle-design-cli generate
|
|
@@ -153,6 +156,10 @@ Check:
|
|
|
153
156
|
sparkle-design-cli check src --format json
|
|
154
157
|
sparkle-design-cli check src/components src/features
|
|
155
158
|
|
|
159
|
+
Plugin spec:
|
|
160
|
+
sparkle-design-cli plugin-spec # アンチパターン拡張プラグインの契約仕様を出力
|
|
161
|
+
sparkle-design-cli plugin-spec --list # 現在のプロジェクトで発見されたプラグインを表示
|
|
162
|
+
|
|
156
163
|
Setup:
|
|
157
164
|
sparkle-design-cli setup # フルセットアップ(推奨・新規導入時)
|
|
158
165
|
sparkle-design-cli setup --assistant claude # Claude 向けガードも同時にセットアップ
|
|
@@ -250,7 +257,31 @@ generate で生成されるファイル:
|
|
|
250
257
|
`);
|
|
251
258
|
}
|
|
252
259
|
|
|
253
|
-
function
|
|
260
|
+
async function runPluginSpec(args) {
|
|
261
|
+
// `--list` prints the discovered plugins; default prints the contract markdown so authors
|
|
262
|
+
// can `npx sparkle-design-cli plugin-spec > PLUGIN.md` to seed their docs.
|
|
263
|
+
// en: With --list, summarize what was discovered in the cwd; otherwise print the spec.
|
|
264
|
+
if (args.includes('--list')) {
|
|
265
|
+
const { discovered } = await loadAntiPatternPlugins();
|
|
266
|
+
if (discovered.length === 0) {
|
|
267
|
+
console.log('No anti-pattern plugins discovered in the current project.');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for (const record of discovered) {
|
|
271
|
+
const status = record.status === 'loaded' ? 'ok' : `error: ${record.error}`;
|
|
272
|
+
console.log(`- ${record.packageName} (${status})`);
|
|
273
|
+
console.log(` entry: ${record.entry}`);
|
|
274
|
+
console.log(` resolved: ${record.resolvedFile}`);
|
|
275
|
+
if (record.groupIds && record.groupIds.length > 0) {
|
|
276
|
+
console.log(` rules: ${record.groupIds.join(', ')}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
console.log(PLUGIN_SPEC_MARKDOWN);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function main() {
|
|
254
285
|
const args = process.argv.slice(2);
|
|
255
286
|
const command = args[0];
|
|
256
287
|
|
|
@@ -282,7 +313,7 @@ function main() {
|
|
|
282
313
|
process.exit(0);
|
|
283
314
|
}
|
|
284
315
|
|
|
285
|
-
const hasFindings = checkProject(options.targets, {
|
|
316
|
+
const hasFindings = await checkProject(options.targets, {
|
|
286
317
|
strict: options.strict,
|
|
287
318
|
format: options.format,
|
|
288
319
|
});
|
|
@@ -310,10 +341,15 @@ function main() {
|
|
|
310
341
|
// en: Internal subcommand invoked by agent stop hooks. The single positional
|
|
311
342
|
// arg is the lint target path determined at setup time.
|
|
312
343
|
const target = args[1];
|
|
313
|
-
const exitCode = runStopHook(target);
|
|
344
|
+
const exitCode = await runStopHook(target);
|
|
314
345
|
process.exit(exitCode);
|
|
315
346
|
}
|
|
316
347
|
|
|
348
|
+
if (command === 'plugin-spec') {
|
|
349
|
+
await runPluginSpec(args.slice(1));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
317
353
|
// 未知のコマンド: エラー終了ではなく note + help 表示にする(typo 時の迷子を防ぐ)
|
|
318
354
|
// en: Unknown command: show a short note + help instead of throwing
|
|
319
355
|
console.warn(
|
|
@@ -23,7 +23,7 @@ function renderJSDocSection(section) {
|
|
|
23
23
|
return output.map((line) => (line ? ` * ${line}` : ' *')).join('\n');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const BUILTIN_MANUAL_REVIEW_REMINDERS = [
|
|
27
27
|
{
|
|
28
28
|
id: 'badge-tag-semantics',
|
|
29
29
|
message:
|
|
@@ -46,7 +46,7 @@ const MANUAL_REVIEW_REMINDERS = [
|
|
|
46
46
|
},
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
-
const
|
|
49
|
+
const BUILTIN_ANTI_PATTERN_GROUPS = [
|
|
50
50
|
{
|
|
51
51
|
id: 'card-description',
|
|
52
52
|
featureSection: lines([
|
|
@@ -1200,51 +1200,62 @@ const ANTI_PATTERN_GROUPS = [
|
|
|
1200
1200
|
},
|
|
1201
1201
|
];
|
|
1202
1202
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1203
|
+
// Built-in groups always come before plugin-supplied groups in this priority order.
|
|
1204
|
+
// Plugin-supplied IDs that aren't in this list fall to the end (stable insertion order).
|
|
1205
|
+
// en: Determines `check` finding emission order so the most-important built-in rules surface first.
|
|
1206
|
+
const BUILTIN_CHECK_ORDER = [
|
|
1207
|
+
'dialog-form',
|
|
1208
|
+
'dialog-button-wrap',
|
|
1209
|
+
'button-icon-only',
|
|
1210
|
+
'material-symbols-direct',
|
|
1211
|
+
'shadcn-token',
|
|
1212
|
+
'tailwind-typography',
|
|
1213
|
+
'card-title-typography',
|
|
1214
|
+
'card-control-non-button',
|
|
1215
|
+
'card-padding-override',
|
|
1216
|
+
'aschild-with-icon-props',
|
|
1217
|
+
'disabled-vs-is-disabled',
|
|
1218
|
+
'button-prefixicon-jsx',
|
|
1219
|
+
'icon-children-text',
|
|
1220
|
+
];
|
|
1219
1221
|
|
|
1220
|
-
|
|
1222
|
+
function getCheckRules(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
|
|
1223
|
+
return groups
|
|
1224
|
+
.filter((group) => group.check)
|
|
1221
1225
|
.map((group) => ({
|
|
1222
1226
|
id: group.id,
|
|
1223
1227
|
...group.check,
|
|
1224
1228
|
}))
|
|
1225
1229
|
.sort((left, right) => {
|
|
1226
|
-
const leftIndex =
|
|
1227
|
-
const rightIndex =
|
|
1230
|
+
const leftIndex = BUILTIN_CHECK_ORDER.indexOf(left.id);
|
|
1231
|
+
const rightIndex = BUILTIN_CHECK_ORDER.indexOf(right.id);
|
|
1228
1232
|
const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
|
1229
1233
|
const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
|
1230
1234
|
return normalizedLeftIndex - normalizedRightIndex;
|
|
1231
1235
|
});
|
|
1232
1236
|
}
|
|
1233
1237
|
|
|
1234
|
-
function getAllJSDocTargets() {
|
|
1235
|
-
return
|
|
1238
|
+
function getAllJSDocTargets(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
|
|
1239
|
+
return groups.flatMap((group) => group.jsdocTargets ?? []);
|
|
1236
1240
|
}
|
|
1237
1241
|
|
|
1238
|
-
function renderFeatureSections() {
|
|
1239
|
-
return
|
|
1242
|
+
function renderFeatureSections(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
|
|
1243
|
+
return groups
|
|
1244
|
+
.map((group) => group.featureSection)
|
|
1245
|
+
.filter(Boolean)
|
|
1246
|
+
.join('\n\n');
|
|
1240
1247
|
}
|
|
1241
1248
|
|
|
1242
|
-
function getManualReviewReminders() {
|
|
1243
|
-
return
|
|
1249
|
+
function getManualReviewReminders(reminders = BUILTIN_MANUAL_REVIEW_REMINDERS) {
|
|
1250
|
+
return reminders;
|
|
1244
1251
|
}
|
|
1245
1252
|
|
|
1246
1253
|
export {
|
|
1247
|
-
|
|
1254
|
+
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
1255
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
1256
|
+
// Backward-compatible aliases — keep until external callers (sync-anti-pattern-docs, tests) migrate.
|
|
1257
|
+
BUILTIN_ANTI_PATTERN_GROUPS as ANTI_PATTERN_GROUPS,
|
|
1258
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
1248
1259
|
COMMON_INTRO,
|
|
1249
1260
|
getAllJSDocTargets,
|
|
1250
1261
|
getCheckRules,
|
package/lib/check.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
6
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
7
|
+
getCheckRules,
|
|
8
|
+
getManualReviewReminders,
|
|
9
|
+
} from './anti-pattern-rules.js';
|
|
10
|
+
import { loadAntiPatternPlugins } from './load-plugins.js';
|
|
11
|
+
import { MATCH_HELPERS } from './plugin-helpers.js';
|
|
5
12
|
import { REGEX, FONT_DOMAINS } from './constants.js';
|
|
6
13
|
|
|
7
14
|
const DEFAULT_TARGET = 'src';
|
|
8
15
|
const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
9
16
|
const CSS_EXTENSIONS = new Set(['.css']);
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
|
|
18
|
+
// Built-ins resolved at module load so test code that imports { RULES } directly still works.
|
|
19
|
+
// en: Synchronous built-in snapshot for backward-compatible export below.
|
|
20
|
+
const BUILTIN_RULES = getCheckRules(BUILTIN_ANTI_PATTERN_GROUPS);
|
|
12
21
|
|
|
13
22
|
const SPARKLE_HEAD_JSX_PATTERN = /<SparkleHead\s*\/?\s*>/;
|
|
14
23
|
// CSP key が「実際の設定 key として記述されている」コンテキストに絞る。
|
|
@@ -102,7 +111,8 @@ function formatSnippet(text) {
|
|
|
102
111
|
* out when character-* / Sparkle tokens don't map cleanly (e.g. sub-token
|
|
103
112
|
* font sizes). Same-line or previous-line comment is honored.
|
|
104
113
|
*/
|
|
105
|
-
const SUPPRESS_LINE =
|
|
114
|
+
const SUPPRESS_LINE =
|
|
115
|
+
/(?:\/\/|\/\*|\{\/\*)\s*sparkle-disable-line\s+([A-Za-z0-9,\s_-]+?)\s*(?:\*\/\}?|$)/;
|
|
106
116
|
const SUPPRESS_NEXT_LINE =
|
|
107
117
|
/(?:\/\/|\/\*|\{\/\*)\s*sparkle-disable-next-line\s+([A-Za-z0-9,\s_-]+?)\s*(?:\*\/\}?|$)/;
|
|
108
118
|
|
|
@@ -130,7 +140,7 @@ function isSuppressed(ruleId, contentLines, lineNumber) {
|
|
|
130
140
|
return false;
|
|
131
141
|
}
|
|
132
142
|
|
|
133
|
-
function collectFindings(filePath, content) {
|
|
143
|
+
function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
134
144
|
const findings = [];
|
|
135
145
|
const contentLines = content.split(/\r?\n/);
|
|
136
146
|
const pushFinding = (rule, index, snippet) => {
|
|
@@ -146,18 +156,46 @@ function collectFindings(filePath, content) {
|
|
|
146
156
|
});
|
|
147
157
|
};
|
|
148
158
|
|
|
149
|
-
for (const rule of
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
for (const rule of rules) {
|
|
160
|
+
// rule ごとに try/catch で隔離する。1 つのプラグイン rule の throw / malformed
|
|
161
|
+
// RegExp で sparkle-design-cli check 全体が落ちるのを防ぐ。loadAntiPatternPlugins の
|
|
162
|
+
// 「warn して skip」と同じ failure mode に揃える。
|
|
163
|
+
// en: Isolate each rule. A broken plugin rule (throw / non-global regex / wrong
|
|
164
|
+
// type) must not crash the whole check pipeline.
|
|
165
|
+
try {
|
|
166
|
+
if (typeof rule.match === 'function') {
|
|
167
|
+
// rule.match(content, helpers) -> Array<{ index: number, text: string }>
|
|
168
|
+
// 複雑な照合が必要なルール向けの opt-in API。2-pass 方式などで regex 単体の
|
|
169
|
+
// backtracking リスクを回避したいときに使う。index は 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);
|
|
177
|
+
if (!Array.isArray(hits)) {
|
|
178
|
+
throw new TypeError(`rule.match must return an array, got ${typeof hits}`);
|
|
179
|
+
}
|
|
180
|
+
for (const hit of hits) {
|
|
181
|
+
pushFinding(rule, hit?.index ?? 0, formatSnippet(hit?.text ?? ''));
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
156
184
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
if (!(rule.pattern instanceof RegExp)) {
|
|
186
|
+
throw new TypeError('rule.pattern must be a RegExp (or define rule.match instead)');
|
|
187
|
+
}
|
|
188
|
+
if (!rule.pattern.global) {
|
|
189
|
+
throw new TypeError('rule.pattern must include the global flag (e.g. /foo/g)');
|
|
190
|
+
}
|
|
191
|
+
for (const match of content.matchAll(rule.pattern)) {
|
|
192
|
+
pushFinding(rule, match.index ?? 0, formatMatch(match));
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = error?.message ?? String(error);
|
|
196
|
+
console.warn(
|
|
197
|
+
`⚠️ sparkle-design-cli: rule "${rule.id ?? '(unknown)'}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
|
|
198
|
+
);
|
|
161
199
|
}
|
|
162
200
|
}
|
|
163
201
|
|
|
@@ -267,7 +305,9 @@ function collectNextjsCspFindings(cwd) {
|
|
|
267
305
|
return findings;
|
|
268
306
|
}
|
|
269
307
|
|
|
270
|
-
function createCheckReport(targets = []) {
|
|
308
|
+
function createCheckReport(targets = [], options = {}) {
|
|
309
|
+
const rules = options.rules ?? BUILTIN_RULES;
|
|
310
|
+
const baseReminders = options.baseReminders ?? BUILTIN_MANUAL_REVIEW_REMINDERS;
|
|
271
311
|
const resolvedTargets = targets.length > 0 ? targets : [DEFAULT_TARGET];
|
|
272
312
|
const textFiles = new Set();
|
|
273
313
|
const cssFiles = new Set();
|
|
@@ -289,7 +329,7 @@ function createCheckReport(targets = []) {
|
|
|
289
329
|
}
|
|
290
330
|
|
|
291
331
|
const findings = [...fileContents.entries()]
|
|
292
|
-
.flatMap(([filePath, content]) => collectFindings(filePath, content))
|
|
332
|
+
.flatMap(([filePath, content]) => collectFindings(filePath, content, rules))
|
|
293
333
|
.map((finding) => ({
|
|
294
334
|
...finding,
|
|
295
335
|
filePath: toRelativeReportPath(finding.filePath),
|
|
@@ -308,7 +348,7 @@ function createCheckReport(targets = []) {
|
|
|
308
348
|
return left.id.localeCompare(right.id);
|
|
309
349
|
});
|
|
310
350
|
|
|
311
|
-
const manualReviewReminders = [...
|
|
351
|
+
const manualReviewReminders = [...baseReminders];
|
|
312
352
|
if (!hasSparkleHeadUsage(fileContents)) {
|
|
313
353
|
manualReviewReminders.push({
|
|
314
354
|
id: 'sparkle-head-missing',
|
|
@@ -352,15 +392,9 @@ function printTextReport(report, options = {}) {
|
|
|
352
392
|
// exit code — reminders are judgment calls, not hard failures.
|
|
353
393
|
console.log('');
|
|
354
394
|
console.log('=== Manual review reminders (must acknowledge each ID in your response) ===');
|
|
355
|
-
console.log(
|
|
356
|
-
|
|
357
|
-
);
|
|
358
|
-
console.log(
|
|
359
|
-
'explicitly state the reminder ID in your reply together with whether the'
|
|
360
|
-
);
|
|
361
|
-
console.log(
|
|
362
|
-
'current code already satisfies it, or what change is needed. Silence = skipped.'
|
|
363
|
-
);
|
|
395
|
+
console.log('These are judgment calls the linter cannot detect. For every item below,');
|
|
396
|
+
console.log('explicitly state the reminder ID in your reply together with whether the');
|
|
397
|
+
console.log('current code already satisfies it, or what change is needed. Silence = skipped.');
|
|
364
398
|
console.log('');
|
|
365
399
|
for (const reminder of reminders) {
|
|
366
400
|
console.log(`- [${reminder.id}] ${reminder.message}`);
|
|
@@ -413,8 +447,19 @@ function printJsonReport(report, options = {}) {
|
|
|
413
447
|
);
|
|
414
448
|
}
|
|
415
449
|
|
|
416
|
-
export function checkProject(targets = [], options = {}) {
|
|
417
|
-
|
|
450
|
+
export async function checkProject(targets = [], options = {}) {
|
|
451
|
+
// Plugins are discovered from the consumer project's package.json deps. Errors are
|
|
452
|
+
// already warn-and-skip inside loadAntiPatternPlugins, so we just consume the result.
|
|
453
|
+
// en: Auto-discover plugins from cwd; loader handles its own failure reporting.
|
|
454
|
+
const { groups: pluginGroups, reminders: pluginReminders } = await loadAntiPatternPlugins();
|
|
455
|
+
const mergedGroups = [...BUILTIN_ANTI_PATTERN_GROUPS, ...pluginGroups];
|
|
456
|
+
const rules = getCheckRules(mergedGroups);
|
|
457
|
+
const baseReminders = getManualReviewReminders([
|
|
458
|
+
...BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
459
|
+
...pluginReminders,
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
const report = createCheckReport(targets, { rules, baseReminders });
|
|
418
463
|
|
|
419
464
|
if (options.format === 'json') {
|
|
420
465
|
printJsonReport(report, options);
|
|
@@ -426,8 +471,8 @@ export function checkProject(targets = [], options = {}) {
|
|
|
426
471
|
}
|
|
427
472
|
|
|
428
473
|
export {
|
|
429
|
-
RULES,
|
|
474
|
+
BUILTIN_RULES as RULES,
|
|
430
475
|
collectFindings,
|
|
431
476
|
createCheckReport,
|
|
432
|
-
|
|
477
|
+
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
433
478
|
};
|
|
@@ -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,308 @@
|
|
|
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, 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.
|
|
37
|
+
* },
|
|
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
|
+
*
|
|
59
|
+
* ## JSDocTarget
|
|
60
|
+
*
|
|
61
|
+
* {
|
|
62
|
+
* file: string, // path relative to the plugin package root
|
|
63
|
+
* targetName: string, // symbol whose JSDoc block receives the anti-pattern section
|
|
64
|
+
* section: {
|
|
65
|
+
* bullets: Array<{ ja: string, en: string }>,
|
|
66
|
+
* example?: string, // tsx snippet
|
|
67
|
+
* },
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* ## ManualReviewReminder
|
|
71
|
+
*
|
|
72
|
+
* {
|
|
73
|
+
* id: string, // unique stable identifier echoed by the AI in acknowledgments
|
|
74
|
+
* message: string, // human-readable instruction
|
|
75
|
+
* }
|
|
76
|
+
*
|
|
77
|
+
* ## Notes
|
|
78
|
+
*
|
|
79
|
+
* - Built-in rules for the public `@goodpatch/sparkle-design` library are bundled with the CLI
|
|
80
|
+
* and not exposed as a plugin. Plugins are merged on top of the built-ins.
|
|
81
|
+
* - Plugins are auto-discovered from the consumer's `package.json` dependencies, so a plugin
|
|
82
|
+
* is active only when the consuming project actually installs the plugin package.
|
|
83
|
+
* - Plugin IDs share a namespace with built-ins. Pick a prefix (e.g. `internal-`) to avoid clashes.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
const PLUGIN_PACKAGE_FIELD = 'sparkleCli';
|
|
87
|
+
const PLUGIN_ANTI_PATTERNS_KEY = 'antiPatterns';
|
|
88
|
+
|
|
89
|
+
function isNonEmptyString(value) {
|
|
90
|
+
return typeof value === 'string' && value.length > 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateGroup(group, context) {
|
|
94
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
95
|
+
throw new TypeError(`${context}: group entry must be a non-null object`);
|
|
96
|
+
}
|
|
97
|
+
if (!isNonEmptyString(group.id)) {
|
|
98
|
+
throw new TypeError(`${context}: group.id must be a non-empty string`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (group.check !== undefined) {
|
|
102
|
+
const { check } = group;
|
|
103
|
+
if (!check || typeof check !== 'object') {
|
|
104
|
+
throw new TypeError(`${context} (group "${group.id}"): check must be an object`);
|
|
105
|
+
}
|
|
106
|
+
if (!isNonEmptyString(check.description)) {
|
|
107
|
+
throw new TypeError(
|
|
108
|
+
`${context} (group "${group.id}"): check.description must be a non-empty string`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (!isNonEmptyString(check.recommendation)) {
|
|
112
|
+
throw new TypeError(
|
|
113
|
+
`${context} (group "${group.id}"): check.recommendation must be a non-empty string`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const hasPattern = check.pattern !== undefined;
|
|
117
|
+
const hasMatch = check.match !== undefined;
|
|
118
|
+
if (hasPattern === hasMatch) {
|
|
119
|
+
throw new TypeError(
|
|
120
|
+
`${context} (group "${group.id}"): check must define exactly one of \`pattern\` (RegExp) or \`match\` (function)`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (hasPattern) {
|
|
124
|
+
if (!(check.pattern instanceof RegExp)) {
|
|
125
|
+
throw new TypeError(`${context} (group "${group.id}"): check.pattern must be a RegExp`);
|
|
126
|
+
}
|
|
127
|
+
// matchAll は global flag が無い RegExp で TypeError を投げるので、ここで明示的に弾く。
|
|
128
|
+
// en: Bail early so the check pipeline never reaches `content.matchAll(nonGlobal)`.
|
|
129
|
+
if (!check.pattern.global) {
|
|
130
|
+
throw new TypeError(
|
|
131
|
+
`${context} (group "${group.id}"): check.pattern must include the global flag (e.g. /foo/g)`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} else if (typeof check.match !== 'function') {
|
|
135
|
+
throw new TypeError(`${context} (group "${group.id}"): check.match must be a function`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validateReminders(reminders, context) {
|
|
141
|
+
if (reminders === undefined) return;
|
|
142
|
+
if (!Array.isArray(reminders)) {
|
|
143
|
+
throw new TypeError(`${context}: manualReviewReminders must be an array if present`);
|
|
144
|
+
}
|
|
145
|
+
for (const [index, reminder] of reminders.entries()) {
|
|
146
|
+
if (!reminder || typeof reminder !== 'object') {
|
|
147
|
+
throw new TypeError(`${context}: manualReviewReminders[${index}] must be a non-null object`);
|
|
148
|
+
}
|
|
149
|
+
if (!isNonEmptyString(reminder.id)) {
|
|
150
|
+
throw new TypeError(
|
|
151
|
+
`${context}: manualReviewReminders[${index}].id must be a non-empty string`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (!isNonEmptyString(reminder.message)) {
|
|
155
|
+
throw new TypeError(
|
|
156
|
+
`${context}: manualReviewReminders[${index}].message must be a non-empty string`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate that the given object matches the documented plugin shape. Throws TypeError
|
|
164
|
+
* with an actionable message on the first violation. Exported so the loader can apply
|
|
165
|
+
* the same checks to plugins that don't go through `defineAntiPatternPlugin`.
|
|
166
|
+
*
|
|
167
|
+
* @param {unknown} plugin
|
|
168
|
+
* @param {string} [context] source identifier shown in error messages (e.g. `@org/pkg (./file.js)`)
|
|
169
|
+
*/
|
|
170
|
+
function validatePluginShape(plugin, context = 'plugin') {
|
|
171
|
+
if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) {
|
|
172
|
+
throw new TypeError(`${context}: plugin must be a non-null object`);
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(plugin.groups)) {
|
|
175
|
+
throw new TypeError(`${context}: plugin.groups must be an array`);
|
|
176
|
+
}
|
|
177
|
+
for (const [index, group] of plugin.groups.entries()) {
|
|
178
|
+
validateGroup(group, `${context} groups[${index}]`);
|
|
179
|
+
}
|
|
180
|
+
validateReminders(plugin.manualReviewReminders, context);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function defineAntiPatternPlugin(plugin) {
|
|
184
|
+
validatePluginShape(plugin, 'defineAntiPatternPlugin');
|
|
185
|
+
return plugin;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const PLUGIN_SPEC_MARKDOWN = `# sparkle-design-cli anti-pattern plugin spec
|
|
189
|
+
|
|
190
|
+
Plugin packages extend \`sparkle-design-cli check\` with additional anti-pattern rules.
|
|
191
|
+
Built-in rules cover the public \`@goodpatch/sparkle-design\` library; plugins are auto-discovered
|
|
192
|
+
from the consuming project's dependencies, so private or org-specific component libraries can ship
|
|
193
|
+
their own detection rules without bundling them into the public CLI.
|
|
194
|
+
|
|
195
|
+
## Declaring a plugin
|
|
196
|
+
|
|
197
|
+
In the plugin package's own \`package.json\`:
|
|
198
|
+
|
|
199
|
+
\`\`\`json
|
|
200
|
+
{
|
|
201
|
+
"name": "@your-org/your-design-extensions",
|
|
202
|
+
"sparkleCli": {
|
|
203
|
+
"antiPatterns": "./anti-patterns/index.js"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
The referenced file must default-export a plugin object:
|
|
209
|
+
|
|
210
|
+
\`\`\`js
|
|
211
|
+
import { defineAntiPatternPlugin } from 'sparkle-design-cli/plugin';
|
|
212
|
+
|
|
213
|
+
export default defineAntiPatternPlugin({
|
|
214
|
+
groups: [
|
|
215
|
+
{
|
|
216
|
+
id: 'internal-foo-misuse',
|
|
217
|
+
featureSection: '### FooCard はネスト禁止\\n...',
|
|
218
|
+
check: {
|
|
219
|
+
description: 'FooCard を別の FooCard でラップしないでください。',
|
|
220
|
+
recommendation: '入れ子表示が必要なら FooStack を使ってください。',
|
|
221
|
+
pattern: /<FooCard[^>]*>[\\s\\S]*?<FooCard/g,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
manualReviewReminders: [
|
|
226
|
+
{
|
|
227
|
+
id: 'internal-foo-color',
|
|
228
|
+
message: 'FooCard の color token が brand に揃っているか確認してください。',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
\`\`\`
|
|
233
|
+
|
|
234
|
+
## Discovery
|
|
235
|
+
|
|
236
|
+
When \`sparkle-design-cli\` runs in a project, it reads that project's \`package.json\`,
|
|
237
|
+
walks \`dependencies\` / \`devDependencies\` / \`peerDependencies\` / \`optionalDependencies\`,
|
|
238
|
+
and resolves each entry's own \`package.json\`. Packages whose \`package.json\` declares
|
|
239
|
+
\`sparkleCli.antiPatterns\` are loaded and merged into the check rules.
|
|
240
|
+
|
|
241
|
+
End users do not have to configure anything — installing a plugin package is enough to activate it.
|
|
242
|
+
|
|
243
|
+
## Shape reference
|
|
244
|
+
|
|
245
|
+
See the JSDoc in \`lib/plugin-api.js\` for the full type contract:
|
|
246
|
+
|
|
247
|
+
- \`AntiPatternGroup\`: \`{ id, featureSection?, jsdocTargets?, check? }\`
|
|
248
|
+
- \`AntiPatternGroup.check\`: \`{ description, recommendation, pattern | match }\`
|
|
249
|
+
- \`pattern\` is a RegExp and **must include the global flag** (\`/foo/g\`); otherwise
|
|
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.
|
|
253
|
+
- \`pattern\` and \`match\` are mutually exclusive — provide exactly one.
|
|
254
|
+
- \`JSDocTarget\`: \`{ file, targetName, section: { bullets, example? } }\`
|
|
255
|
+
- \`ManualReviewReminder\`: \`{ id, message }\`
|
|
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
|
+
|
|
290
|
+
## ID namespace
|
|
291
|
+
|
|
292
|
+
Plugin rule IDs share a namespace with built-in rules and with each other. Choose a stable prefix
|
|
293
|
+
(e.g. \`internal-\`, \`acme-\`) so suppression comments like \`// sparkle-disable-next-line internal-foo-misuse\`
|
|
294
|
+
remain unambiguous.
|
|
295
|
+
|
|
296
|
+
## Failure mode
|
|
297
|
+
|
|
298
|
+
Errors loading or evaluating a plugin are logged as warnings; the rest of the check continues with
|
|
299
|
+
the rules that did load. The CLI never fails because a plugin is missing or broken.
|
|
300
|
+
`;
|
|
301
|
+
|
|
302
|
+
export {
|
|
303
|
+
PLUGIN_PACKAGE_FIELD,
|
|
304
|
+
PLUGIN_ANTI_PATTERNS_KEY,
|
|
305
|
+
PLUGIN_SPEC_MARKDOWN,
|
|
306
|
+
defineAntiPatternPlugin,
|
|
307
|
+
validatePluginShape,
|
|
308
|
+
};
|
|
@@ -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/lib/stop-hook.js
CHANGED
|
@@ -19,7 +19,7 @@ import { checkProject } from './check.js';
|
|
|
19
19
|
* Claude Code sets `stop_hook_active: true` on re-fires so the hook can
|
|
20
20
|
* exit cleanly instead of looping forever (see Claude Code hooks docs).
|
|
21
21
|
*/
|
|
22
|
-
export function runStopHook(target) {
|
|
22
|
+
export async function runStopHook(target) {
|
|
23
23
|
const payload = readStdinJsonSafely();
|
|
24
24
|
|
|
25
25
|
if (payload && payload.stop_hook_active === true) {
|
|
@@ -32,7 +32,7 @@ export function runStopHook(target) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const targets = target ? [target] : [];
|
|
35
|
-
const hasFindings = checkProject(targets, { strict: true, format: 'text' });
|
|
35
|
+
const hasFindings = await checkProject(targets, { strict: true, format: 'text' });
|
|
36
36
|
|
|
37
37
|
if (hasFindings) {
|
|
38
38
|
process.stderr.write(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkle-design-cli",
|
|
3
|
-
"version": "2.0
|
|
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",
|
|
@@ -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
|
},
|