sparkle-design-cli 2.5.0-beta.4 → 2.5.0-beta.5
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/CONTRIBUTING.md +28 -2
- package/README.md +4 -1
- package/bin/sparkle-design.js +45 -4
- package/docs/anti-patterns.md +77 -2
- package/docs/config.md +2 -0
- package/lib/anti-pattern-rules.js +8 -5
- package/lib/check-config.js +358 -0
- package/lib/check.js +228 -18
- package/lib/migrate.js +36 -50
- package/lib/path-utils.js +40 -0
- package/lib/rules-report.js +5 -1
- package/lib/stop-hook.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { FILE_NAMES } from './constants.js';
|
|
5
|
+
import { globToRegExp } from './path-utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `check` のパス単位の除外設定(goodpatch/sparkle-design-cli#102)。
|
|
9
|
+
*
|
|
10
|
+
* `sparkle.config.json` の `check.ignore` に、除外したいパスの glob と
|
|
11
|
+
* (任意で)ルール ID を書く。行単位のコメント(`sparkle-disable-line` /
|
|
12
|
+
* `sparkle-disable-next-line`)だけだと、サイト独自のページのように
|
|
13
|
+
* 意図的な箇所がまとまっているときにコメントだらけになるため。
|
|
14
|
+
*
|
|
15
|
+
* ```json
|
|
16
|
+
* {
|
|
17
|
+
* "check": {
|
|
18
|
+
* "ignore": [
|
|
19
|
+
* { "files": ["src/app/(marketing)/**"], "rules": ["tailwind-typography"] },
|
|
20
|
+
* { "files": ["**\/*.stories.tsx"] }
|
|
21
|
+
* ],
|
|
22
|
+
* "allowTokens": ["text-muted-foreground"]
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* - glob は **cwd(`check` を実行したディレクトリ)**からの相対パスで照合する。`check` の
|
|
28
|
+
* 出力に出るパスと同じ基準で、`-c` で別の場所の設定ファイルを指定しても基準は cwd のまま。
|
|
29
|
+
* cwd の外のファイル(出力が `../` や絶対パスになるもの)には効かない
|
|
30
|
+
* - glob 記号を含まないパスはディレクトリ指定とみなし、その配下も対象にする
|
|
31
|
+
* (`src/app/blog` と `src/app/blog/**` は同じ意味)
|
|
32
|
+
* - `rules` を省略すると、そのパスの全ルールを除外する
|
|
33
|
+
* - `allowTokens`(#104)は、プロジェクトが自前の CSS で定義して意図して使う shadcn/ui の
|
|
34
|
+
* トークンを宣言する。`shadcn-token` の指摘のうち、クラスが宣言と完全一致するものを出さない。
|
|
35
|
+
* `{ "tokens": [...], "files": [glob] }` の形でパスを絞れる。宣言したものだけを許可するので、
|
|
36
|
+
* 意図しない持ち込みは引き続き検出される
|
|
37
|
+
*
|
|
38
|
+
* 設定の形が不正なときは `CheckConfigError`(code: `E_CHECK_CONFIG`)を throw する。除外設定の typo を黙って無視すると
|
|
39
|
+
* 「除外したつもりが効いていない」か、その逆に気付けないため。
|
|
40
|
+
*
|
|
41
|
+
* en: Path-level ignore entries read from `sparkle.config.json` → `check.ignore`.
|
|
42
|
+
* Globs are cwd-relative (same as report paths). A glob-free path is treated as
|
|
43
|
+
* a directory and covers everything under it. Omitting `rules` ignores all rules.
|
|
44
|
+
* `allowTokens` (#104) declares shadcn/ui classes the project defines and uses on
|
|
45
|
+
* purpose; it only hides `shadcn-token` findings that match a declared class.
|
|
46
|
+
* Malformed config throws rather than being silently skipped.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const GLOB_CHARS = /[*?{]/;
|
|
50
|
+
const CHECK_KEYS = new Set(['ignore', 'allowTokens']);
|
|
51
|
+
const ENTRY_KEYS = new Set(['files', 'rules']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 除外設定の誤り。stop-hook はこれを見て「設定を直して」とブロックする
|
|
55
|
+
* (exit 1 は Claude Code では非ブロッキング扱いで、検査が黙って止まるため)。
|
|
56
|
+
* en: Tagged so stop-hook can block with a fix-the-config message instead of
|
|
57
|
+
* exiting 1, which Claude Code treats as a non-blocking hook error.
|
|
58
|
+
*/
|
|
59
|
+
export class CheckConfigError extends Error {
|
|
60
|
+
constructor(message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'CheckConfigError';
|
|
63
|
+
this.code = 'E_CHECK_CONFIG';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* cwd の外を指すレポートパスか。`../` 始まりと絶対パス(Windows のドライブ指定含む)。
|
|
69
|
+
* en: Report paths outside cwd — `../…` or absolute (including drive letters).
|
|
70
|
+
*/
|
|
71
|
+
function isOutsideCwd(relativePath) {
|
|
72
|
+
return (
|
|
73
|
+
relativePath === '..' ||
|
|
74
|
+
relativePath.startsWith('../') ||
|
|
75
|
+
relativePath.startsWith('/') ||
|
|
76
|
+
/^[A-Za-z]:/.test(relativePath)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describeEntry(index) {
|
|
81
|
+
return `check.ignore[${index}]`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertStringArray(value, label) {
|
|
85
|
+
if (
|
|
86
|
+
!Array.isArray(value) ||
|
|
87
|
+
value.length === 0 ||
|
|
88
|
+
value.some((item) => typeof item !== 'string' || !item.trim())
|
|
89
|
+
) {
|
|
90
|
+
throw new CheckConfigError(
|
|
91
|
+
`${label} は空でない文字列の配列で指定してください: ${JSON.stringify(value)}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* glob 1 件を照合用の正規表現(複数)にする。glob 記号を含まないパスは
|
|
98
|
+
* 「そのパス自身」と「その配下」の両方にマッチさせる。
|
|
99
|
+
* en: A glob-free path matches itself and everything under it.
|
|
100
|
+
*/
|
|
101
|
+
function compileFileGlob(glob, label) {
|
|
102
|
+
const trimmed = glob.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
103
|
+
if (
|
|
104
|
+
trimmed === '' ||
|
|
105
|
+
trimmed === '.' ||
|
|
106
|
+
path.isAbsolute(trimmed) ||
|
|
107
|
+
/^[A-Za-z]:/.test(trimmed) ||
|
|
108
|
+
glob.trim().startsWith('/') ||
|
|
109
|
+
trimmed.split('/').includes('..') ||
|
|
110
|
+
// `src/./app` や `src//app` はどのパスにも一致せず、黙って効かない
|
|
111
|
+
// en: `src/./app` / `src//app` would never match anything — reject loudly.
|
|
112
|
+
trimmed
|
|
113
|
+
.replace(/^\.\//, '')
|
|
114
|
+
.split('/')
|
|
115
|
+
.some((segment) => segment === '' || segment === '.')
|
|
116
|
+
) {
|
|
117
|
+
throw new CheckConfigError(
|
|
118
|
+
`${label} には cwd からの相対パスを指定してください(空・絶対パス・..・途中の . や空のセグメントは使えません): ${glob}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
if (GLOB_CHARS.test(trimmed)) {
|
|
123
|
+
return [globToRegExp(trimmed, label)];
|
|
124
|
+
}
|
|
125
|
+
return [globToRegExp(trimmed, label), globToRegExp(`${trimmed}/**`, label)];
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new CheckConfigError(error.message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `check.ignore` を検証して照合しやすい形にする。
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} checkSection `sparkle.config.json` の `check` の値
|
|
135
|
+
* @returns {Array<{ files: string[], matchers: RegExp[], rules: string[] | null }>}
|
|
136
|
+
*/
|
|
137
|
+
export function normalizeCheckIgnore(checkSection) {
|
|
138
|
+
if (checkSection === undefined) return [];
|
|
139
|
+
if (checkSection === null || typeof checkSection !== 'object' || Array.isArray(checkSection)) {
|
|
140
|
+
throw new CheckConfigError('sparkle.config.json の check はオブジェクトで指定してください');
|
|
141
|
+
}
|
|
142
|
+
const unknownCheckKeys = Object.keys(checkSection).filter((key) => !CHECK_KEYS.has(key));
|
|
143
|
+
if (unknownCheckKeys.length > 0) {
|
|
144
|
+
throw new CheckConfigError(
|
|
145
|
+
`sparkle.config.json の check に未知のキーがあります: ${unknownCheckKeys.join(', ')}(使えるのは ignore / allowTokens)`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const { ignore } = checkSection;
|
|
149
|
+
if (ignore === undefined) return [];
|
|
150
|
+
if (!Array.isArray(ignore)) {
|
|
151
|
+
throw new CheckConfigError('sparkle.config.json の check.ignore は配列で指定してください');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return ignore.map((entry, index) => {
|
|
155
|
+
const label = describeEntry(index);
|
|
156
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
157
|
+
throw new CheckConfigError(
|
|
158
|
+
`${label} は { "files": [...], "rules": [...] } の形で指定してください`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const unknownKeys = Object.keys(entry).filter((key) => !ENTRY_KEYS.has(key));
|
|
162
|
+
if (unknownKeys.length > 0) {
|
|
163
|
+
throw new CheckConfigError(
|
|
164
|
+
`${label} に未知のキーがあります: ${unknownKeys.join(', ')}(使えるのは files / rules)`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
assertStringArray(entry.files, `${label}.files`);
|
|
168
|
+
if (entry.rules !== undefined) {
|
|
169
|
+
assertStringArray(entry.rules, `${label}.rules`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
files: entry.files.map((glob) => glob.trim()),
|
|
173
|
+
matchers: entry.files.flatMap((glob) => compileFileGlob(glob, `${label}.files`)),
|
|
174
|
+
rules: entry.rules ? entry.rules.map((id) => id.trim()) : null,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `check.allowTokens` が効くルール。shadcn/ui のトークンを自前の CSS で定義して使う
|
|
181
|
+
* プロジェクトのための宣言(#104)なので、`shadcn-token` の指摘にだけ効かせる。
|
|
182
|
+
* 他ルールまで黙らせる汎用の抜け道にしない(範囲を絞った除外は `check.ignore` で行う)。
|
|
183
|
+
* en: allowTokens only applies to `shadcn-token` findings — it declares intentional
|
|
184
|
+
* shadcn/ui tokens, and must not become a generic escape hatch for other rules.
|
|
185
|
+
*/
|
|
186
|
+
export const ALLOW_TOKENS_RULE_ID = 'shadcn-token';
|
|
187
|
+
const ALLOW_ENTRY_KEYS = new Set(['tokens', 'files']);
|
|
188
|
+
|
|
189
|
+
function assertClassNames(tokens, label) {
|
|
190
|
+
const invalid = tokens.filter((token) => /\s/.test(token.trim()));
|
|
191
|
+
if (invalid.length > 0) {
|
|
192
|
+
throw new CheckConfigError(
|
|
193
|
+
`${label} にはクラス名を 1 つずつ別の要素で書いてください: ${JSON.stringify(invalid)}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `check.allowTokens` を検証して照合しやすい形にする。要素はクラス名の文字列
|
|
200
|
+
* (全体で許可)か、`{ "tokens": [...], "files": [glob] }`(そのパスでだけ許可)。
|
|
201
|
+
* 空配列は「宣言なし」として受け付ける。
|
|
202
|
+
* en: Entries are class-name strings (allowed everywhere) or
|
|
203
|
+
* `{ tokens, files }` (allowed only under those paths). `[]` means none.
|
|
204
|
+
*
|
|
205
|
+
* @param {unknown} checkSection
|
|
206
|
+
* @returns {Array<{ tokens: string[], matchers: RegExp[] | null }>}
|
|
207
|
+
*/
|
|
208
|
+
export function normalizeAllowTokens(checkSection) {
|
|
209
|
+
const allowTokens = checkSection?.allowTokens;
|
|
210
|
+
if (allowTokens === undefined) return [];
|
|
211
|
+
if (!Array.isArray(allowTokens)) {
|
|
212
|
+
throw new CheckConfigError(
|
|
213
|
+
'check.allowTokens はクラス名か { "tokens": [...], "files": [...] } の配列で指定してください'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const globalTokens = [];
|
|
217
|
+
const scoped = [];
|
|
218
|
+
allowTokens.forEach((entry, index) => {
|
|
219
|
+
const label = `check.allowTokens[${index}]`;
|
|
220
|
+
if (typeof entry === 'string') {
|
|
221
|
+
if (!entry.trim()) {
|
|
222
|
+
throw new CheckConfigError(`${label} が空文字です`);
|
|
223
|
+
}
|
|
224
|
+
assertClassNames([entry], label);
|
|
225
|
+
globalTokens.push(entry.trim());
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
229
|
+
throw new CheckConfigError(
|
|
230
|
+
`${label} はクラス名か { "tokens": [...], "files": [...] } で指定してください`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const unknownKeys = Object.keys(entry).filter((key) => !ALLOW_ENTRY_KEYS.has(key));
|
|
234
|
+
if (unknownKeys.length > 0) {
|
|
235
|
+
throw new CheckConfigError(
|
|
236
|
+
`${label} に未知のキーがあります: ${unknownKeys.join(', ')}(使えるのは tokens / files)`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
assertStringArray(entry.tokens, `${label}.tokens`);
|
|
240
|
+
assertClassNames(entry.tokens, `${label}.tokens`);
|
|
241
|
+
assertStringArray(entry.files, `${label}.files`);
|
|
242
|
+
scoped.push({
|
|
243
|
+
tokens: entry.tokens.map((token) => token.trim()),
|
|
244
|
+
matchers: entry.files.flatMap((glob) => compileFileGlob(glob, `${label}.files`)),
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
return [
|
|
248
|
+
...(globalTokens.length > 0 ? [{ tokens: [...new Set(globalTokens)], matchers: null }] : []),
|
|
249
|
+
...scoped,
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `shadcn-token` の指摘(`relativePath` のファイルで `token` を検出)が
|
|
255
|
+
* `check.allowTokens` で許可されているか。`check.ignore` と同じく cwd の外には効かない。
|
|
256
|
+
* en: Whether a shadcn-token finding is allowed; like check.ignore, never
|
|
257
|
+
* applies outside cwd.
|
|
258
|
+
*/
|
|
259
|
+
export function isTokenAllowed(entries, relativePath, token) {
|
|
260
|
+
if (entries.length === 0 || isOutsideCwd(relativePath)) return false;
|
|
261
|
+
return entries.some(
|
|
262
|
+
(entry) =>
|
|
263
|
+
entry.tokens.includes(token) &&
|
|
264
|
+
(entry.matchers === null || entry.matchers.some((matcher) => matcher.test(relativePath)))
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `sparkle.config.json` を読み、`check` セクションの生の値を返す。
|
|
270
|
+
*
|
|
271
|
+
* - `configPath` 未指定で既定の `./sparkle.config.json` が無ければ `undefined`(`check` は
|
|
272
|
+
* 設定ファイルが無いプロジェクトでも動く)
|
|
273
|
+
* - `configPath` を明示したのに無い、または JSON が壊れているときは throw
|
|
274
|
+
* en: Read the raw `check` section; missing default file means no config.
|
|
275
|
+
*/
|
|
276
|
+
function readCheckSection(configPath, cwd) {
|
|
277
|
+
const resolved = path.resolve(cwd, configPath ?? FILE_NAMES.CONFIG);
|
|
278
|
+
let raw;
|
|
279
|
+
try {
|
|
280
|
+
raw = fs.readFileSync(resolved, 'utf8');
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error.code === 'ENOENT' && !configPath) return undefined;
|
|
283
|
+
throw new CheckConfigError(
|
|
284
|
+
`設定ファイルを読み込めませんでした: ${resolved} (${error.code ?? error.message})`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
let config;
|
|
288
|
+
try {
|
|
289
|
+
config = JSON.parse(raw);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new CheckConfigError(`設定ファイルの JSON が不正です: ${resolved} (${error.message})`);
|
|
292
|
+
}
|
|
293
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
294
|
+
throw new CheckConfigError(`設定ファイルの中身はオブジェクトである必要があります: ${resolved}`);
|
|
295
|
+
}
|
|
296
|
+
return config.check;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `sparkle.config.json` から `check` の設定(`ignore` と `allowTokens`)を読む。
|
|
301
|
+
* en: Load both `check.ignore` and `check.allowTokens`.
|
|
302
|
+
*
|
|
303
|
+
* @param {string | null} [configPath]
|
|
304
|
+
* @param {string} [cwd]
|
|
305
|
+
* @returns {{ ignore: ReturnType<typeof normalizeCheckIgnore>,
|
|
306
|
+
* allowTokens: ReturnType<typeof normalizeAllowTokens> }}
|
|
307
|
+
*/
|
|
308
|
+
export function loadCheckConfig(configPath = null, cwd = process.cwd()) {
|
|
309
|
+
const checkSection = readCheckSection(configPath, cwd);
|
|
310
|
+
return {
|
|
311
|
+
ignore: normalizeCheckIgnore(checkSection),
|
|
312
|
+
allowTokens: normalizeAllowTokens(checkSection),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `check.ignore` だけを読む。`migrate` 用で、使わない `allowTokens` の形は検証しない
|
|
318
|
+
* (`allowTokens` の書き間違いで移行まで止めないため)。
|
|
319
|
+
* en: `check.ignore` only — used by migrate, which must not fail on allowTokens.
|
|
320
|
+
*/
|
|
321
|
+
export function loadCheckIgnore(configPath = null, cwd = process.cwd()) {
|
|
322
|
+
return normalizeCheckIgnore(readCheckSection(configPath, cwd));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 相対パス × ルール ID が除外設定にかかるか。cwd の外のパスにはかけない
|
|
327
|
+
* (`**\/*.stories.tsx` のような glob が `../other/…` に一致してしまうのを防ぐ)。
|
|
328
|
+
* en: Whether a cwd-relative path / rule pair is covered by an ignore entry.
|
|
329
|
+
* Paths outside cwd are never ignored, so `**` globs cannot reach them.
|
|
330
|
+
*
|
|
331
|
+
* @param {ReturnType<typeof normalizeCheckIgnore>} entries
|
|
332
|
+
* @param {string} relativePath `/` 区切りの cwd 相対パス
|
|
333
|
+
* @param {string} ruleId
|
|
334
|
+
*/
|
|
335
|
+
export function isPathIgnored(entries, relativePath, ruleId) {
|
|
336
|
+
if (entries.length === 0 || isOutsideCwd(relativePath)) return false;
|
|
337
|
+
return entries.some(
|
|
338
|
+
(entry) =>
|
|
339
|
+
(entry.rules === null || entry.rules.includes(ruleId)) &&
|
|
340
|
+
entry.matchers.some((matcher) => matcher.test(relativePath))
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 除外設定に書かれたルール ID のうち、`knownRuleIds` に無いものを返す。
|
|
346
|
+
* typo やプラグイン未導入で「除外したつもりが何も外していない」状態に気付けるようにする。
|
|
347
|
+
* en: Rule IDs referenced by ignore entries that no active rule has.
|
|
348
|
+
*/
|
|
349
|
+
export function unknownIgnoreRuleIds(entries, knownRuleIds) {
|
|
350
|
+
const known = new Set(knownRuleIds);
|
|
351
|
+
const unknown = new Set();
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
for (const id of entry.rules ?? []) {
|
|
354
|
+
if (!known.has(id)) unknown.add(id);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return [...unknown].sort();
|
|
358
|
+
}
|