sparkle-design-cli 2.4.2 → 2.5.0-beta.2
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 +60 -0
- package/README.md +48 -504
- package/bin/sparkle-design.js +86 -31
- package/docs/anti-patterns.md +92 -0
- package/docs/config.md +133 -0
- package/docs/manual-setup.md +117 -0
- package/docs/plugins.md +120 -0
- package/docs/theming.md +122 -0
- package/lib/anti-pattern-rules.js +564 -9
- package/lib/check.js +245 -19
- package/lib/load-plugins.js +6 -0
- package/lib/path-utils.js +120 -0
- package/lib/plugin-api.js +17 -1
- package/lib/rules-report.js +216 -0
- package/lib/spacing-scale.js +228 -0
- package/lib/stop-hook.js +158 -7
- package/lib/token-migration.js +888 -0
- package/package.json +7 -5
- package/templates/sparkle-variables/.github/workflows/validate.yml +21 -0
- package/templates/sparkle-variables/README.md +151 -0
- package/templates/sparkle-variables/colors.json +87 -87
- package/templates/sparkle-variables/gray.json +70 -70
- package/templates/sparkle-variables/radius.csv +1 -1
- package/templates/sparkle-variables/scripts/validate-tokens.mjs +734 -0
- package/templates/sparkle-variables/sparkle-design.template.css +1107 -373
package/lib/check.js
CHANGED
|
@@ -2,8 +2,13 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
BLOCKING_SEVERITIES,
|
|
5
6
|
BUILTIN_ANTI_PATTERN_GROUPS,
|
|
6
7
|
BUILTIN_MANUAL_REVIEW_REMINDERS,
|
|
8
|
+
DEFAULT_RULE_TARGETS,
|
|
9
|
+
DEFAULT_SEVERITY,
|
|
10
|
+
RULE_TARGET,
|
|
11
|
+
SEVERITY,
|
|
7
12
|
getCheckRules,
|
|
8
13
|
getManualReviewReminders,
|
|
9
14
|
} from './anti-pattern-rules.js';
|
|
@@ -11,7 +16,9 @@ import { loadAntiPatternPlugins } from './load-plugins.js';
|
|
|
11
16
|
import { MATCH_HELPERS } from './plugin-helpers.js';
|
|
12
17
|
import { REGEX, FONT_DOMAINS } from './constants.js';
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
// stop-hook 側も「target 未指定時の既定」を知る必要があるので export する。
|
|
20
|
+
// en: Exported so stop-hook can probe with the same default instead of hardcoding it.
|
|
21
|
+
export const DEFAULT_TARGET = 'src';
|
|
15
22
|
const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
16
23
|
const CSS_EXTENSIONS = new Set(['.css']);
|
|
17
24
|
|
|
@@ -19,6 +26,56 @@ const CSS_EXTENSIONS = new Set(['.css']);
|
|
|
19
26
|
// en: Synchronous built-in snapshot for backward-compatible export below.
|
|
20
27
|
const BUILTIN_RULES = getCheckRules(BUILTIN_ANTI_PATTERN_GROUPS);
|
|
21
28
|
|
|
29
|
+
const SEVERITY_ORDER = [SEVERITY.ERROR, SEVERITY.WARNING, SEVERITY.INFO];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* finding の severity を既知の値に丸める。
|
|
33
|
+
*
|
|
34
|
+
* `getCheckRules` を通れば正規化済みだが、`collectFindings` / `createCheckReport`
|
|
35
|
+
* は public export なので rule を直接渡す経路がある。そこを素通しにすると
|
|
36
|
+
* 「並び順は error 扱いなのに exit code 判定では非 error」という**判定ごとに
|
|
37
|
+
* 結論が変わる**状態になり、件数の内訳も合わなくなる。読み出し側を 1 つの
|
|
38
|
+
* 関数に集約して、どの判定でも同じ値を見るようにする。
|
|
39
|
+
* en: Coerce to a known severity at every read site so ordering, counting and
|
|
40
|
+
* the exit-code decision can never disagree about the same finding.
|
|
41
|
+
*/
|
|
42
|
+
function coerceSeverity(severity) {
|
|
43
|
+
return SEVERITY_ORDER.includes(severity) ? severity : DEFAULT_SEVERITY;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function severityRank(severity) {
|
|
47
|
+
return SEVERITY_ORDER.indexOf(coerceSeverity(severity));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `--strict` / stop-hook が失敗として扱う findings。
|
|
52
|
+
* `warning` / `info` は報告のみで exit code を変えない(issue #74)。
|
|
53
|
+
* en: Only blocking-severity findings affect the exit code.
|
|
54
|
+
*/
|
|
55
|
+
function blockingFindings(findings) {
|
|
56
|
+
return findings.filter((finding) => BLOCKING_SEVERITIES.has(coerceSeverity(finding.severity)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function countBySeverity(findings) {
|
|
60
|
+
const counts = Object.fromEntries(SEVERITY_ORDER.map((severity) => [severity, 0]));
|
|
61
|
+
for (const finding of findings) {
|
|
62
|
+
counts[coerceSeverity(finding.severity)] += 1;
|
|
63
|
+
}
|
|
64
|
+
return counts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ruleAppliesTo(rule, fileKind) {
|
|
68
|
+
return (rule.targets ?? DEFAULT_RULE_TARGETS).includes(fileKind);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// NOTE: トークンを定義している CSS(CLI の生成物など)を使用側と取り違えない
|
|
72
|
+
// ための判定は、ここではなく `anti-pattern-rules.js` の `migrationMatcher` が
|
|
73
|
+
// 内容ベースで行う(同じファイルが宣言している変数への参照は報告しない)。
|
|
74
|
+
// ファイル名で除外していたときは `generate --scope`(`-o` 必須で出力名が任意)
|
|
75
|
+
// の生成物が素通りしていた。
|
|
76
|
+
// en: Definition-vs-usage is decided by content in migrationMatcher, not by
|
|
77
|
+
// filename — `generate --scope` output has an arbitrary name.
|
|
78
|
+
|
|
22
79
|
const SPARKLE_HEAD_JSX_PATTERN = /<SparkleHead\s*\/?\s*>/;
|
|
23
80
|
// CSP key が「実際の設定 key として記述されている」コンテキストに絞る。
|
|
24
81
|
// 以前は単純に `/content-security-policy|.../i` だったため、コメント行
|
|
@@ -46,9 +103,28 @@ function toRelativeReportPath(filePath) {
|
|
|
46
103
|
/**
|
|
47
104
|
* ディレクトリを1回だけ走査し、テキストファイルと CSS ファイルを同時に収集する
|
|
48
105
|
*/
|
|
49
|
-
function collectFiles(targetPath, textFiles, cssFiles, visited) {
|
|
106
|
+
function collectFiles(targetPath, textFiles, cssFiles, visited, isUserTarget = false) {
|
|
50
107
|
if (!fs.existsSync(targetPath)) {
|
|
51
|
-
|
|
108
|
+
// cwd の案内を出してよいのは、ユーザーが渡した target を解決した 1 回目だけ。
|
|
109
|
+
// 再帰の途中で欠けるのは壊れた symlink や走査中の削除で、パスは既に絶対だから
|
|
110
|
+
// cwd は無関係。そこで「作業ディレクトリを確認してください」と言うと、
|
|
111
|
+
// 存在しない原因を追わせることになる。
|
|
112
|
+
// en: Only the user-supplied target can be a cwd problem. Deeper misses come
|
|
113
|
+
// from broken symlinks or concurrent deletion and are already absolute, so
|
|
114
|
+
// blaming cwd there sends the reader after a cause that does not exist.
|
|
115
|
+
if (!isUserTarget) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Target path does not exist: ${targetPath}\n` +
|
|
118
|
+
` 走査中にパスが解決できませんでした(壊れた symlink か、走査中に削除された可能性があります)。` +
|
|
119
|
+
` / Could not resolve this path while walking the tree (broken symlink or concurrent deletion).`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Target path does not exist: ${targetPath}\n` +
|
|
124
|
+
` 相対 path は現在の作業ディレクトリ(${process.cwd()})基準で解決しています。` +
|
|
125
|
+
`別のディレクトリから実行していないか確認してください。` +
|
|
126
|
+
` / Relative targets resolve against the current working directory.`
|
|
127
|
+
);
|
|
52
128
|
}
|
|
53
129
|
|
|
54
130
|
const realPath = fs.realpathSync(targetPath);
|
|
@@ -140,23 +216,37 @@ function isSuppressed(ruleId, contentLines, lineNumber) {
|
|
|
140
216
|
return false;
|
|
141
217
|
}
|
|
142
218
|
|
|
143
|
-
function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
219
|
+
function collectFindings(filePath, content, rules = BUILTIN_RULES, options = {}) {
|
|
220
|
+
const fileKind = options.fileKind ?? RULE_TARGET.SOURCE;
|
|
221
|
+
const onRuleError = options.onRuleError;
|
|
144
222
|
const findings = [];
|
|
145
223
|
const contentLines = content.split(/\r?\n/);
|
|
146
|
-
const pushFinding = (rule, index, snippet) => {
|
|
224
|
+
const pushFinding = (rule, index, snippet, recommendationOverride) => {
|
|
147
225
|
const line = getLineNumber(content, index ?? 0);
|
|
148
226
|
if (isSuppressed(rule.id, contentLines, line)) return;
|
|
149
227
|
findings.push({
|
|
150
228
|
filePath,
|
|
151
229
|
id: rule.id,
|
|
230
|
+
// severity は getCheckRules で正規化済み。プラグイン rule が
|
|
231
|
+
// getCheckRules を経由せず直接渡されるテスト経路のために既定値も持たせる。
|
|
232
|
+
// en: Normalized in getCheckRules; the fallback covers rules injected
|
|
233
|
+
// directly in tests without going through it.
|
|
234
|
+
severity: coerceSeverity(rule.severity),
|
|
152
235
|
description: rule.description,
|
|
153
|
-
|
|
236
|
+
// 移行ルールのように「マッチ 1 件ごとに移行先が変わる」ものは
|
|
237
|
+
// rule.match が hit ごとの recommendation を返す。無ければルール既定を使う。
|
|
238
|
+
// en: Per-occurrence recommendation from rule.match wins over the
|
|
239
|
+
// rule-level default (migration targets differ per match).
|
|
240
|
+
recommendation: recommendationOverride ?? rule.recommendation,
|
|
154
241
|
line,
|
|
155
242
|
snippet,
|
|
156
243
|
});
|
|
157
244
|
};
|
|
158
245
|
|
|
159
246
|
for (const rule of rules) {
|
|
247
|
+
// ルールごとに対象ファイル種別が違う(既定は source のみ)。
|
|
248
|
+
if (!ruleAppliesTo(rule, fileKind)) continue;
|
|
249
|
+
|
|
160
250
|
// rule ごとに try/catch で隔離する。1 つのプラグイン rule の throw / malformed
|
|
161
251
|
// RegExp で sparkle-design-cli check 全体が落ちるのを防ぐ。loadAntiPatternPlugins の
|
|
162
252
|
// 「warn して skip」と同じ failure mode に揃える。
|
|
@@ -178,7 +268,7 @@ function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
|
178
268
|
throw new TypeError(`rule.match must return an array, got ${typeof hits}`);
|
|
179
269
|
}
|
|
180
270
|
for (const hit of hits) {
|
|
181
|
-
pushFinding(rule, hit?.index ?? 0, formatSnippet(hit?.text ?? ''));
|
|
271
|
+
pushFinding(rule, hit?.index ?? 0, formatSnippet(hit?.text ?? ''), hit?.recommendation);
|
|
182
272
|
}
|
|
183
273
|
continue;
|
|
184
274
|
}
|
|
@@ -193,9 +283,17 @@ function collectFindings(filePath, content, rules = BUILTIN_RULES) {
|
|
|
193
283
|
}
|
|
194
284
|
} catch (error) {
|
|
195
285
|
const message = error?.message ?? String(error);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
286
|
+
// ルールが落ちた = そのルールについては「違反ゼロ」ではなく「検査していない」。
|
|
287
|
+
// 呼び出し側に記録させてレポートに載せる(無ければ従来どおり warn するだけ)。
|
|
288
|
+
// en: A crashed rule means "not checked", not "clean" — hand it to the
|
|
289
|
+
// caller so it can surface in the report instead of only on stderr.
|
|
290
|
+
if (typeof onRuleError === 'function') {
|
|
291
|
+
onRuleError({ ruleId: rule.id ?? '(unknown)', filePath, message });
|
|
292
|
+
} else {
|
|
293
|
+
console.warn(
|
|
294
|
+
`⚠️ sparkle-design-cli: rule "${rule.id ?? '(unknown)'}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
199
297
|
}
|
|
200
298
|
}
|
|
201
299
|
|
|
@@ -214,6 +312,7 @@ function collectFontImportFindings(cssFiles) {
|
|
|
214
312
|
findings.push({
|
|
215
313
|
filePath,
|
|
216
314
|
id: 'font-import-in-css',
|
|
315
|
+
severity: SEVERITY.ERROR,
|
|
217
316
|
description:
|
|
218
317
|
'CSS にフォント @import が残っています。SparkleHead コンポーネントに移行してください。',
|
|
219
318
|
recommendation:
|
|
@@ -293,6 +392,7 @@ function collectNextjsCspFindings(cwd) {
|
|
|
293
392
|
findings.push({
|
|
294
393
|
filePath: configPath,
|
|
295
394
|
id: 'csp-font-block',
|
|
395
|
+
severity: SEVERITY.ERROR,
|
|
296
396
|
description: `CSP ヘッダーが設定されていますが、${missing} が許可されていない可能性があります。`,
|
|
297
397
|
recommendation: `style-src に ${FONT_DOMAINS.GOOGLEAPIS} を、font-src に ${FONT_DOMAINS.GSTATIC} を追加してください。`,
|
|
298
398
|
line: 1,
|
|
@@ -315,10 +415,10 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
315
415
|
|
|
316
416
|
// 1回のディレクトリ走査でテキストファイルと CSS ファイルを同時に収集
|
|
317
417
|
for (const target of resolvedTargets) {
|
|
318
|
-
collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited);
|
|
418
|
+
collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited, true);
|
|
319
419
|
}
|
|
320
420
|
|
|
321
|
-
const checkedFiles = [...textFiles]
|
|
421
|
+
const checkedFiles = [...textFiles, ...cssFiles]
|
|
322
422
|
.map((filePath) => toRelativeReportPath(filePath))
|
|
323
423
|
.sort((left, right) => left.localeCompare(right));
|
|
324
424
|
|
|
@@ -328,20 +428,67 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
328
428
|
fileContents.set(filePath, fs.readFileSync(filePath, 'utf8'));
|
|
329
429
|
}
|
|
330
430
|
|
|
431
|
+
// 落ちたルールは rule ID 単位で 1 回だけ warn する。catch が (rule × file) 単位
|
|
432
|
+
// なので、素直に warn すると 500 ファイルのリポジトリで同じ行が 500 回出て
|
|
433
|
+
// 本当の findings がスクロールバックから押し出される。
|
|
434
|
+
// en: Deduplicate by rule ID — the catch is per (rule, file), so warning on
|
|
435
|
+
// every file would bury the real findings.
|
|
436
|
+
const skippedRuleMap = new Map();
|
|
437
|
+
const onRuleError = ({ ruleId, filePath, message }) => {
|
|
438
|
+
const existing = skippedRuleMap.get(ruleId);
|
|
439
|
+
if (existing) {
|
|
440
|
+
existing.fileCount += 1;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
skippedRuleMap.set(ruleId, {
|
|
444
|
+
ruleId,
|
|
445
|
+
message,
|
|
446
|
+
firstFile: toRelativeReportPath(filePath),
|
|
447
|
+
fileCount: 1,
|
|
448
|
+
});
|
|
449
|
+
console.warn(
|
|
450
|
+
`⚠️ sparkle-design-cli: rule "${ruleId}" failed on ${toRelativeReportPath(filePath)} — skipping (${message})`
|
|
451
|
+
);
|
|
452
|
+
};
|
|
453
|
+
|
|
331
454
|
const findings = [...fileContents.entries()]
|
|
332
|
-
.flatMap(([filePath, content]) =>
|
|
455
|
+
.flatMap(([filePath, content]) =>
|
|
456
|
+
collectFindings(filePath, content, rules, { fileKind: RULE_TARGET.SOURCE, onRuleError })
|
|
457
|
+
)
|
|
333
458
|
.map((finding) => ({
|
|
334
459
|
...finding,
|
|
335
460
|
filePath: toRelativeReportPath(finding.filePath),
|
|
336
461
|
}));
|
|
337
462
|
|
|
463
|
+
// CSS も検査する。`--radius-halfModal` のような CSS 変数の直接参照は `.css` に
|
|
464
|
+
// こそ自然に書かれ、しかも消えてもビルドエラーにならず見た目だけ壊れる。
|
|
465
|
+
// 対象は `targets` に css を含むルールだけなので、既存ルールの挙動は変わらない。
|
|
466
|
+
// en: Scan CSS too — variable references live there and fail silently. Only
|
|
467
|
+
// rules that opted into `css` run, so existing rules are unaffected.
|
|
468
|
+
for (const filePath of cssFiles) {
|
|
469
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
470
|
+
findings.push(
|
|
471
|
+
...collectFindings(filePath, content, rules, {
|
|
472
|
+
fileKind: RULE_TARGET.CSS,
|
|
473
|
+
onRuleError,
|
|
474
|
+
}).map((finding) => ({ ...finding, filePath: toRelativeReportPath(finding.filePath) }))
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
338
478
|
const fontFindings = collectFontImportFindings(cssFiles);
|
|
339
479
|
findings.push(...fontFindings.map((f) => ({ ...f, filePath: toRelativeReportPath(f.filePath) })));
|
|
340
480
|
|
|
341
481
|
const cspFindings = collectNextjsCspFindings(process.cwd());
|
|
342
482
|
findings.push(...cspFindings.map((f) => ({ ...f, filePath: toRelativeReportPath(f.filePath) })));
|
|
343
483
|
|
|
484
|
+
// severity の高い順 → ファイル → 行 → ID。移行期は warning が大量に出るため、
|
|
485
|
+
// ファイル順だけで並べると本当に直すべき error が warning に埋もれる。
|
|
486
|
+
// 既存ルールはすべて error なので、error 同士の相対順は従来どおり保たれる。
|
|
487
|
+
// en: Sort by severity first so migration warnings can't bury errors. All
|
|
488
|
+
// pre-existing rules are errors, so their relative order is unchanged.
|
|
344
489
|
findings.sort((left, right) => {
|
|
490
|
+
const severityComparison = severityRank(left.severity) - severityRank(right.severity);
|
|
491
|
+
if (severityComparison !== 0) return severityComparison;
|
|
345
492
|
const fileComparison = left.filePath.localeCompare(right.filePath);
|
|
346
493
|
if (fileComparison !== 0) return fileComparison;
|
|
347
494
|
if (left.line !== right.line) return left.line - right.line;
|
|
@@ -361,6 +508,12 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
361
508
|
targets: resolvedTargets,
|
|
362
509
|
checkedFiles,
|
|
363
510
|
findings,
|
|
511
|
+
// 「ルールが落ちて検査されなかった」ことをレポートに載せる。stderr の warn
|
|
512
|
+
// だけだと JSON を読む CI / AI には見えず、findings 0 件・passed true を
|
|
513
|
+
// 「クリーン」と誤読する。
|
|
514
|
+
// en: Surface skipped rules in the report — a stderr-only warning is
|
|
515
|
+
// invisible to JSON consumers, who would read 0 findings as "clean".
|
|
516
|
+
skippedRules: [...skippedRuleMap.values()],
|
|
364
517
|
manualReviewReminders,
|
|
365
518
|
};
|
|
366
519
|
}
|
|
@@ -369,14 +522,29 @@ function printTextReport(report, options = {}) {
|
|
|
369
522
|
if (report.findings.length === 0) {
|
|
370
523
|
console.log('sparkle-design-cli check: no findings');
|
|
371
524
|
} else {
|
|
372
|
-
|
|
525
|
+
const counts = countBySeverity(report.findings);
|
|
526
|
+
const breakdown = SEVERITY_ORDER.filter((severity) => counts[severity] > 0)
|
|
527
|
+
.map((severity) => `${counts[severity]} ${severity}`)
|
|
528
|
+
.join(', ');
|
|
529
|
+
console.log(`sparkle-design-cli check: ${report.findings.length} finding(s) (${breakdown})\n`);
|
|
373
530
|
|
|
374
531
|
for (const finding of report.findings) {
|
|
375
|
-
|
|
532
|
+
const severity = coerceSeverity(finding.severity);
|
|
533
|
+
console.log(
|
|
534
|
+
`${finding.filePath}:${finding.line} [${severity}] [${finding.id}] ${finding.description}`
|
|
535
|
+
);
|
|
376
536
|
console.log(` Recommendation: ${finding.recommendation}`);
|
|
377
537
|
console.log(` Snippet: ${finding.snippet}`);
|
|
378
538
|
console.log('');
|
|
379
539
|
}
|
|
540
|
+
|
|
541
|
+
if (counts[SEVERITY.WARNING] > 0 || counts[SEVERITY.INFO] > 0) {
|
|
542
|
+
console.log(
|
|
543
|
+
'Note: warning / info は報告のみで exit code には影響しません(--strict でも失敗しません)。' +
|
|
544
|
+
' / warning and info findings are informational and never affect the exit code.'
|
|
545
|
+
);
|
|
546
|
+
console.log('');
|
|
547
|
+
}
|
|
380
548
|
}
|
|
381
549
|
|
|
382
550
|
const reminders = report.manualReviewReminders ?? [];
|
|
@@ -406,14 +574,43 @@ function printTextReport(report, options = {}) {
|
|
|
406
574
|
console.log('=========================================================================');
|
|
407
575
|
}
|
|
408
576
|
|
|
409
|
-
|
|
577
|
+
const skipped = report.skippedRules ?? [];
|
|
578
|
+
if (skipped.length > 0) {
|
|
579
|
+
console.error('');
|
|
580
|
+
console.error(
|
|
581
|
+
`⚠️ ${skipped.length} 件のルールが実行に失敗して検査されていません(違反ゼロではなく「未検査」です):`
|
|
582
|
+
);
|
|
583
|
+
for (const entry of skipped) {
|
|
584
|
+
console.error(
|
|
585
|
+
` - [${entry.ruleId}] ${entry.message}(${entry.firstFile} を含む計 ${entry.fileCount} ファイル)`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
console.error('');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (options.strict && hasBlockingIssues(report)) {
|
|
410
592
|
console.error('sparkle-design-cli check: failed because --strict was specified');
|
|
411
593
|
}
|
|
412
594
|
}
|
|
413
595
|
|
|
596
|
+
/**
|
|
597
|
+
* `--strict` / stop-hook を失敗させるべきか。
|
|
598
|
+
*
|
|
599
|
+
* blocking severity の findings に加えて、**実行に失敗したルールがある場合も失敗**
|
|
600
|
+
* とする。ルールが落ちた状態は「違反ゼロ」ではなく「検査していない」であり、
|
|
601
|
+
* ここを通してしまうと検査が死んでいることに誰も気付けない。
|
|
602
|
+
* en: Also fail when a rule crashed — that state is "not checked", not "clean".
|
|
603
|
+
*/
|
|
604
|
+
function hasBlockingIssues(report) {
|
|
605
|
+
return blockingFindings(report.findings).length > 0 || (report.skippedRules ?? []).length > 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
414
608
|
function printJsonReport(report, options = {}) {
|
|
415
609
|
const strictMode = Boolean(options.strict);
|
|
416
|
-
const
|
|
610
|
+
const counts = countBySeverity(report.findings);
|
|
611
|
+
const blockingCount = blockingFindings(report.findings).length;
|
|
612
|
+
const skippedCount = (report.skippedRules ?? []).length;
|
|
613
|
+
const passed = !(strictMode && hasBlockingIssues(report));
|
|
417
614
|
const reminders = report.manualReviewReminders ?? [];
|
|
418
615
|
|
|
419
616
|
console.log(
|
|
@@ -423,6 +620,15 @@ function printJsonReport(report, options = {}) {
|
|
|
423
620
|
targetCount: report.targets.length,
|
|
424
621
|
checkedFileCount: report.checkedFiles.length,
|
|
425
622
|
findingCount: report.findings.length,
|
|
623
|
+
// severity 別内訳。`blockingFindingCount` だけが exit code に効く。
|
|
624
|
+
// en: Only blockingFindingCount affects the exit code.
|
|
625
|
+
severityCounts: counts,
|
|
626
|
+
blockingFindingCount: blockingCount,
|
|
627
|
+
// 実行に失敗して検査されなかったルールの数。0 でないなら findings が
|
|
628
|
+
// 0 件でも「クリーン」とは言えない(--strict は失敗する)。
|
|
629
|
+
// en: Rules that crashed and therefore did not run. Non-zero means the
|
|
630
|
+
// report is incomplete, so --strict fails even with zero findings.
|
|
631
|
+
skippedRuleCount: skippedCount,
|
|
426
632
|
reminderCount: reminders.length,
|
|
427
633
|
// AI 向けの attention フラグ。reminder が 1 件でもあれば true になり、
|
|
428
634
|
// AI は最終 response で各 reminder ID を echo する必要がある。
|
|
@@ -447,7 +653,13 @@ function printJsonReport(report, options = {}) {
|
|
|
447
653
|
);
|
|
448
654
|
}
|
|
449
655
|
|
|
450
|
-
|
|
656
|
+
/**
|
|
657
|
+
* `check` 本体。レポートを出力し、レポートそのものと「失敗させるべきか」を返す。
|
|
658
|
+
* stop-hook のように findings の内訳まで見たい呼び出し側のために report を返す。
|
|
659
|
+
* en: Runs the check, prints the report, and returns both the report and the
|
|
660
|
+
* blocking decision (stop-hook needs the breakdown, not just the boolean).
|
|
661
|
+
*/
|
|
662
|
+
export async function runCheck(targets = [], options = {}) {
|
|
451
663
|
// Plugins are discovered from the consumer project's package.json deps. Errors are
|
|
452
664
|
// already warn-and-skip inside loadAntiPatternPlugins, so we just consume the result.
|
|
453
665
|
// en: Auto-discover plugins from cwd; loader handles its own failure reporting.
|
|
@@ -467,12 +679,26 @@ export async function checkProject(targets = [], options = {}) {
|
|
|
467
679
|
printTextReport(report, options);
|
|
468
680
|
}
|
|
469
681
|
|
|
470
|
-
return report
|
|
682
|
+
return { report, blocked: hasBlockingIssues(report) };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export async function checkProject(targets = [], options = {}) {
|
|
686
|
+
const { blocked } = await runCheck(targets, options);
|
|
687
|
+
// 戻り値は「exit code を 1 にすべきか」。severity 導入前は findings が
|
|
688
|
+
// 1 件でもあれば true だったが、既存ルールはすべて error なので既存プロジェクト
|
|
689
|
+
// での挙動は変わらない。beta 中の移行 warning で CI や stop-hook を止めない。
|
|
690
|
+
// en: Returns "should this fail?" — unchanged for existing projects since all
|
|
691
|
+
// pre-existing rules are errors; beta migration warnings never block.
|
|
692
|
+
return blocked;
|
|
471
693
|
}
|
|
472
694
|
|
|
473
695
|
export {
|
|
474
696
|
BUILTIN_RULES as RULES,
|
|
697
|
+
blockingFindings,
|
|
698
|
+
coerceSeverity,
|
|
475
699
|
collectFindings,
|
|
700
|
+
countBySeverity,
|
|
476
701
|
createCheckReport,
|
|
702
|
+
hasBlockingIssues,
|
|
477
703
|
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
478
704
|
};
|
package/lib/load-plugins.js
CHANGED
|
@@ -113,6 +113,12 @@ export async function loadAntiPatternPlugins({ cwd = process.cwd() } = {}) {
|
|
|
113
113
|
validatePluginShape(candidate, `${packageName} (${entry})`);
|
|
114
114
|
groups.push(...candidate.groups);
|
|
115
115
|
record.groupIds = candidate.groups.map((group) => group.id);
|
|
116
|
+
// group の**実体**も持たせる。ID だけだと、別プラグインが同じ group ID を
|
|
117
|
+
// 宣言したときに所有者を復元できない(plugin-api は ID の名前空間が
|
|
118
|
+
// ビルトインとも他プラグインとも共有されると明記している)。
|
|
119
|
+
// en: Keep the group objects, not just ids — ids are not unique across
|
|
120
|
+
// plugins, so ownership can't be reconstructed from them afterwards.
|
|
121
|
+
record.groups = candidate.groups;
|
|
116
122
|
if (Array.isArray(candidate.manualReviewReminders)) {
|
|
117
123
|
reminders.push(...candidate.manualReviewReminders);
|
|
118
124
|
}
|
package/lib/path-utils.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import path from 'path';
|
|
2
3
|
|
|
3
4
|
// setup.js 側と共通の「unsafe な shell メタ文字や quote」を弾く pattern。
|
|
@@ -64,3 +65,122 @@ export function assertSafeRelativePath(inputPath, label) {
|
|
|
64
65
|
}
|
|
65
66
|
return normalized;
|
|
66
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 相対 target を解決する基準ディレクトリの**候補**を、優先順に列挙する。
|
|
71
|
+
*
|
|
72
|
+
* 1. `startDir`(=通常は cwd)… 従来の挙動。ここで解決できるなら何も変えない
|
|
73
|
+
* 2. `CLAUDE_PROJECT_DIR` … Claude Code が hook 実行時に渡す絶対 path
|
|
74
|
+
* 3. `sparkle.config.json` を持つ祖先(近い順)… CLI 自身の設定ファイル
|
|
75
|
+
* 4. `package.json` を持つ祖先(近い順)… monorepo では workspace → repo root の順
|
|
76
|
+
*
|
|
77
|
+
* monorepo で「最も近い package.json」だけを見ると、cwd が `apps/web` のときに
|
|
78
|
+
* 基準も `apps/web` になり、hook に書かれた `apps/web/app` が二重化するという
|
|
79
|
+
* issue #85 と同じ壊れ方を再現してしまう。だから 1 つに決め打たず、repo root まで
|
|
80
|
+
* 含めて候補を並べ、呼び出し側が「target が実在するか」で選べるようにする。
|
|
81
|
+
*
|
|
82
|
+
* en: Enumerate candidate base directories in priority order. Picking a single
|
|
83
|
+
* "project root" is not enough: in a monorepo the nearest package.json is the
|
|
84
|
+
* workspace package, which reproduces the very bug this is meant to fix.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} [startDir]
|
|
87
|
+
* @param {{ existsSync?: (p: string) => boolean, env?: Record<string, string> }} [deps]
|
|
88
|
+
* @returns {Array<{ dir: string, source: string }>} 重複を除いた候補(優先順)
|
|
89
|
+
*/
|
|
90
|
+
export function projectRootCandidates(startDir = process.cwd(), deps = {}) {
|
|
91
|
+
const exists = deps.existsSync ?? fs.existsSync;
|
|
92
|
+
const env = deps.env ?? process.env;
|
|
93
|
+
const candidates = [{ dir: path.resolve(startDir), source: 'cwd' }];
|
|
94
|
+
|
|
95
|
+
const fromEnv = env.CLAUDE_PROJECT_DIR;
|
|
96
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() && exists(fromEnv)) {
|
|
97
|
+
candidates.push({ dir: path.resolve(fromEnv), source: 'CLAUDE_PROJECT_DIR' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const marker of PROJECT_ROOT_MARKERS) {
|
|
101
|
+
for (const dir of ancestorsContaining(path.resolve(startDir), marker, exists)) {
|
|
102
|
+
candidates.push({ dir, source: marker });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
return candidates.filter(({ dir }) => {
|
|
108
|
+
if (seen.has(dir)) return false;
|
|
109
|
+
seen.add(dir);
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `targets` がすべて実在する最初の候補ディレクトリを選ぶ。
|
|
116
|
+
*
|
|
117
|
+
* hook は AI の作業途中に発火するため、実行時の cwd を前提にできない。AI が調査で
|
|
118
|
+
* `cd apps/web` したまま戻していないと `apps/web/app` が `apps/web/apps/web/app` に
|
|
119
|
+
* 解決されて落ちる(issue #85)。候補を順に当てて実在するものを採れば、配布済みの
|
|
120
|
+
* `.claude/settings.json` を書き換えずに直る。
|
|
121
|
+
*
|
|
122
|
+
* cwd を最優先に置いているので、**今まで動いていた呼び出しの挙動は一切変わらない**。
|
|
123
|
+
* どの候補でも解決できないときも cwd を返し、エラーメッセージは呼び出し側
|
|
124
|
+
* (`check` の "Target path does not exist")に任せる。
|
|
125
|
+
*
|
|
126
|
+
* en: Pick the first candidate base where every target exists. cwd comes first,
|
|
127
|
+
* so anything that already worked keeps working; the fallback only kicks in for
|
|
128
|
+
* the broken case this exists to fix.
|
|
129
|
+
*
|
|
130
|
+
* @param {string[]} targets 相対 path の配列(空なら cwd を返す)
|
|
131
|
+
* @param {{ startDir?: string, existsSync?: (p: string) => boolean, env?: Record<string, string> }} [options]
|
|
132
|
+
* @returns {{ dir: string, source: string, resolved: boolean }}
|
|
133
|
+
*/
|
|
134
|
+
export function resolveTargetBaseDir(targets, options = {}) {
|
|
135
|
+
const startDir = path.resolve(options.startDir ?? process.cwd());
|
|
136
|
+
const exists = options.existsSync ?? fs.existsSync;
|
|
137
|
+
const fallback = { dir: startDir, source: 'cwd', resolved: false };
|
|
138
|
+
|
|
139
|
+
if (!Array.isArray(targets) || targets.length === 0) return fallback;
|
|
140
|
+
|
|
141
|
+
for (const candidate of projectRootCandidates(startDir, options)) {
|
|
142
|
+
if (targets.every((target) => exists(path.resolve(candidate.dir, target)))) {
|
|
143
|
+
return { ...candidate, resolved: true };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return fallback;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const PROJECT_ROOT_MARKERS = ['sparkle.config.json', 'package.json'];
|
|
151
|
+
|
|
152
|
+
// 探索を止める境界。ここより上は「別のプロジェクト」とみなす。
|
|
153
|
+
//
|
|
154
|
+
// 境界を設けないと、プロジェクトが別の `package.json` を持つディレクトリの下に
|
|
155
|
+
// 置かれている場合(`/workspace/package.json` の下に `/workspace/project/`)、
|
|
156
|
+
// **target 名を打ち間違えたときに外側の同名 path が拾われて、無関係なファイルを
|
|
157
|
+
// 黙って検査する**。「target が無い」と報告されるべき場面で静かに別の場所を見る
|
|
158
|
+
// のは、このルール一式が防ごうとしている silent failure そのもの。
|
|
159
|
+
//
|
|
160
|
+
// git worktree では `.git` がファイル(`gitdir:` を書いた 1 行)になるが、
|
|
161
|
+
// `existsSync` はどちらでも true を返すので同じく境界として機能する。
|
|
162
|
+
// en: Bound the walk at the repository root. Without it, a typo'd target could
|
|
163
|
+
// resolve against an unrelated outer project and be checked silently.
|
|
164
|
+
const REPOSITORY_BOUNDARY = '.git';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `startDir` から root 方向へ辿り、`marker` を含むディレクトリを近い順に返す。
|
|
168
|
+
* リポジトリ境界(`.git`)を含むディレクトリまで見たら、そこで打ち切る。
|
|
169
|
+
* en: All ancestors (nearest first) that contain marker, stopping at the repo root.
|
|
170
|
+
*/
|
|
171
|
+
function ancestorsContaining(startDir, marker, exists) {
|
|
172
|
+
const found = [];
|
|
173
|
+
let current = startDir;
|
|
174
|
+
// path.dirname('/') === '/' なので、変化しなくなった時点が終端。
|
|
175
|
+
// en: dirname stops changing at the filesystem root — that's the loop guard.
|
|
176
|
+
for (;;) {
|
|
177
|
+
if (exists(path.join(current, marker))) found.push(current);
|
|
178
|
+
// 境界ディレクトリ自身は候補に含めてから打ち切る(repo root がまさに
|
|
179
|
+
// 探している基準であることが多いため)。
|
|
180
|
+
// en: Include the boundary directory itself, then stop.
|
|
181
|
+
if (exists(path.join(current, REPOSITORY_BOUNDARY))) return found;
|
|
182
|
+
const parent = path.dirname(current);
|
|
183
|
+
if (parent === current) return found;
|
|
184
|
+
current = parent;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/lib/plugin-api.js
CHANGED
|
@@ -27,13 +27,29 @@
|
|
|
27
27
|
* featureSection?: string, // markdown emitted into the published anti-pattern doc (esa)
|
|
28
28
|
* jsdocTargets?: JSDocTarget[], // optional — JSDoc injection targets in the plugin's own source
|
|
29
29
|
* check?: {
|
|
30
|
+
* severity?: 'error' | 'warning' | 'info',
|
|
31
|
+
* // defaults to 'error'. Only 'error' affects the exit code
|
|
32
|
+
* // of `check --strict` / the stop hook; 'warning' / 'info'
|
|
33
|
+
* // are reported but never fail. An unknown value warns and
|
|
34
|
+
* // falls back to 'error'.
|
|
35
|
+
* targets?: Array<'source' | 'css'>,
|
|
36
|
+
* // which file kinds the rule runs on.
|
|
37
|
+
* // defaults to ['source'] (.js/.jsx/.ts/.tsx).
|
|
38
|
+
* // Add 'css' for rules that match CSS variable
|
|
39
|
+
* // references or @apply utilities. Note: the built-in
|
|
40
|
+
* // migration rules additionally skip references to
|
|
41
|
+
* // variables the same file declares (definition side),
|
|
42
|
+
* // which is how generated stylesheets are excluded —
|
|
43
|
+
* // by content, not by filename.
|
|
30
44
|
* description: string, // shown in `check` findings
|
|
31
45
|
* recommendation: string, // shown in `check` findings
|
|
32
46
|
* pattern?: RegExp, // simple regex check. MUST have the global (`g`) flag.
|
|
33
47
|
* // mutually exclusive with `match`.
|
|
34
|
-
* match?: (content: string, helpers: MatchHelpers) => Array<{ index: number, text: string }>,
|
|
48
|
+
* match?: (content: string, helpers: MatchHelpers) => Array<{ index: number, text: string, recommendation?: string }>,
|
|
35
49
|
* // opt-in API for complex matching (2-pass, AST, etc.).
|
|
36
50
|
* // `helpers` (2nd arg) is injected by the CLI — see ## MatchHelpers.
|
|
51
|
+
* // A per-hit `recommendation` overrides `check.recommendation`,
|
|
52
|
+
* // for rules whose fix differs per occurrence (e.g. token migration).
|
|
37
53
|
* },
|
|
38
54
|
* }
|
|
39
55
|
*
|