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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `sparkle-design-cli rules` の実装。
3
+ *
4
+ * ## なぜコマンドなのか
5
+ *
6
+ * ルール一覧はこれまで README / `check --help` / internal の setup-guide /
7
+ * コンポーネントの JSDoc / スキルの features.md と 6 箇所に写しが散らばっていて、
8
+ * 実際に `check --help` は組み込み 18 件のうち 16 件しか載せておらず、しかも
9
+ * `check` のルールではない項目を 2 件含んだまま腐っていた。
10
+ *
11
+ * それ以上に本質的なのは、**静的なドキュメントには「有効なルール」を書けない**
12
+ * ということ。プラグインはコンシューマの package.json から自動発見されるので、
13
+ * 実際に効いているルールはプロジェクトごとに違う。組み込み 18 件を列挙した
14
+ * ドキュメントは、プラグインを入れた利用者にとって最初から不正確になる。
15
+ *
16
+ * en: The rule list used to be copied into six places and had already rotted.
17
+ * More fundamentally, static docs cannot state which rules are *active* — plugins
18
+ * are discovered from the consumer's package.json, so the effective set differs
19
+ * per project. This command is the only place that can answer that.
20
+ */
21
+
22
+ import {
23
+ BUILTIN_ANTI_PATTERN_GROUPS,
24
+ RULE_TARGET,
25
+ SEVERITY,
26
+ getCheckRules,
27
+ } from './anti-pattern-rules.js';
28
+ import { loadAntiPatternPlugins } from './load-plugins.js';
29
+
30
+ /** 利用者の手元に `docs/` は無いので、案内は解決できる場所を指す。 */
31
+ const DOCS_URL = 'https://github.com/goodpatch/sparkle-design-cli/blob/main/docs';
32
+
33
+ /** 表示順。`check` のレポートと同じ「重いものが先」に揃える。 */
34
+ const SEVERITY_ORDER = [SEVERITY.ERROR, SEVERITY.WARNING, SEVERITY.INFO];
35
+
36
+ const SEVERITY_NOTE = {
37
+ [SEVERITY.ERROR]: '例外なし。--strict / stop-hook を失敗させる',
38
+ [SEVERITY.WARNING]: '原則ダメだが理由があれば例外可。exit code は変えない',
39
+ [SEVERITY.INFO]: '参考。従わなくてもよい選択肢',
40
+ };
41
+
42
+ /**
43
+ * 組み込み + プラグインのルールを集める。
44
+ *
45
+ * プラグイン由来かどうかを `source` に持たせる。利用者が「なぜこのルールが
46
+ * 出るのか」を追えるようにするためで、これが分からないと組み込みの不具合と
47
+ * プラグインの不具合を切り分けられない。
48
+ */
49
+ export async function collectActiveRules(options = {}) {
50
+ // cwd を受けるのはテストのためだけではない。プラグインは**そのディレクトリの**
51
+ // package.json から発見されるので、どこを見たかで結果が変わる。既定は
52
+ // loadAntiPatternPlugins 側の既定(process.cwd())に委ねる。
53
+ // en: The discovered set depends on which directory is inspected.
54
+ const { groups: pluginGroups, discovered } = await loadAntiPatternPlugins(
55
+ options.cwd ? { cwd: options.cwd } : undefined
56
+ );
57
+
58
+ // 出所は**結合前**に決める。結合後に ID で引き当てる方式だと、プラグインが
59
+ // 組み込みと同じ ID を名乗ったときに両方が builtin として表示され、
60
+ // 「見慣れないルールが出た」ときの切り分けができなくなる。ID の重複は
61
+ // getCheckRules も check 側も弾かない(両方のルールが実際に動く)ので、
62
+ // ここで潰さず、出所を正しく付けたうえで衝突として見せる。
63
+ // en: Derive the origin before merging. Looking it up by id afterwards would
64
+ // label a plugin rule that reuses a built-in id as `builtin`, defeating the
65
+ // whole point of the field. Duplicate ids are not rejected anywhere — both
66
+ // rules really run — so surface the clash instead of hiding it.
67
+ const shape = (source) => (rule) => ({
68
+ id: rule.id,
69
+ severity: rule.severity,
70
+ description: rule.description,
71
+ targets: rule.targets,
72
+ source,
73
+ });
74
+
75
+ const rules = [
76
+ ...getCheckRules(BUILTIN_ANTI_PATTERN_GROUPS).map(shape('builtin')),
77
+ ...getCheckRules(pluginGroups).map(shape('plugin')),
78
+ ];
79
+
80
+ // 各プラグインが実際に出した rule は、**そのプラグインの group から直接**求める。
81
+ // 「全 rule の中に同じ id があるか」で絞ると、`check` を持たない group の id が
82
+ // 組み込みや別プラグインの rule id と衝突したときに、そのプラグインが所有して
83
+ // いない ID を並べてしまう(`check` は plugin-api で optional なので実際に起きる)。
84
+ // en: Derive each plugin's rule ids from its own groups. Matching against the
85
+ // merged list would attribute a built-in (or another plugin's) rule to a plugin
86
+ // whose `check`-less group merely reuses that id.
87
+ // 所有関係はローダーが持つ `record.groups`(group の実体)から取る。
88
+ // ID で引き直すと、別プラグインが同じ group ID を宣言したときに互いの分まで
89
+ // 拾ってしまう(実測で両方が 2 件ずつ持つ状態になった)。
90
+ // en: Ownership comes from the loader's group objects. Re-deriving it from ids
91
+ // credits each plugin with the other's rules when two declare the same id.
92
+ const plugins = discovered.map((record) => ({
93
+ ...record,
94
+ ruleIds: getCheckRules(record.groups ?? []).map((rule) => rule.id),
95
+ }));
96
+
97
+ return { rules, plugins };
98
+ }
99
+
100
+ function severityRank(severity) {
101
+ const index = SEVERITY_ORDER.indexOf(severity);
102
+ return index === -1 ? SEVERITY_ORDER.length : index;
103
+ }
104
+
105
+ function sortForDisplay(rules) {
106
+ return [...rules].sort(
107
+ (a, b) => severityRank(a.severity) - severityRank(b.severity) || a.id.localeCompare(b.id)
108
+ );
109
+ }
110
+
111
+ /** `--format json` の出力。CI や AI から読む前提なので件数の内訳も添える。 */
112
+ export function renderRulesJson({ rules, plugins }) {
113
+ const counts = Object.fromEntries(
114
+ SEVERITY_ORDER.map((severity) => [
115
+ severity,
116
+ rules.filter((r) => r.severity === severity).length,
117
+ ])
118
+ );
119
+ return JSON.stringify(
120
+ {
121
+ summary: {
122
+ ruleCount: rules.length,
123
+ duplicateIds: [
124
+ ...new Set(
125
+ rules
126
+ .filter((rule, i) => rules.findIndex((r) => r.id === rule.id) !== i)
127
+ .map((r) => r.id)
128
+ ),
129
+ ],
130
+ severityCounts: counts,
131
+ builtinCount: rules.filter((r) => r.source === 'builtin').length,
132
+ pluginCount: rules.filter((r) => r.source === 'plugin').length,
133
+ },
134
+ rules: sortForDisplay(rules),
135
+ plugins: plugins.map((record) => ({
136
+ packageName: record.packageName,
137
+ status: record.status,
138
+ error: record.error ?? null,
139
+ // `ruleIds` は collectActiveRules がプラグインごとに算出したもの
140
+ // (`check` を持つ group だけ)。`groupIds` は宣言した group 全部。
141
+ // en: `ruleIds` counts only groups that actually define a check.
142
+ ruleIds: record.ruleIds ?? [],
143
+ groupIds: record.groupIds ?? [],
144
+ })),
145
+ },
146
+ null,
147
+ 2
148
+ );
149
+ }
150
+
151
+ /** 既定のテキスト出力。 */
152
+ export function renderRulesText({ rules, plugins }) {
153
+ const lines = [];
154
+ const sorted = sortForDisplay(rules);
155
+
156
+ lines.push(`有効なルール: ${sorted.length} 件`);
157
+
158
+ for (const severity of SEVERITY_ORDER) {
159
+ const group = sorted.filter((rule) => rule.severity === severity);
160
+ if (group.length === 0) continue;
161
+ lines.push('', `[${severity}] ${group.length} 件 — ${SEVERITY_NOTE[severity]}`);
162
+ for (const rule of group) {
163
+ // `.css` も見るルールは既定と違うので明示する。どのファイルが検査対象か
164
+ // 分からないと「なぜ検出されないのか」を利用者が追えない。
165
+ const css = rule.targets?.includes(RULE_TARGET.CSS) ? ' (.css も検査)' : '';
166
+ const from = rule.source === 'plugin' ? ' [plugin]' : '';
167
+ lines.push(` ${rule.id}${from}${css}`);
168
+ lines.push(` ${rule.description}`);
169
+ }
170
+ }
171
+
172
+ const clashes = [
173
+ ...new Set(
174
+ rules.filter((rule, i) => rules.findIndex((r) => r.id === rule.id) !== i).map((r) => r.id)
175
+ ),
176
+ ];
177
+ if (clashes.length > 0) {
178
+ // 衝突は「組み込み × プラグイン」だけでなく「プラグイン同士」でも起きる
179
+ // (plugin-api が名前空間を共有すると明記している)。source を見ずに
180
+ // 「組み込みと同じ」と決め打つと、後者で嘘の案内になる。
181
+ // en: Clashes also happen plugin-vs-plugin; don't assume a built-in is involved.
182
+ lines.push('');
183
+ for (const id of clashes) {
184
+ const sources = rules.filter((rule) => rule.id === id).map((rule) => rule.source);
185
+ const kind = sources.includes('builtin') ? '組み込みと同じ ID' : 'プラグイン同士で同じ ID';
186
+ lines.push(`⚠️ ${kind}のルールがあります: ${id}(${sources.join(' + ')})`);
187
+ }
188
+ lines.push(' どちらも実行されます。指摘の出所が分からなくなるので ID を変えてください。');
189
+ }
190
+
191
+ lines.push('');
192
+ if (plugins.length === 0) {
193
+ lines.push('プラグイン: なし(組み込みルールのみ)');
194
+ } else {
195
+ lines.push('プラグイン:');
196
+ for (const record of plugins) {
197
+ const status = record.status === 'loaded' ? 'ok' : `error: ${record.error}`;
198
+ lines.push(` - ${record.packageName} (${status})`);
199
+ }
200
+ }
201
+ lines.push('');
202
+ // 利用者の cwd に `docs/` は無い。npm 同梱の実体か GitHub を指す。
203
+ // en: `docs/` doesn't exist in the consumer's cwd — point at the real locations.
204
+ lines.push(
205
+ `個別ルールの背景と対処: ${DOCS_URL}/anti-patterns.md` +
206
+ '(npm 経由なら node_modules/sparkle-design-cli/docs/anti-patterns.md)'
207
+ );
208
+ lines.push('抑制するには `sparkle-disable-next-line <rule-id>` を使います。');
209
+
210
+ return lines.join('\n');
211
+ }
212
+
213
+ export async function runRules(options = {}) {
214
+ const collected = await collectActiveRules(options);
215
+ console.log(options.format === 'json' ? renderRulesJson(collected) : renderRulesText(collected));
216
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Figma の `Spacing: Primitives` を「使ってよいステップ」として検査するためのデータ。
3
+ *
4
+ * ## なぜ CSS 変数ではなく check ルールなのか
5
+ *
6
+ * Tailwind v4 の `p-N` / `m-N` / `gap-N` は `calc(var(--spacing) * N)` に
7
+ * コンパイルされる。ここに `--spacing-<N>` という**名前付きキー**を定義すると、
8
+ * その N だけ名前付きの値が優先される。つまり Figma の値をそのまま CSS 変数に
9
+ * すると、既存 consumer の余白がビルドも警告も通ったまま静かに変わる。
10
+ *
11
+ * しかも数字の意味がずれている。Figma の `padding/16` は 16px だが、
12
+ * Tailwind の `p-16` は 64px(16 × 4px)で、同じ数字が 4 倍違う。
13
+ *
14
+ * そのため sparkle-variables はトークンを出さず(`--spacing-*` を定義しないことを
15
+ * validate-tokens.mjs が検査している)、代わりにここで「Figma のスケールに乗って
16
+ * いる値だけを使う」制約として検査する。
17
+ *
18
+ * en: Figma's spacing scale is enforced as a lint, not emitted as CSS variables.
19
+ * Defining `--spacing-<N>` would override Tailwind's dynamic spacing and silently
20
+ * rescale every existing consumer's padding — and Figma's `padding/16` (16px) is
21
+ * 4× smaller than Tailwind's `p-16` (64px), so the numbers don't even line up.
22
+ */
23
+
24
+ /**
25
+ * Figma `Spacing: Primitives` の 17 段(px)。
26
+ * これが唯一の正解データで、以下はすべてここから導出する。
27
+ */
28
+ export const FIGMA_SPACING_PX = [0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 56, 72, 88, 104, 120];
29
+
30
+ /** Tailwind の 1 ステップは 0.25rem = 4px。`p-4` は 4 × 4px = 16px。 */
31
+ export const TAILWIND_STEP_PX = 4;
32
+
33
+ /**
34
+ * px を Tailwind のステップに直した**数値**の集合(`0.5` / `1.5` など小数を含む)。
35
+ *
36
+ * 文字列ではなく数値で持つのは、`p-4` と `p-4.0` のような表記ゆれを別物として
37
+ * 扱わないため。判定側は書かれた字面を `Number.parseFloat` で数値化してから
38
+ * この集合と突き合わせる。
39
+ */
40
+ export const FIGMA_SPACING_STEPS = new Set(FIGMA_SPACING_PX.map((px) => px / TAILWIND_STEP_PX));
41
+
42
+ /**
43
+ * 数値ステップを取るユーティリティ接頭辞。
44
+ *
45
+ * Figma のコレクション名は `padding/*` だが、検査対象は padding だけにしない。
46
+ * `gap-7`(28px)のように Figma のスケールに無い値は、padding でなくても
47
+ * 同じだけレイアウトを崩すため。逆に言うと **これは「Figma がそう定義している」
48
+ * のではなく「同じスケールに乗せる」という運用上の判断**で、severity を `info` に
49
+ * しているのはそのため。
50
+ * en: Figma only names the collection `padding/*`; applying it to margin/gap/space
51
+ * is our operational call, which is why this ships at `info` severity.
52
+ */
53
+ export const SPACING_UTILITY_PREFIXES = [
54
+ 'p',
55
+ 'px',
56
+ 'py',
57
+ 'pt',
58
+ 'pr',
59
+ 'pb',
60
+ 'pl',
61
+ 'ps',
62
+ 'pe',
63
+ 'm',
64
+ 'mx',
65
+ 'my',
66
+ 'mt',
67
+ 'mr',
68
+ 'mb',
69
+ 'ml',
70
+ 'ms',
71
+ 'me',
72
+ 'gap',
73
+ 'gap-x',
74
+ 'gap-y',
75
+ 'space-x',
76
+ 'space-y',
77
+ ];
78
+
79
+ /**
80
+ * 長い接頭辞を先に並べる。
81
+ *
82
+ * 正規表現の選択肢は先勝ちだが、この形(後続に `-<数字>` を要求する)なら
83
+ * 短い側が先に来てもバックトラックで長い側に到達するため、**並び順を変えても
84
+ * 検出結果は変わらない**。それでも並べ替えるのは、正しさをバックトラックの
85
+ * 挙動に依存させないため。
86
+ * en: Longest-first. With the trailing `-<digits>` requirement, backtracking
87
+ * would reach the longer alternative anyway — this just avoids depending on it.
88
+ */
89
+ function alternation(values) {
90
+ if (values.length === 0) {
91
+ throw new Error('接頭辞が空です(検査が何にもマッチしなくなります)');
92
+ }
93
+ return [...values]
94
+ .sort((a, b) => b.length - a.length || a.localeCompare(b))
95
+ .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
96
+ .join('|');
97
+ }
98
+
99
+ /**
100
+ * arbitrary variant の角括弧。**1 段だけ入れ子を許す**。
101
+ *
102
+ * `[^\]\s]*` だと最初の `]` で閉じてしまい、`[&:has([data-x])]:` や
103
+ * `[&_[data-slot=icon]]:` のような shadcn 系で頻出する形で variant を取りこぼす。
104
+ * 正規表現で任意段の入れ子は扱えないので 1 段で打ち切る(2 段以上は既知の制約)。
105
+ * en: Allows one level of nesting — `[&_[data-slot=icon]]:` is common in
106
+ * shadcn-style code. Arbitrary depth isn't expressible here; two levels or more
107
+ * is a known limitation.
108
+ */
109
+ const VARIANT_BRACKET_BODY = '\\[(?:[^\\[\\]\\s]|\\[[^\\[\\]\\s]*\\])*\\]';
110
+
111
+ /**
112
+ * variant の名前部分。`hover` / `md` / `data-[state=open]` に加えて、
113
+ * container query バリアントの `@sm` / `@2xl` のように `@` で始まる形も取る。
114
+ */
115
+ const VARIANT_NAME = `@?[a-z0-9][a-z0-9-]*(?:${VARIANT_BRACKET_BODY})?`;
116
+
117
+ /**
118
+ * 角括弧だけの arbitrary variant(`[&>*]` / `[@supports(display:grid)]`)と、
119
+ * 角括弧で幅を直接書く container query バリアント(`@[400px]`)。
120
+ * `@` を許さないと `@[400px]:p-7` の `@` だけが外れ、**存在しないクラス**
121
+ * `[400px]:p-6` を代替として提示してしまう。
122
+ */
123
+ const VARIANT_BRACKET = `@?${VARIANT_BRACKET_BODY}`;
124
+
125
+ /**
126
+ * 直下の子 `*:` と全子孫 `**:`。どちらも実在する Tailwind の variant。
127
+ *
128
+ * ここを落とすと `*:p-7` が `p-7` としてマッチし、「**子要素の余白**を
129
+ * **自分自身の余白**に書き換えろ」という案内になる。名前でも角括弧でもないので
130
+ * 独立した選択肢として持つ必要がある。
131
+ * en: Child (`*:`) and descendant (`**:`) variants. Missing them turns
132
+ * "change the children's padding" into "change your own padding".
133
+ */
134
+ const VARIANT_STAR = '\\*\\*?';
135
+
136
+ /**
137
+ * 名前付き group / peer / container の `/edit` 部分
138
+ * (`group-hover/edit:` / `peer-focus/email:` / `@2xl/main:`)。
139
+ *
140
+ * 名前はユーザーが決めるので大文字・アンダースコアも入る(`group/menuItem`)。
141
+ * 小文字だけに絞ると、その variant を丸ごと取りこぼした案内になる。
142
+ */
143
+ const VARIANT_MODIFIER = `(?:\\/(?:[A-Za-z0-9_-]+|${VARIANT_BRACKET_BODY}))?`;
144
+
145
+ /**
146
+ * variant 1 段。
147
+ *
148
+ * ここを狭く取ると、**variant を取りこぼしたうえで残りにマッチする**という
149
+ * 一番たちの悪い失敗をする。たとえば `/edit` を知らないと
150
+ * `group-hover/edit:p-7` が `edit:p-7` としてマッチし、代替候補が
151
+ * `edit:p-6` という存在しないクラスになる。
152
+ *
153
+ * 各段は必ず `:` を消費し、角括弧の中身も `[` と非 `[` の排他な選択肢で
154
+ * 組んであるため、`*` の中に置いても曖昧なバックトラックにならない。
155
+ * en: Too narrow a segment doesn't just miss the variant — it matches the
156
+ * leftover (`group-hover/edit:p-7` → `edit:p-7`) and suggests a class that
157
+ * doesn't exist. Every repetition must consume a `:` and the bracket body is
158
+ * built from mutually exclusive alternatives, so the outer `*` stays unambiguous.
159
+ */
160
+ const VARIANT_SEGMENT = `(?:${VARIANT_STAR}|${VARIANT_NAME}|${VARIANT_BRACKET})${VARIANT_MODIFIER}`;
161
+
162
+ /** `hover:md:` のような variant 連鎖。 */
163
+ const VARIANT_CHAIN = `(?:${VARIANT_SEGMENT}:)*`;
164
+
165
+ /**
166
+ * spacing ユーティリティにマッチする正規表現を作る。
167
+ *
168
+ * - 先頭の `-` は負のマージン(`-mt-4`)
169
+ * - 先頭・末尾の `!` は important(`!p-4` / `p-4!`)
170
+ * - 前後の `(?<![\w\-/])` / `(?![\w-])` は、`top-7` の `p-7` や `p-4x` のような
171
+ * 部分一致を弾くための境界。
172
+ * - **`-` を含めるのが重要**。これが無いと `custom-mt-4` の末尾に紛れた
173
+ * `mt-4` を誤検出する。
174
+ * - **`/` を含めるのはパス対策**。`https://example.com/v1/p-7` や
175
+ * `import x from './gap-7'` を拾ってしまうため。クラス名の直前に `/` が
176
+ * 来ることは実際には無い(`group-hover/edit:` の `/` は variant の内側で
177
+ * 消費されるので、マッチ開始位置の直前には現れない)。
178
+ *
179
+ * 名前つきキャプチャ:
180
+ * - `lead` … 数値の直前までの字面(`hover:-mt-`)。代替候補はこれに数字を
181
+ * 付け直して作るので、variant も `-` も `!` も自動的に保たれる
182
+ * - `sign` … ユーティリティ直前の `!` / `-`(important と負値)
183
+ * - `step` … ステップ値
184
+ * - `important` … 末尾の `!`
185
+ *
186
+ * `sign` を**正規表現で切り出す**のが重要。`lead` 全体を `/-/` で調べる方式だと、
187
+ * `[&:-moz-any-link]:mt-7` のように variant の内側に `-` を含む形で
188
+ * 「負値である」と誤判定し、正の 28px を -28px と案内してしまう。
189
+ * en: Named captures. `sign` must come from the pattern itself — scanning the
190
+ * whole `lead` for a hyphen misreads variants like `[&:-moz-any-link]:mt-7`
191
+ * as negative and reports +28px as −28px.
192
+ */
193
+ export function buildSpacingUtilityPattern() {
194
+ return new RegExp(
195
+ `(?<![\\w\\-/])(?<lead>${VARIANT_CHAIN}(?<sign>!?-?)(?:${alternation(SPACING_UTILITY_PREFIXES)})-)(?<step>\\d+(?:\\.\\d+)?)(?<important>!?)(?![\\w-])`,
196
+ 'g'
197
+ );
198
+ }
199
+
200
+ /**
201
+ * ステップ値が Figma のスケールに乗っているかを判定する。
202
+ *
203
+ * @param {string} step 字面のステップ(`"4"` / `"0.5"` / `"20"`)
204
+ * @returns {null | { px: number, lower: number | null, upper: number | null }}
205
+ * スケール内なら null。スケール外なら px 換算値と、前後の許容値を返す。
206
+ * 数値として読めない入力も null(=報告しない)を返す。検出パターンが
207
+ * `\d+(?:\.\d+)?` しか渡さないので実際には到達しないが、この関数を単体で
208
+ * 使うときは「null = 違反ではない」であって「null = 妥当な値」ではない。
209
+ */
210
+ export function resolveSpacingStep(step) {
211
+ const value = Number.parseFloat(step);
212
+ if (!Number.isFinite(value)) return null;
213
+ if (FIGMA_SPACING_STEPS.has(value)) return null;
214
+
215
+ const px = value * TAILWIND_STEP_PX;
216
+ const below = FIGMA_SPACING_PX.filter((p) => p < px);
217
+ const above = FIGMA_SPACING_PX.filter((p) => p > px);
218
+ return {
219
+ px,
220
+ lower: below.length ? Math.max(...below) : null,
221
+ upper: above.length ? Math.min(...above) : null,
222
+ };
223
+ }
224
+
225
+ /** px を Tailwind のステップ表記に戻す(`16` → `"4"`, `2` → `"0.5"`)。 */
226
+ export function pxToStep(px) {
227
+ return String(px / TAILWIND_STEP_PX);
228
+ }
package/lib/stop-hook.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'fs';
2
- import { checkProject } from './check.js';
2
+
3
+ import { DEFAULT_TARGET, countBySeverity, runCheck } from './check.js';
4
+ import { resolveTargetBaseDir } from './path-utils.js';
3
5
 
4
6
  /**
5
7
  * AI assistant の Stop / stop hook 用エントリポイント。
@@ -14,10 +16,21 @@ import { checkProject } from './check.js';
14
16
  * 抜ける。これで「最初の 1 回だけ exit 2 で停止をブロックして findings
15
17
  * を通知し、以降はユーザーの判断に委ねる」フローになる。
16
18
  *
19
+ * 相対 path は cwd で解決できなければ**プロジェクトルート基準で解決し直す**。
20
+ * hook は AI の作業途中に発火するため、AI が調査で `cd` したまま戻していない
21
+ * ケースが普通に起きる(issue #85)。cwd を最優先で試すので、今まで動いていた
22
+ * 呼び出しの挙動は変わらない。`check` サブコマンドは人間が直接叩くものなので
23
+ * 従来どおり cwd 基準のみで、この救済は stop-hook 限定。
24
+ *
17
25
  * en: Run `check --strict` once per session to surface findings, but stop
18
26
  * blocking on subsequent invocations within the same Stop hook chain.
19
27
  * Claude Code sets `stop_hook_active: true` on re-fires so the hook can
20
28
  * exit cleanly instead of looping forever (see Claude Code hooks docs).
29
+ * Relative targets fall back to the project root when cwd cannot resolve them,
30
+ * because hooks fire mid-session when the shell may sit in a subdirectory.
31
+ *
32
+ * @param {string | string[]} [target] lint 対象 path(複数可)
33
+ * @returns {Promise<0 | 2>} hook の exit code
21
34
  */
22
35
  export async function runStopHook(target) {
23
36
  const payload = readStdinJsonSafely();
@@ -31,21 +44,159 @@ export async function runStopHook(target) {
31
44
  return 0;
32
45
  }
33
46
 
34
- const targets = target ? [target] : [];
35
- const hasFindings = await checkProject(targets, { strict: true, format: 'text' });
47
+ const targets = normalizeTargets(target);
48
+ const restoreCwd = enterTargetBaseDir(targets);
49
+
50
+ let result;
51
+ try {
52
+ result = await runCheck(targets, { strict: true, format: 'text' });
53
+ } finally {
54
+ // check が throw しても cwd は必ず戻す。runStopHook は bin から呼ばれて
55
+ // すぐ process.exit する経路が主だが、テストや将来の埋め込み利用で
56
+ // プロセスが生き続ける場合に cwd を汚したままにしない。
57
+ // en: Always restore cwd, even when the check throws.
58
+ restoreCwd();
59
+ }
60
+ const { report, blocked } = result;
61
+
62
+ if (blocked) {
63
+ // ブロック理由は「error の findings」と「実行に失敗して未検査のルール」の
64
+ // 2 系統ある。後者のとき「findings を修正してください」とだけ書くと、
65
+ // 存在しない findings を AI が探し始めるので理由を書き分ける。
66
+ // en: Blocking has two causes — error findings and rules that failed to run.
67
+ // Say which, or the AI hunts for findings that don't exist.
68
+ const skipped = report.skippedRules ?? [];
69
+ const errorCount = countBySeverity(report.findings).error;
70
+ const reasons = [];
71
+ if (errorCount > 0) reasons.push(`severity=error の findings ${errorCount} 件`);
72
+ if (skipped.length > 0) {
73
+ reasons.push(
74
+ `実行に失敗して未検査のルール ${skipped.length} 件(${skipped.map((s) => s.ruleId).join(', ')})`
75
+ );
76
+ }
36
77
 
37
- if (hasFindings) {
38
78
  process.stderr.write(
39
- '\nsparkle-design-cli stop-hook: findings を検出したため応答終了を 1 度だけブロックしました。' +
40
- '上記の findings を修正するか、対応しない判断であればユーザーに確認してから再度応答を完了してください。' +
41
- ' / Blocked once because findings were detected. Fix them or confirm with the user before completing the response again.\n'
79
+ `\nsparkle-design-cli stop-hook: ${reasons.join(' と ')} を検出したため応答終了を 1 度だけブロックしました。` +
80
+ (errorCount > 0
81
+ ? '上記の findings を修正するか、対応しない判断であればユーザーに確認してください。'
82
+ : '') +
83
+ (skipped.length > 0
84
+ ? 'ルールの実行失敗は検査自体が行われていないことを意味します。プラグインの不具合の可能性があるためユーザーに報告してください(findings を探す必要はありません)。'
85
+ : '') +
86
+ ` / Blocked once: ${reasons.join(' and ')}.\n`
42
87
  );
43
88
  return 2;
44
89
  }
45
90
 
91
+ // exit 0 の hook の stdout は AI に渡らない(Claude Code がフィードバックとして
92
+ // 読むのは exit 2 の stderr のみ)。warning / info は設計上ブロックしないので、
93
+ // このままだと printTextReport が出した移行ガイドが誰にも読まれずに消える。
94
+ // ブロックはせず、要約だけ stderr に出して人間の目に留まるようにする。
95
+ // 詳細は `sparkle-design-cli check --format json` で取得できる。
96
+ // en: A hook exiting 0 has its stdout dropped, so non-blocking findings would
97
+ // vanish. Emit a short summary on stderr instead of escalating to exit 2.
98
+ const counts = countBySeverity(report.findings);
99
+ const nonBlocking = counts.warning + counts.info;
100
+ if (nonBlocking > 0) {
101
+ process.stderr.write(
102
+ `\nsparkle-design-cli stop-hook: ブロックしない findings が ${nonBlocking} 件あります` +
103
+ `(warning ${counts.warning} / info ${counts.info})。` +
104
+ '詳細は `npx --yes sparkle-design-cli check src --format json` で確認できます。' +
105
+ ` / ${nonBlocking} non-blocking finding(s); run check --format json for details.\n`
106
+ );
107
+ }
108
+
46
109
  return 0;
47
110
  }
48
111
 
112
+ /**
113
+ * hook に渡された lint 対象を配列に正規化する。
114
+ *
115
+ * 2.5.0-beta.1 までは `bin/sparkle-design.js` が `args[1]` しか渡しておらず、
116
+ * 実運用で見られる `stop-hook apps/web/app apps/web/components` の**2つ目以降が
117
+ * 黙って未検査**になっていた(issue #85 の調査で判明)。無指定なら `runCheck`
118
+ * 側の既定 target(`src`)に委ねるので、空配列をそのまま返す。
119
+ *
120
+ * en: Accept multiple targets. Before this, only the first positional arg was
121
+ * forwarded, so extra paths in a hook command were silently never checked.
122
+ */
123
+ function normalizeTargets(target) {
124
+ const list = Array.isArray(target) ? target : [target];
125
+ return list
126
+ .filter((value) => typeof value === 'string' && value.trim())
127
+ .map((value) => value.trim());
128
+ }
129
+
130
+ /**
131
+ * target が実在する基準ディレクトリへ `chdir` し、元に戻す関数を返す。
132
+ *
133
+ * target path だけを解決し直すのでは足りない。`check` は cwd に依存する処理を
134
+ * 他にも持っているので、target だけずらすとそれらが元の cwd を見たままになる。
135
+ *
136
+ * - **アンチパターンプラグインの discovery**(`loadAntiPatternPlugins` が
137
+ * `cwd/package.json` の `sparkleCli.antiPatterns` を読む)。ここがずれると
138
+ * 「target は repo root のファイルを見ているのに、有効なルールは apps/web 基準」
139
+ * という、**検査したのに一部のルールが効いていない**状態になる
140
+ * - Next.js の CSP 設定検出(`cwd` から `next.config.*` を探す)
141
+ * - レポートの相対 path 表示(`path.relative(process.cwd(), ...)`)
142
+ *
143
+ * cwd ごと合わせるのが最も整合的。
144
+ *
145
+ * `resolveTargetBaseDir` は cwd を最優先で試すので、**今まで通り動いていたケースは
146
+ * chdir されず挙動が変わらない**。移動したときだけ stderr に 1 行出す。黙って別の
147
+ * ディレクトリを検査していると、findings の差分を追うときに原因が分からなくなる。
148
+ *
149
+ * `process.chdir()` はプロセス全体の状態なので、**1 プロセスにつき 1 回だけ
150
+ * `runStopHook` を呼ぶ**ことが前提。`bin/sparkle-design.js` は subcommand を 1 つ
151
+ * 処理して `process.exit` するのでこの前提は満たされている。将来 `runStopHook` を
152
+ * ライブラリとして並行に呼ぶ用途が出たら、cwd を動かさずに基準ディレクトリを
153
+ * `runCheck` へ引数で渡す形(`check` 側の cwd 依存を全部剥がす)に作り替えること。
154
+ *
155
+ * en: chdir to the base where the targets actually exist, rather than only
156
+ * rebasing target paths — the check pipeline reads config, CSP settings and
157
+ * report paths from cwd too. cwd is tried first, so working setups don't move.
158
+ * chdir is process-global, so this assumes one runStopHook per process — true
159
+ * for the CLI, which exits right after. Concurrent library use would need the
160
+ * base directory threaded through runCheck instead.
161
+ */
162
+ function enterTargetBaseDir(targets) {
163
+ const originalCwd = process.cwd();
164
+ // target 未指定なら check 側の既定 target で探る。ここで `src` を決め打ちすると
165
+ // 既定が変わったときに静かにズレるので、check.js の定数をそのまま使う。
166
+ // en: Probe with check's own default target instead of hardcoding 'src'.
167
+ const probe = targets.length > 0 ? targets : [DEFAULT_TARGET];
168
+ const { dir, source, resolved } = resolveTargetBaseDir(probe, { startDir: originalCwd });
169
+
170
+ if (!resolved || dir === originalCwd) return () => {};
171
+
172
+ try {
173
+ process.chdir(dir);
174
+ } catch (error) {
175
+ // chdir に失敗しても検査自体は続ける価値がある(従来どおり cwd 基準)。
176
+ // en: Fall back to cwd-relative behaviour instead of aborting the check.
177
+ process.stderr.write(
178
+ `sparkle-design-cli stop-hook: ${dir} へ移動できなかったため cwd 基準で検査します (${error.code ?? error.message})` +
179
+ ` / Could not chdir to the resolved base dir; falling back to cwd.\n`
180
+ );
181
+ return () => {};
182
+ }
183
+
184
+ process.stderr.write(
185
+ `sparkle-design-cli stop-hook: 実行時の cwd (${originalCwd}) では ${probe.join(' / ')} が見つからないため、` +
186
+ `${dir} 基準で検査します(${source} から判定)。` +
187
+ ` / Resolved relative targets against ${dir} instead of cwd.\n`
188
+ );
189
+
190
+ return () => {
191
+ try {
192
+ process.chdir(originalCwd);
193
+ } catch {
194
+ // 元の cwd が消えている場合まで面倒は見ない。
195
+ // en: Ignore — the original cwd may no longer exist.
196
+ }
197
+ };
198
+ }
199
+
49
200
  function readStdinJsonSafely() {
50
201
  // stdin が tty / 空 / 非 JSON の場合は「初回呼び出し」として扱う。
51
202
  // en: Treat missing or non-JSON stdin as a first invocation.