sparkle-design-cli 1.3.8 → 1.3.10
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/README.md +126 -17
- package/bin/sparkle-design.js +212 -53
- package/lib/anti-pattern-rules.js +901 -0
- package/lib/check.js +174 -0
- package/lib/setup.js +260 -0
- package/package.json +3 -2
package/lib/check.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { getCheckRules, getManualReviewReminders } from './anti-pattern-rules.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TARGET = 'src';
|
|
7
|
+
const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
8
|
+
const RULES = getCheckRules();
|
|
9
|
+
const MANUAL_REVIEW_REMINDERS = getManualReviewReminders();
|
|
10
|
+
|
|
11
|
+
function toRelativeReportPath(filePath) {
|
|
12
|
+
return path.relative(process.cwd(), filePath).split(path.sep).join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function collectFiles(targetPath, files, visited) {
|
|
16
|
+
if (!fs.existsSync(targetPath)) {
|
|
17
|
+
throw new Error(`Target path does not exist: ${targetPath}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const realPath = fs.realpathSync(targetPath);
|
|
21
|
+
if (visited.has(realPath)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
visited.add(realPath);
|
|
25
|
+
|
|
26
|
+
const stat = fs.statSync(realPath);
|
|
27
|
+
|
|
28
|
+
if (stat.isFile()) {
|
|
29
|
+
if (TEXT_EXTENSIONS.has(path.extname(realPath))) {
|
|
30
|
+
files.add(realPath);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!stat.isDirectory()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const entry of fs.readdirSync(realPath, { withFileTypes: true })) {
|
|
40
|
+
if (entry.name === 'node_modules' || entry.name === '.git') {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
collectFiles(path.join(realPath, entry.name), files, visited);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getLineNumber(content, index) {
|
|
49
|
+
return content.slice(0, index).split(/\r?\n/).length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatMatch(match) {
|
|
53
|
+
return match[0].replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectFindings(filePath) {
|
|
57
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
58
|
+
const findings = [];
|
|
59
|
+
|
|
60
|
+
for (const rule of RULES) {
|
|
61
|
+
for (const match of content.matchAll(rule.pattern)) {
|
|
62
|
+
findings.push({
|
|
63
|
+
filePath,
|
|
64
|
+
id: rule.id,
|
|
65
|
+
description: rule.description,
|
|
66
|
+
recommendation: rule.recommendation,
|
|
67
|
+
line: getLineNumber(content, match.index ?? 0),
|
|
68
|
+
snippet: formatMatch(match),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return findings;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createCheckReport(targets = []) {
|
|
77
|
+
const resolvedTargets = targets.length > 0 ? targets : [DEFAULT_TARGET];
|
|
78
|
+
const files = new Set();
|
|
79
|
+
const visited = new Set();
|
|
80
|
+
|
|
81
|
+
for (const target of resolvedTargets) {
|
|
82
|
+
collectFiles(path.resolve(process.cwd(), target), files, visited);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const checkedFiles = [...files]
|
|
86
|
+
.map((filePath) => toRelativeReportPath(filePath))
|
|
87
|
+
.sort((left, right) => left.localeCompare(right));
|
|
88
|
+
const findings = [...files]
|
|
89
|
+
.flatMap(collectFindings)
|
|
90
|
+
.map((finding) => ({
|
|
91
|
+
...finding,
|
|
92
|
+
filePath: toRelativeReportPath(finding.filePath),
|
|
93
|
+
}))
|
|
94
|
+
.sort((left, right) => {
|
|
95
|
+
const fileComparison = left.filePath.localeCompare(right.filePath);
|
|
96
|
+
if (fileComparison !== 0) {
|
|
97
|
+
return fileComparison;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (left.line !== right.line) {
|
|
101
|
+
return left.line - right.line;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return left.id.localeCompare(right.id);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
targets: resolvedTargets,
|
|
109
|
+
checkedFiles,
|
|
110
|
+
findings,
|
|
111
|
+
manualReviewReminders: MANUAL_REVIEW_REMINDERS,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function printTextReport(report, options = {}) {
|
|
116
|
+
if (report.findings.length === 0) {
|
|
117
|
+
console.log('sparkle-design-cli check: no findings');
|
|
118
|
+
} else {
|
|
119
|
+
console.log(`sparkle-design-cli check: ${report.findings.length} finding(s)\n`);
|
|
120
|
+
|
|
121
|
+
for (const finding of report.findings) {
|
|
122
|
+
console.log(`${finding.filePath}:${finding.line} [${finding.id}] ${finding.description}`);
|
|
123
|
+
console.log(` Recommendation: ${finding.recommendation}`);
|
|
124
|
+
console.log(` Snippet: ${finding.snippet}`);
|
|
125
|
+
console.log('');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log('Manual review reminders:');
|
|
130
|
+
for (const reminder of report.manualReviewReminders) {
|
|
131
|
+
console.log(`- [${reminder.id}] ${reminder.message}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (options.strict && report.findings.length > 0) {
|
|
135
|
+
console.error('sparkle-design-cli check: failed because --strict was specified');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printJsonReport(report, options = {}) {
|
|
140
|
+
const strictMode = Boolean(options.strict);
|
|
141
|
+
const passed = !(strictMode && report.findings.length > 0);
|
|
142
|
+
|
|
143
|
+
console.log(
|
|
144
|
+
JSON.stringify(
|
|
145
|
+
{
|
|
146
|
+
summary: {
|
|
147
|
+
targetCount: report.targets.length,
|
|
148
|
+
checkedFileCount: report.checkedFiles.length,
|
|
149
|
+
findingCount: report.findings.length,
|
|
150
|
+
strictMode,
|
|
151
|
+
passed,
|
|
152
|
+
exitCode: passed ? 0 : 1,
|
|
153
|
+
},
|
|
154
|
+
...report,
|
|
155
|
+
},
|
|
156
|
+
null,
|
|
157
|
+
2
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function checkProject(targets = [], options = {}) {
|
|
163
|
+
const report = createCheckReport(targets);
|
|
164
|
+
|
|
165
|
+
if (options.format === 'json') {
|
|
166
|
+
printJsonReport(report, options);
|
|
167
|
+
} else {
|
|
168
|
+
printTextReport(report, options);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return report.findings.length > 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { RULES, collectFindings, createCheckReport, MANUAL_REVIEW_REMINDERS };
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const ASSISTANT_CONFIG = {
|
|
5
|
+
claude: {
|
|
6
|
+
path: 'CLAUDE.md',
|
|
7
|
+
},
|
|
8
|
+
codex: {
|
|
9
|
+
path: 'AGENTS.md',
|
|
10
|
+
},
|
|
11
|
+
cursor: {
|
|
12
|
+
path: '.cursor/rules/sparkle-design-guard.mdc',
|
|
13
|
+
},
|
|
14
|
+
generic: {
|
|
15
|
+
path: 'SPARKLE-DESIGN-AI.md',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const BLOCK_START = '<!-- sparkle-design-cli:setup:start -->';
|
|
20
|
+
const BLOCK_END = '<!-- sparkle-design-cli:setup:end -->';
|
|
21
|
+
const TARGET_CANDIDATES = ['src', 'src/components', 'src/features', 'app', 'components'];
|
|
22
|
+
const UNSAFE_TARGET_PATTERN = /[\s"'`;$&|<>()[\]{}\\]/;
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
function escapeRegExp(value) {
|
|
26
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureDir(filePath) {
|
|
30
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJson(filePath) {
|
|
34
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeJson(filePath, value) {
|
|
38
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeTarget(cwd, target, sourceLabel) {
|
|
42
|
+
const trimmed = target.trim();
|
|
43
|
+
|
|
44
|
+
if (!trimmed) {
|
|
45
|
+
throw new Error(`Target path is empty (${sourceLabel})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (path.isAbsolute(trimmed)) {
|
|
49
|
+
throw new Error(`Target path must be relative to the project root: ${trimmed}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (UNSAFE_TARGET_PATTERN.test(trimmed)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Target path contains unsupported characters: ${trimmed}. Use a simple relative path without spaces or shell metacharacters.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalized = path.posix.normalize(trimmed.replace(/\\/g, '/'));
|
|
59
|
+
|
|
60
|
+
if (normalized === '.' || normalized === '') {
|
|
61
|
+
throw new Error(`Target path is empty (${sourceLabel})`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (normalized.startsWith('../')) {
|
|
65
|
+
throw new Error(`Target path must stay inside the project root: ${trimmed}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const absolutePath = path.resolve(cwd, normalized);
|
|
69
|
+
if (!fs.existsSync(absolutePath)) {
|
|
70
|
+
throw new Error(`Target path does not exist: ${normalized}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function detectTarget(cwd) {
|
|
77
|
+
for (const candidate of TARGET_CANDIDATES) {
|
|
78
|
+
const absolutePath = path.resolve(cwd, candidate);
|
|
79
|
+
if (fs.existsSync(absolutePath)) {
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error(
|
|
85
|
+
'Could not auto-detect a lint target. Pass --target with an existing relative path such as src or app.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isManagedSparkleScript(value, mode) {
|
|
90
|
+
if (!value || typeof value !== 'string') {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tokens = value.trim().split(/\s+/);
|
|
95
|
+
if (tokens.length < 3) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (tokens[0] !== 'sparkle-design-cli' || tokens[1] !== 'check') {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hasStrict = tokens.includes('--strict');
|
|
104
|
+
const formatIndex = tokens.indexOf('--format');
|
|
105
|
+
const formatValue = formatIndex >= 0 ? tokens[formatIndex + 1] : null;
|
|
106
|
+
|
|
107
|
+
if (mode === 'strict') {
|
|
108
|
+
return hasStrict && formatIndex === -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (mode === 'json') {
|
|
112
|
+
return formatIndex >= 0 && formatValue === 'json' && !hasStrict;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildInstructionBlock(target, assistant) {
|
|
119
|
+
const heading = assistant === 'cursor' ? '# Sparkle Design Guard' : '## Sparkle Design Guard';
|
|
120
|
+
|
|
121
|
+
return [
|
|
122
|
+
BLOCK_START,
|
|
123
|
+
heading,
|
|
124
|
+
'',
|
|
125
|
+
'- Run `lint:sparkle` when Sparkle Design changes are involved.',
|
|
126
|
+
`- For AI review, prefer \`lint:sparkle:json\` or run \`npx --yes sparkle-design-cli check ${target} --format json\` directly.`,
|
|
127
|
+
'- Always inspect both `findings` and `manualReviewReminders` from the JSON output.',
|
|
128
|
+
'- If semantic guidance is still needed, consult Sparkle Design docs and JSDoc examples.',
|
|
129
|
+
BLOCK_END,
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updateInstructionFile(filePath, block) {
|
|
134
|
+
const exists = fs.existsSync(filePath);
|
|
135
|
+
const current = exists ? fs.readFileSync(filePath, 'utf8') : '';
|
|
136
|
+
|
|
137
|
+
if (current.includes(BLOCK_START) && current.includes(BLOCK_END)) {
|
|
138
|
+
const next = current.replace(new RegExp(`${BLOCK_START}[\\s\\S]*?${BLOCK_END}`), block);
|
|
139
|
+
return { changed: next !== current, content: next, existed: exists };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const separator = current.trim().length > 0 ? '\n\n' : '';
|
|
143
|
+
return {
|
|
144
|
+
changed: true,
|
|
145
|
+
content: `${current}${separator}${block}\n`,
|
|
146
|
+
existed: exists,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveScriptUpdate(existingValue, nextValue, mode, force) {
|
|
151
|
+
if (!existingValue) {
|
|
152
|
+
return { value: nextValue, changed: true, conflict: false, reason: 'created' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (existingValue === nextValue) {
|
|
156
|
+
return { value: existingValue, changed: false, conflict: false, reason: 'unchanged' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (force || isManagedSparkleScript(existingValue, mode)) {
|
|
160
|
+
return { value: nextValue, changed: true, conflict: false, reason: force ? 'forced' : 'updated' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { value: existingValue, changed: false, conflict: true, reason: 'preserved-custom' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function updatePackageJson(filePath, target, force) {
|
|
167
|
+
const pkg = readJson(filePath);
|
|
168
|
+
const scripts = { ...(pkg.scripts ?? {}) };
|
|
169
|
+
|
|
170
|
+
const strictResult = resolveScriptUpdate(
|
|
171
|
+
scripts['lint:sparkle'],
|
|
172
|
+
`sparkle-design-cli check ${target} --strict`,
|
|
173
|
+
'strict',
|
|
174
|
+
force
|
|
175
|
+
);
|
|
176
|
+
const jsonResult = resolveScriptUpdate(
|
|
177
|
+
scripts['lint:sparkle:json'],
|
|
178
|
+
`sparkle-design-cli check ${target} --format json`,
|
|
179
|
+
'json',
|
|
180
|
+
force
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
scripts['lint:sparkle'] = strictResult.value;
|
|
184
|
+
scripts['lint:sparkle:json'] = jsonResult.value;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
changed: strictResult.changed || jsonResult.changed || !pkg.scripts,
|
|
188
|
+
packageJson: {
|
|
189
|
+
...pkg,
|
|
190
|
+
scripts,
|
|
191
|
+
},
|
|
192
|
+
scriptResults: {
|
|
193
|
+
'lint:sparkle': strictResult,
|
|
194
|
+
'lint:sparkle:json': jsonResult,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function setupAssistant(options = {}) {
|
|
200
|
+
const assistant = options.assistant ?? 'generic';
|
|
201
|
+
const cwd = process.cwd();
|
|
202
|
+
const dryRun = Boolean(options.dryRun);
|
|
203
|
+
const force = Boolean(options.forceScriptUpdate);
|
|
204
|
+
const assistantConfig = ASSISTANT_CONFIG[assistant];
|
|
205
|
+
|
|
206
|
+
if (!assistantConfig) {
|
|
207
|
+
throw new Error(`Unsupported assistant: ${assistant}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const targetSource = options.target ? 'explicit' : 'auto';
|
|
211
|
+
const target = options.target
|
|
212
|
+
? normalizeTarget(cwd, options.target, 'explicit')
|
|
213
|
+
: normalizeTarget(cwd, detectTarget(cwd), 'auto');
|
|
214
|
+
|
|
215
|
+
const packageJsonPath = path.resolve(cwd, 'package.json');
|
|
216
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
217
|
+
throw new Error(`package.json not found in ${cwd}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const instructionPath = path.resolve(cwd, assistantConfig.path);
|
|
221
|
+
const packageResult = updatePackageJson(packageJsonPath, target, force);
|
|
222
|
+
const instructionBlock = buildInstructionBlock(target, assistant);
|
|
223
|
+
const instructionResult = updateInstructionFile(instructionPath, instructionBlock);
|
|
224
|
+
|
|
225
|
+
if (!dryRun) {
|
|
226
|
+
if (packageResult.changed) {
|
|
227
|
+
writeJson(packageJsonPath, packageResult.packageJson);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (instructionResult.changed) {
|
|
231
|
+
ensureDir(instructionPath);
|
|
232
|
+
fs.writeFileSync(instructionPath, instructionResult.content, 'utf8');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const summary = {
|
|
237
|
+
assistant,
|
|
238
|
+
target,
|
|
239
|
+
targetSource,
|
|
240
|
+
dryRun,
|
|
241
|
+
forceScriptUpdate: force,
|
|
242
|
+
packageJson: {
|
|
243
|
+
path: path.relative(cwd, packageJsonPath),
|
|
244
|
+
changed: packageResult.changed,
|
|
245
|
+
scripts: {
|
|
246
|
+
'lint:sparkle': packageResult.packageJson.scripts?.['lint:sparkle'] ?? null,
|
|
247
|
+
'lint:sparkle:json': packageResult.packageJson.scripts?.['lint:sparkle:json'] ?? null,
|
|
248
|
+
},
|
|
249
|
+
results: packageResult.scriptResults,
|
|
250
|
+
},
|
|
251
|
+
instructions: {
|
|
252
|
+
path: path.relative(cwd, instructionPath),
|
|
253
|
+
changed: instructionResult.changed,
|
|
254
|
+
existed: instructionResult.existed,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
259
|
+
return summary;
|
|
260
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkle-design-cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.10",
|
|
4
4
|
"description": "Sparkle Design CSS Generator - デザインシステムCSSを設定ファイルから生成するツール",
|
|
5
5
|
"main": "lib/generate-css.js",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"lint": "eslint bin/ lib/ test/",
|
|
14
14
|
"lint:fix": "eslint bin/ lib/ test/ --fix",
|
|
15
15
|
"format": "prettier --write bin/ lib/ test/ *.md *.json",
|
|
16
|
-
"format:check": "prettier --check bin/ lib/ test/ *.md *.json"
|
|
16
|
+
"format:check": "prettier --check bin/ lib/ test/ *.md *.json",
|
|
17
|
+
"sync:anti-pattern-docs": "node scripts/sync-anti-pattern-docs.mjs"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [
|
|
19
20
|
"css",
|