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/CONTRIBUTING.md +9 -0
- package/README.md +24 -4
- package/bin/sparkle-design.js +92 -13
- package/docs/config.md +27 -2
- package/lib/anti-pattern-rules.js +137 -3
- package/lib/auth.js +302 -0
- package/lib/check.js +25 -4
- package/lib/load-plugins.js +6 -0
- package/lib/path-utils.js +120 -0
- package/lib/rules-report.js +42 -7
- package/lib/stop-hook.js +116 -3
- package/lib/token-migration.js +210 -0
- package/package.json +5 -5
package/lib/auth.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { URL, URLSearchParams } from 'url';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `sparkle-design-cli auth` — Google Artifact Registry(AR)の npm registry 用に、
|
|
9
|
+
* 利用者の Google 資格情報からアクセストークンを取り、ユーザー単位の `~/.npmrc` に書く。
|
|
10
|
+
*
|
|
11
|
+
* AR のトークンは約 60 分で失効するので、「翌日 `pnpm install` が 401 で落ちる」を
|
|
12
|
+
* 1 コマンドで復帰できるようにするのが目的(goodpatch/sparkle-design-cli#94 の Phase 1)。
|
|
13
|
+
*
|
|
14
|
+
* 失効を弱めないための制約(#94 の追加仕様):
|
|
15
|
+
* - 書き込むのはアクセストークン(60 分以内に失効)だけ。refresh token は gcloud / ADC の
|
|
16
|
+
* 管理下に置いたまま読むだけで、コピー・キャッシュしない
|
|
17
|
+
* - トークンを標準出力・エラー出力に出さない
|
|
18
|
+
* en: Writes a short-lived Google access token for an Artifact Registry npm registry into the
|
|
19
|
+
* user-level ~/.npmrc. Never persists or prints long-lived credentials or the token itself.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SCOPE = '@goodpatch';
|
|
23
|
+
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
24
|
+
const GOOGLE_TOKENINFO_URL = 'https://oauth2.googleapis.com/tokeninfo';
|
|
25
|
+
/** アクセストークンとして受け付ける文字。改行などで .npmrc に別の行を差し込ませない */
|
|
26
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9._\-~+/=]+$/;
|
|
27
|
+
|
|
28
|
+
export class AuthError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'AuthError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* registry URL を検証して、末尾スラッシュ付きの正規形にする。
|
|
37
|
+
*
|
|
38
|
+
* トークンは `cloud-platform` スコープの利用者アクセストークンで、60 分間は GCP 全体を
|
|
39
|
+
* その人として操作できる。AR 以外のホストに渡すと資格情報の漏えいになるので、
|
|
40
|
+
* https かつ AR の npm ホスト(`<location>-npm.pkg.dev`)に限る。
|
|
41
|
+
* en: Only https Artifact Registry npm hosts are accepted, to avoid leaking a broad Google token.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeRegistry(raw) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(String(raw).trim());
|
|
47
|
+
} catch {
|
|
48
|
+
throw new AuthError(`registry の URL として解釈できません: ${raw}`);
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== 'https:') {
|
|
51
|
+
throw new AuthError(`registry は https である必要があります: ${url.href}`);
|
|
52
|
+
}
|
|
53
|
+
if (!/^[a-z0-9-]+-npm\.pkg\.dev$/.test(url.hostname)) {
|
|
54
|
+
throw new AuthError(
|
|
55
|
+
`Artifact Registry の npm registry(<location>-npm.pkg.dev)ではありません: ${url.host}\n` +
|
|
56
|
+
' このコマンドは Google のアクセストークンを書き込むため、Artifact Registry 以外には使えません。'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
60
|
+
throw new AuthError(`registry の URL に余分な要素が含まれています: ${raw}`);
|
|
61
|
+
}
|
|
62
|
+
const pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
63
|
+
return `https://${url.host}${pathname}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `~/.npmrc` に書くキー(`//host/path/:_authToken`) */
|
|
67
|
+
export function registryAuthKey(registry) {
|
|
68
|
+
return `${registry.replace(/^https:/, '')}:_authToken`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `.npmrc` のテキストから `@goodpatch:registry=` の値を取り出す。無ければ null */
|
|
72
|
+
export function readScopeRegistry(npmrcText, scope = SCOPE) {
|
|
73
|
+
for (const rawLine of npmrcText.split(/\r?\n/)) {
|
|
74
|
+
const line = rawLine.trim();
|
|
75
|
+
if (line.startsWith('#') || line.startsWith(';')) continue;
|
|
76
|
+
const match = /^([^=]+?)\s*=\s*(.+)$/.exec(line);
|
|
77
|
+
if (match && match[1] === `${scope}:registry`) {
|
|
78
|
+
return match[2].trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* プロジェクトの `.npmrc` を cwd から親へ辿って探し、`@goodpatch:registry=` を返す。
|
|
86
|
+
* monorepo でサブディレクトリから実行しても、ルートの `.npmrc` を見つけられるようにする。
|
|
87
|
+
*/
|
|
88
|
+
export function findProjectRegistry(cwd, readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
89
|
+
let dir = path.resolve(cwd);
|
|
90
|
+
for (;;) {
|
|
91
|
+
const candidate = path.join(dir, '.npmrc');
|
|
92
|
+
let text = null;
|
|
93
|
+
try {
|
|
94
|
+
text = readFile(candidate);
|
|
95
|
+
} catch {
|
|
96
|
+
// 無ければ親へ
|
|
97
|
+
}
|
|
98
|
+
if (text !== null) {
|
|
99
|
+
const registry = readScopeRegistry(text);
|
|
100
|
+
if (registry) return { registry, source: candidate };
|
|
101
|
+
}
|
|
102
|
+
const parent = path.dirname(dir);
|
|
103
|
+
if (parent === dir) return null;
|
|
104
|
+
dir = parent;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `.npmrc` のテキスト中の、同じ registry のトークン行を置き換える(無ければ末尾に足す)。
|
|
110
|
+
* `google-artifactregistry-auth` は追記するだけで、実行のたびに行が積み上がるため。
|
|
111
|
+
* 末尾スラッシュの有無が違う同じ registry の行もまとめて取り除く。
|
|
112
|
+
*/
|
|
113
|
+
export function upsertAuthToken(npmrcText, registry, token) {
|
|
114
|
+
const key = registryAuthKey(registry);
|
|
115
|
+
const variants = new Set([key, key.replace(/\/:_authToken$/, ':_authToken')]);
|
|
116
|
+
const lines = npmrcText === '' ? [] : npmrcText.split(/\r?\n/);
|
|
117
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
118
|
+
const kept = lines.filter((line) => {
|
|
119
|
+
const match = /^\s*([^=\s]+)\s*=/.exec(line);
|
|
120
|
+
return !(match && variants.has(match[1]));
|
|
121
|
+
});
|
|
122
|
+
kept.push(`${key}=${token}`);
|
|
123
|
+
return `${kept.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** npm が読むユーザー単位の設定ファイル */
|
|
127
|
+
export function resolveUserConfigPath(env = process.env, homedir = os.homedir()) {
|
|
128
|
+
return env.NPM_CONFIG_USERCONFIG || env.npm_config_userconfig || path.join(homedir, '.npmrc');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** ADC ファイルの場所(gcloud と同じ探し方) */
|
|
132
|
+
export function resolveAdcPath(env = process.env, homedir = os.homedir()) {
|
|
133
|
+
if (env.GOOGLE_APPLICATION_CREDENTIALS) return env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
134
|
+
const configDir =
|
|
135
|
+
env.CLOUDSDK_CONFIG ||
|
|
136
|
+
(process.platform === 'win32' && env.APPDATA
|
|
137
|
+
? path.join(env.APPDATA, 'gcloud')
|
|
138
|
+
: path.join(homedir, '.config', 'gcloud'));
|
|
139
|
+
return path.join(configDir, 'application_default_credentials.json');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 失効時刻の表示(例: 15:52(あと 59 分)) */
|
|
143
|
+
export function formatExpiry(expiresInSeconds, now = new Date()) {
|
|
144
|
+
const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
|
|
145
|
+
const hh = String(expiresAt.getHours()).padStart(2, '0');
|
|
146
|
+
const mm = String(expiresAt.getMinutes()).padStart(2, '0');
|
|
147
|
+
return `${hh}:${mm}(あと ${Math.floor(expiresInSeconds / 60)} 分)`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- トークンの取得(テストでは差し替える) ----
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* ADC(`gcloud auth application-default login` で作られる authorized_user)の
|
|
154
|
+
* refresh token でアクセストークンを取り直す。refresh token は読むだけで保存しない。
|
|
155
|
+
* 使えなければ null(呼び出し側で gcloud にフォールバックする)。
|
|
156
|
+
*/
|
|
157
|
+
async function tokenFromAdc({ env, homedir, fetchImpl }) {
|
|
158
|
+
let adc;
|
|
159
|
+
try {
|
|
160
|
+
adc = JSON.parse(fs.readFileSync(resolveAdcPath(env, homedir), 'utf8'));
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// サービスアカウントキー等は対象外(組織ポリシーでキー発行自体が禁止されている)
|
|
165
|
+
if (adc?.type !== 'authorized_user' || !adc.refresh_token || !adc.client_id) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetchImpl(GOOGLE_TOKEN_URL, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
172
|
+
body: new URLSearchParams({
|
|
173
|
+
grant_type: 'refresh_token',
|
|
174
|
+
refresh_token: adc.refresh_token,
|
|
175
|
+
client_id: adc.client_id,
|
|
176
|
+
client_secret: adc.client_secret ?? '',
|
|
177
|
+
}).toString(),
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok) return null;
|
|
180
|
+
const json = await res.json();
|
|
181
|
+
if (typeof json.access_token !== 'string') return null;
|
|
182
|
+
return { token: json.access_token, expiresIn: Number(json.expires_in) || null, via: 'ADC' };
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `gcloud auth print-access-token`。未導入・未ログインなら null */
|
|
189
|
+
function tokenFromGcloud({ spawn }) {
|
|
190
|
+
const result = spawn('gcloud', ['auth', 'print-access-token'], {
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
shell: process.platform === 'win32',
|
|
193
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
194
|
+
});
|
|
195
|
+
if (result.error || result.status !== 0) return null;
|
|
196
|
+
const token = String(result.stdout ?? '').trim();
|
|
197
|
+
return token ? { token, expiresIn: null, via: 'gcloud' } : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 失効までの秒数を tokeninfo で調べる(取れなくても認証自体は成功扱い) */
|
|
201
|
+
async function lookupExpiresIn(token, fetchImpl) {
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetchImpl(GOOGLE_TOKENINFO_URL, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
206
|
+
body: new URLSearchParams({ access_token: token }).toString(),
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) return null;
|
|
209
|
+
const json = await res.json();
|
|
210
|
+
return Number(json.expires_in) || null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const defaultDeps = {
|
|
217
|
+
env: process.env,
|
|
218
|
+
homedir: os.homedir(),
|
|
219
|
+
cwd: () => process.cwd(),
|
|
220
|
+
fetchImpl: (...args) => globalThis.fetch(...args),
|
|
221
|
+
spawn: spawnSync,
|
|
222
|
+
now: () => new Date(),
|
|
223
|
+
log: (...args) => console.log(...args),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `auth` 本体。成功したら書き込み先と失効時刻を返す。
|
|
228
|
+
* 失敗は AuthError(メッセージにトークンは含めない)。
|
|
229
|
+
*/
|
|
230
|
+
export async function runAuth(options = {}, deps = {}) {
|
|
231
|
+
const d = { ...defaultDeps, ...deps };
|
|
232
|
+
|
|
233
|
+
let registrySource;
|
|
234
|
+
let rawRegistry;
|
|
235
|
+
if (options.registry) {
|
|
236
|
+
rawRegistry = options.registry;
|
|
237
|
+
registrySource = '--registry';
|
|
238
|
+
} else {
|
|
239
|
+
const found = findProjectRegistry(d.cwd());
|
|
240
|
+
if (!found) {
|
|
241
|
+
throw new AuthError(
|
|
242
|
+
`対象の registry が決められません。次のどちらかを行ってください:\n` +
|
|
243
|
+
` - プロジェクトの .npmrc に ${SCOPE}:registry=https://<location>-npm.pkg.dev/<project>/<repository>/ を書く\n` +
|
|
244
|
+
` - --registry <url> を指定する`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
rawRegistry = found.registry;
|
|
248
|
+
registrySource = found.source;
|
|
249
|
+
}
|
|
250
|
+
const registry = normalizeRegistry(rawRegistry);
|
|
251
|
+
|
|
252
|
+
const acquired = (await tokenFromAdc(d)) ?? tokenFromGcloud(d);
|
|
253
|
+
if (!acquired) {
|
|
254
|
+
throw new AuthError(
|
|
255
|
+
'Google の認証情報が見つからないか、期限切れです。次を実行してから、もう一度 auth を実行してください:\n' +
|
|
256
|
+
' gcloud auth login\n' +
|
|
257
|
+
' (ADC を使う場合は gcloud auth application-default login)'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!TOKEN_PATTERN.test(acquired.token)) {
|
|
261
|
+
throw new AuthError(
|
|
262
|
+
'取得したアクセストークンの形式が想定と異なるため、書き込みを中止しました。'
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const expiresIn = acquired.expiresIn ?? (await lookupExpiresIn(acquired.token, d.fetchImpl));
|
|
266
|
+
|
|
267
|
+
const userConfigPath = resolveUserConfigPath(d.env, d.homedir);
|
|
268
|
+
let current = '';
|
|
269
|
+
let exists = false;
|
|
270
|
+
try {
|
|
271
|
+
current = fs.readFileSync(userConfigPath, 'utf8');
|
|
272
|
+
exists = true;
|
|
273
|
+
} catch {
|
|
274
|
+
// 無ければ新規作成
|
|
275
|
+
}
|
|
276
|
+
const next = upsertAuthToken(current, registry, acquired.token);
|
|
277
|
+
// 本人だけが読めるようにする。既存の ~/.npmrc は既定の 0644(他ユーザーも読める)のことが
|
|
278
|
+
// 多く、そのまま書くと共有マシンで他のアカウントにトークンを読まれうるので、
|
|
279
|
+
// 書き込む前に 0600 へ絞る(Windows は POSIX の権限が無いので対象外)
|
|
280
|
+
let tightened = false;
|
|
281
|
+
if (exists && process.platform !== 'win32') {
|
|
282
|
+
const mode = fs.statSync(userConfigPath).mode & 0o777;
|
|
283
|
+
if (mode !== 0o600) {
|
|
284
|
+
fs.chmodSync(userConfigPath, 0o600);
|
|
285
|
+
tightened = true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
fs.writeFileSync(userConfigPath, next, exists ? undefined : { mode: 0o600 });
|
|
289
|
+
|
|
290
|
+
d.log(`✅ Artifact Registry の認証情報を書き込みました(${acquired.via} の資格情報を使用)`);
|
|
291
|
+
d.log(` registry: ${registry}(${registrySource})`);
|
|
292
|
+
d.log(
|
|
293
|
+
` 書き込み先: ${userConfigPath}${tightened ? '(他ユーザーから読めない権限 0600 に変更しました)' : ''}`
|
|
294
|
+
);
|
|
295
|
+
d.log(
|
|
296
|
+
expiresIn
|
|
297
|
+
? ` 有効期限: ${formatExpiry(expiresIn, d.now())}。切れたらもう一度 auth を実行してください`
|
|
298
|
+
: ' 有効期限: 約 60 分。切れたらもう一度 auth を実行してください'
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return { registry, userConfigPath, expiresIn, via: acquired.via };
|
|
302
|
+
}
|
package/lib/check.js
CHANGED
|
@@ -16,7 +16,9 @@ import { loadAntiPatternPlugins } from './load-plugins.js';
|
|
|
16
16
|
import { MATCH_HELPERS } from './plugin-helpers.js';
|
|
17
17
|
import { REGEX, FONT_DOMAINS } from './constants.js';
|
|
18
18
|
|
|
19
|
-
|
|
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';
|
|
20
22
|
const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
21
23
|
const CSS_EXTENSIONS = new Set(['.css']);
|
|
22
24
|
|
|
@@ -101,9 +103,28 @@ function toRelativeReportPath(filePath) {
|
|
|
101
103
|
/**
|
|
102
104
|
* ディレクトリを1回だけ走査し、テキストファイルと CSS ファイルを同時に収集する
|
|
103
105
|
*/
|
|
104
|
-
function collectFiles(targetPath, textFiles, cssFiles, visited) {
|
|
106
|
+
function collectFiles(targetPath, textFiles, cssFiles, visited, isUserTarget = false) {
|
|
105
107
|
if (!fs.existsSync(targetPath)) {
|
|
106
|
-
|
|
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
|
+
);
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
const realPath = fs.realpathSync(targetPath);
|
|
@@ -394,7 +415,7 @@ function createCheckReport(targets = [], options = {}) {
|
|
|
394
415
|
|
|
395
416
|
// 1回のディレクトリ走査でテキストファイルと CSS ファイルを同時に収集
|
|
396
417
|
for (const target of resolvedTargets) {
|
|
397
|
-
collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited);
|
|
418
|
+
collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited, true);
|
|
398
419
|
}
|
|
399
420
|
|
|
400
421
|
const checkedFiles = [...textFiles, ...cssFiles]
|
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/rules-report.js
CHANGED
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
} from './anti-pattern-rules.js';
|
|
28
28
|
import { loadAntiPatternPlugins } from './load-plugins.js';
|
|
29
29
|
|
|
30
|
+
/** 利用者の手元に `docs/` は無いので、案内は解決できる場所を指す。 */
|
|
31
|
+
const DOCS_URL = 'https://github.com/goodpatch/sparkle-design-cli/blob/main/docs';
|
|
32
|
+
|
|
30
33
|
/** 表示順。`check` のレポートと同じ「重いものが先」に揃える。 */
|
|
31
34
|
const SEVERITY_ORDER = [SEVERITY.ERROR, SEVERITY.WARNING, SEVERITY.INFO];
|
|
32
35
|
|
|
@@ -74,7 +77,24 @@ export async function collectActiveRules(options = {}) {
|
|
|
74
77
|
...getCheckRules(pluginGroups).map(shape('plugin')),
|
|
75
78
|
];
|
|
76
79
|
|
|
77
|
-
|
|
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 };
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
function severityRank(severity) {
|
|
@@ -116,7 +136,11 @@ export function renderRulesJson({ rules, plugins }) {
|
|
|
116
136
|
packageName: record.packageName,
|
|
117
137
|
status: record.status,
|
|
118
138
|
error: record.error ?? null,
|
|
119
|
-
ruleIds
|
|
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 ?? [],
|
|
120
144
|
})),
|
|
121
145
|
},
|
|
122
146
|
null,
|
|
@@ -151,11 +175,17 @@ export function renderRulesText({ rules, plugins }) {
|
|
|
151
175
|
),
|
|
152
176
|
];
|
|
153
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.
|
|
154
182
|
lines.push('');
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
'
|
|
158
|
-
|
|
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 を変えてください。');
|
|
159
189
|
}
|
|
160
190
|
|
|
161
191
|
lines.push('');
|
|
@@ -169,7 +199,12 @@ export function renderRulesText({ rules, plugins }) {
|
|
|
169
199
|
}
|
|
170
200
|
}
|
|
171
201
|
lines.push('');
|
|
172
|
-
|
|
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
|
+
);
|
|
173
208
|
lines.push('抑制するには `sparkle-disable-next-line <rule-id>` を使います。');
|
|
174
209
|
|
|
175
210
|
return lines.join('\n');
|