sparkle-design-cli 2.5.0-beta.1 → 2.5.0-beta.3

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/lib/stop-hook.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'fs';
2
- import { countBySeverity, runCheck } 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 { countBySeverity, runCheck } 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,8 +44,20 @@ export async function runStopHook(target) {
31
44
  return 0;
32
45
  }
33
46
 
34
- const targets = target ? [target] : [];
35
- const { report, blocked } = await runCheck(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;
36
61
 
37
62
  if (blocked) {
38
63
  // ブロック理由は「error の findings」と「実行に失敗して未検査のルール」の
@@ -84,6 +109,94 @@ export async function runStopHook(target) {
84
109
  return 0;
85
110
  }
86
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
+
87
200
  function readStdinJsonSafely() {
88
201
  // stdin が tty / 空 / 非 JSON の場合は「初回呼び出し」として扱う。
89
202
  // en: Treat missing or non-JSON stdin as a first invocation.
@@ -606,6 +606,216 @@ export function buildLegacyCssVarPattern() {
606
606
  );
607
607
  }
608
608
 
609
+ // --- Tailwind 既定パレット -> Sparkle セマンティックトークン ------------------
610
+
611
+ /**
612
+ * Tailwind 既定パレットのファミリー → Sparkle の意味ファミリー。
613
+ *
614
+ * Sparkle のプリミティブは Tailwind の同名変数をそのまま参照している
615
+ * (`--color-neutral-600: var(--color-gray-600)` / `--color-negative-600:
616
+ * var(--color-red-600)` など)。そのため `gray` / `red` / `green` / `yellow` /
617
+ * `blue` の 5 系統は**値が完全に一致する**ので、置き換えても見た目が変わらない。
618
+ * それ以外(`slate` / `zinc` / `emerald` …)は近いだけで色味が変わるため、
619
+ * 自動変換可とは扱わず必ず要判断にする。
620
+ *
621
+ * - `family`: 対応する旧セマンティックファミリー。`null` は対応する意味が無い
622
+ * - `exact`: Sparkle のプリミティブが同じ値を指しているか
623
+ * - `ambiguousWith`: 用途上もう一方の意味も有力な場合(ブランド色かどうかは
624
+ * コードからは判断できないため、候補を両方出して人に選ばせる)
625
+ *
626
+ * `neutral` は**意図的に載せていない**。Tailwind の既定パレットにも Sparkle の
627
+ * 旧セマンティック層にも同名のファミリーがあり、`text-neutral-500` は
628
+ * `legacy-color-token` がすでに検出する。ここにも載せると同じ箇所が 2 回報告される。
629
+ * en: `neutral` is deliberately absent — it collides with Sparkle's own legacy
630
+ * family name and is already covered by `legacy-color-token`.
631
+ *
632
+ * `gray` を `neutral` ではなく `base` に寄せているのは、`base` が「gray と同値」
633
+ * かつ surface では Figma 専用の `surface/base/*`(ページ地)が優先されるため。
634
+ * text / border / object には `base` が無いので `neutral` に自動フォールバックする
635
+ * (`familyScaleMap` 参照)。
636
+ */
637
+ export const TAILWIND_PALETTE_FAMILIES = {
638
+ // 値まで一致する系統
639
+ gray: { family: 'base', exact: true },
640
+ red: { family: 'negative', exact: true },
641
+ green: { family: 'success', exact: true },
642
+ yellow: { family: 'warning', exact: true },
643
+ blue: { family: 'info', exact: true, ambiguousWith: 'primary' },
644
+ // 意味は同じだが色味が変わる系統
645
+ slate: { family: 'base', exact: false },
646
+ zinc: { family: 'base', exact: false },
647
+ stone: { family: 'base', exact: false },
648
+ rose: { family: 'negative', exact: false },
649
+ emerald: { family: 'success', exact: false },
650
+ teal: { family: 'success', exact: false },
651
+ lime: { family: 'success', exact: false },
652
+ amber: { family: 'warning', exact: false },
653
+ orange: { family: 'warning', exact: false },
654
+ sky: { family: 'info', exact: false, ambiguousWith: 'primary' },
655
+ cyan: { family: 'info', exact: false, ambiguousWith: 'primary' },
656
+ indigo: { family: 'info', exact: false, ambiguousWith: 'primary' },
657
+ // 対応する意味が無い系統
658
+ violet: { family: null },
659
+ purple: { family: null },
660
+ fuchsia: { family: null },
661
+ pink: { family: null },
662
+ };
663
+
664
+ /**
665
+ * Tailwind v4 のパレットは 950 まである。Sparkle の旧セマンティック層は 900 止まり
666
+ * なので、950 は機械的な対応先が無い(要判断として報告する)。
667
+ */
668
+ const TAILWIND_ONLY_LEVELS = ['950'];
669
+ const TAILWIND_PALETTE_LEVELS = [...LEGACY_LEVELS, ...TAILWIND_ONLY_LEVELS];
670
+
671
+ /**
672
+ * `bg-gray-50` / `hover:text-red-600/50` のような Tailwind 既定パレットの
673
+ * 色ユーティリティを検出する正規表現。
674
+ *
675
+ * `legacy-color-token` の `buildLegacyScalePattern` と同じ境界・variant・modifier
676
+ * の扱いを共有する(`\b` ではなく `[\w-]` 境界を使う理由は同関数のコメント参照)。
677
+ */
678
+ export function buildTailwindPalettePattern() {
679
+ const prefixes = alternation(Object.keys(UTILITY_PREFIX_TO_CATEGORY));
680
+ const families = alternation(Object.keys(TAILWIND_PALETTE_FAMILIES));
681
+ const levels = alternation(TAILWIND_PALETTE_LEVELS);
682
+ return new RegExp(
683
+ `${NOT_CLASS_CHAR_BEFORE}${VARIANT_CHAIN}${IMPORTANT_PREFIX}(?:${prefixes})-(?:${families})-(?:${levels})${NOT_CLASS_CHAR_AFTER}${MODIFIER_SUFFIX}`,
684
+ 'g'
685
+ );
686
+ }
687
+
688
+ /**
689
+ * Tailwind 既定パレットのユーティリティ 1 個を解析して移行結果を返す。
690
+ *
691
+ * 旧セマンティックファミリーに読み替えてから `resolveLegacyUtility` に委譲する。
692
+ * 対応表・状態の絞り込み・opacity modifier の持ち回りを二重に実装せずに済み、
693
+ * `legacy-color-token` と同じ移行先を必ず案内できる。
694
+ *
695
+ * ただし classification は**そのまま通さない**。
696
+ * - 値が一致しない系統(`slate` など)は見た目が変わるので必ず `review`
697
+ * - `info` / `primary` のどちらとも読める系統(`blue` など)は、ブランド色かどうかが
698
+ * コードから判断できないので候補を両方出して `review`
699
+ *
700
+ * en: Rewrite the Tailwind family to Sparkle's legacy family and delegate, so the
701
+ * mapping table, state narrowing and modifier handling are never duplicated.
702
+ * Classification is downgraded to `review` whenever the swap is not value-preserving
703
+ * or the semantic (info vs primary) cannot be decided from the code.
704
+ *
705
+ * @param {string} utility 例 `bg-gray-50` / `hover:text-red-600/50`
706
+ * @returns {null | { input: string, classification: string, replacement?: string, candidates?: string[], reason?: string }}
707
+ */
708
+ export function resolveTailwindPaletteUtility(utility) {
709
+ if (typeof utility !== 'string' || utility.length === 0) return null;
710
+
711
+ const lastColon = utility.lastIndexOf(':');
712
+ const variantPrefix = lastColon === -1 ? '' : utility.slice(0, lastColon + 1);
713
+ let rawBare = lastColon === -1 ? utility : utility.slice(lastColon + 1);
714
+
715
+ const importantPrefix = rawBare.startsWith('!') ? '!' : '';
716
+ if (importantPrefix) rawBare = rawBare.slice(1);
717
+
718
+ const [, bare, modifier] = MODIFIER_SUFFIX_PATTERN.exec(rawBare);
719
+
720
+ const dashIndex = bare.indexOf('-');
721
+ if (dashIndex === -1) return null;
722
+ const prefix = bare.slice(0, dashIndex);
723
+ const rest = bare.slice(dashIndex + 1);
724
+ if (!UTILITY_PREFIX_TO_CATEGORY[prefix]) return null;
725
+
726
+ const scaleMatch = /^([a-z]+)-(\d{2,3})$/.exec(rest);
727
+ if (!scaleMatch) return null;
728
+ const [, paletteFamily, level] = scaleMatch;
729
+
730
+ const mapping = TAILWIND_PALETTE_FAMILIES[paletteFamily];
731
+ if (!mapping || !TAILWIND_PALETTE_LEVELS.includes(level)) return null;
732
+
733
+ const wrap = (result) => ({ input: utility, ...result });
734
+
735
+ if (!mapping.family) {
736
+ return wrap({
737
+ classification: MIGRATION_CLASS.REVIEW,
738
+ reason:
739
+ `${paletteFamily} に対応する意味のトークンが Sparkle にありません。` +
740
+ 'Figma の該当箇所を見て用途(surface / text / border / object)から選び直してください。' +
741
+ '装飾目的の面であれば surface-accent-1 / 2 / 3 が候補になります。',
742
+ });
743
+ }
744
+
745
+ if (TAILWIND_ONLY_LEVELS.includes(level)) {
746
+ return wrap({
747
+ classification: MIGRATION_CLASS.REVIEW,
748
+ reason:
749
+ `Tailwind の ${level} に対応するレベルが Sparkle にありません(Sparkle は 900 まで)。` +
750
+ `${paletteFamily}-900 相当で足りるかを Figma で確認してください。`,
751
+ });
752
+ }
753
+
754
+ const delegate = (family) =>
755
+ resolveLegacyUtility(
756
+ `${variantPrefix}${importantPrefix}${prefix}-${family}-${level}${modifier}`
757
+ );
758
+
759
+ const primary = delegate(mapping.family);
760
+ if (!primary) return null;
761
+
762
+ // 用途上どちらの意味とも読める系統は、候補を両方並べて人に選ばせる。
763
+ // en: Surface both semantics when the code cannot tell brand colour from status.
764
+ if (mapping.ambiguousWith) {
765
+ const sides = [
766
+ { family: mapping.family, result: primary },
767
+ { family: mapping.ambiguousWith, result: delegate(mapping.ambiguousWith) },
768
+ ];
769
+ // 片側に該当トークンが無いことがある(例: surface の info には 600 が無い)。
770
+ // 候補を並べただけでは「なぜ片方しか出ないのか」が分からず、AI が
771
+ // 残った候補を唯一の正解と誤解するので、欠けている側を明示する。
772
+ // en: One side can have no matching token; say so, or the remaining
773
+ // candidate reads as the single correct answer.
774
+ const missing = sides.filter((side) => toCandidateList(side.result).length === 0);
775
+ return wrap({
776
+ classification: MIGRATION_CLASS.REVIEW,
777
+ candidates: sides.flatMap((side) => toCandidateList(side.result)),
778
+ reason:
779
+ `${paletteFamily} は「${mapping.family}(状態・意味を表す色)」とも「${mapping.ambiguousWith}(ブランド色)」とも読めます。` +
780
+ 'どちらの用途かはコードから判断できないため、Figma の該当箇所を見て選んでください。' +
781
+ (missing.length > 0
782
+ ? `${missing.map((side) => side.family).join(' / ')} 側には対応するトークンがありません。`
783
+ : '') +
784
+ (mapping.exact ? '' : `なお ${paletteFamily} と Sparkle の色は完全には一致しません。`),
785
+ });
786
+ }
787
+
788
+ if (!mapping.exact) {
789
+ return wrap({
790
+ classification: MIGRATION_CLASS.REVIEW,
791
+ candidates: toCandidateList(primary),
792
+ reason:
793
+ `${paletteFamily} は Sparkle の ${mapping.family} に意味は対応しますが、色が完全には一致しません(置き換えると見た目が変わります)。` +
794
+ 'Figma の該当箇所で意図した色かを確認してください。' +
795
+ (primary.reason ? ` ${primary.reason}` : ''),
796
+ });
797
+ }
798
+
799
+ // 値が一致する系統は、旧トークンからの移行と同じ確度で案内できる。
800
+ // en: Value-identical families keep the delegated classification as-is.
801
+ return wrap({
802
+ classification: primary.classification,
803
+ replacement: primary.replacement,
804
+ candidates: primary.candidates,
805
+ reason: primary.reason,
806
+ });
807
+ }
808
+
809
+ /**
810
+ * `resolveLegacyUtility` の戻り値を候補リストに正規化する。
811
+ * en: Flatten a delegated result into a candidate list.
812
+ */
813
+ function toCandidateList(result) {
814
+ if (!result) return [];
815
+ if (result.replacement) return [result.replacement];
816
+ return result.candidates ?? [];
817
+ }
818
+
609
819
  /**
610
820
  * CSS 変数名 1 個を解析して移行結果を返す。
611
821
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.5.0-beta.1",
3
+ "version": "2.5.0-beta.3",
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",
@@ -19,10 +19,10 @@
19
19
  "scripts": {
20
20
  "test": "node --test test/*.test.js",
21
21
  "test:watch": "node --test --watch test/*.test.js",
22
- "lint": "eslint bin/ lib/ test/",
23
- "lint:fix": "eslint bin/ lib/ test/ --fix",
24
- "format": "prettier --write bin/ lib/ test/ *.md *.json",
25
- "format:check": "prettier --check bin/ lib/ test/ *.md *.json",
22
+ "lint": "eslint bin/ lib/ scripts/ test/",
23
+ "lint:fix": "eslint bin/ lib/ scripts/ test/ --fix",
24
+ "format": "prettier --write bin/ lib/ scripts/ test/ docs/ \"*.md\" \"*.json\"",
25
+ "format:check": "prettier --check bin/ lib/ scripts/ test/ docs/ \"*.md\" \"*.json\"",
26
26
  "sync:anti-pattern-docs": "node scripts/sync-anti-pattern-docs.mjs"
27
27
  },
28
28
  "keywords": [