cursor-lint 0.13.0 → 0.14.0
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/package.json +1 -1
- package/src/cli.js +195 -0
- package/src/diff.js +93 -0
- package/src/doctor.js +134 -0
- package/src/migrate.js +118 -0
- package/src/stats.js +183 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -7,6 +7,10 @@ const { initProject } = require('./init');
|
|
|
7
7
|
const { fixProject } = require('./fix');
|
|
8
8
|
const { generateRules, suggestSkills, listPresets, generateFromPreset } = require('./generate');
|
|
9
9
|
const { checkVersions, checkRuleVersionMismatches } = require('./versions');
|
|
10
|
+
const { showStats } = require('./stats');
|
|
11
|
+
const { migrate } = require('./migrate');
|
|
12
|
+
const { doctor } = require('./doctor');
|
|
13
|
+
const { saveSnapshot, diffSnapshot } = require('./diff');
|
|
10
14
|
|
|
11
15
|
const VERSION = '0.13.0';
|
|
12
16
|
|
|
@@ -18,6 +22,8 @@ const BLUE = '\x1b[34m';
|
|
|
18
22
|
const DIM = '\x1b[2m';
|
|
19
23
|
const RESET = '\x1b[0m';
|
|
20
24
|
|
|
25
|
+
const SNAPSHOT_FILE = '.cursor-lint-snapshot.json';
|
|
26
|
+
|
|
21
27
|
function showHelp() {
|
|
22
28
|
console.log(`
|
|
23
29
|
${CYAN}cursor-lint${RESET} v${VERSION}
|
|
@@ -38,6 +44,11 @@ ${YELLOW}Options:${RESET}
|
|
|
38
44
|
--generate --preset list Show available presets
|
|
39
45
|
--order Show rule load order, priority tiers, and token estimates
|
|
40
46
|
--version-check Detect installed package versions and show relevant rule tips
|
|
47
|
+
--stats Show rule health dashboard (counts, tokens, coverage)
|
|
48
|
+
--migrate Convert .cursorrules to .cursor/rules/*.mdc format
|
|
49
|
+
--doctor Full project health check with letter grade
|
|
50
|
+
--diff save Save current rules as snapshot
|
|
51
|
+
--diff Compare current rules to saved snapshot
|
|
41
52
|
|
|
42
53
|
${YELLOW}What it checks (default):${RESET}
|
|
43
54
|
• .cursorrules files (warns about agent mode compatibility)
|
|
@@ -105,6 +116,10 @@ async function main() {
|
|
|
105
116
|
const isGenerate = args.includes('--generate');
|
|
106
117
|
const isOrder = args.includes('--order');
|
|
107
118
|
const isVersionCheck = args.includes('--version-check');
|
|
119
|
+
const isStats = args.includes('--stats');
|
|
120
|
+
const isMigrate = args.includes('--migrate');
|
|
121
|
+
const isDoctor = args.includes('--doctor');
|
|
122
|
+
const isDiff = args.includes('--diff');
|
|
108
123
|
|
|
109
124
|
if (isVersionCheck) {
|
|
110
125
|
console.log(`\n📦 cursor-lint v${VERSION} --version-check\n`);
|
|
@@ -142,6 +157,186 @@ async function main() {
|
|
|
142
157
|
console.log(`${DIM}Use these notes to customize your .mdc rules for your exact versions.${RESET}\n`);
|
|
143
158
|
process.exit(mismatches.length > 0 ? 1 : 0);
|
|
144
159
|
|
|
160
|
+
} else if (isStats) {
|
|
161
|
+
console.log(`\n📊 cursor-lint v${VERSION} --stats\n`);
|
|
162
|
+
console.log(`Scanning ${cwd}...\n`);
|
|
163
|
+
const stats = showStats(cwd);
|
|
164
|
+
|
|
165
|
+
// Summary
|
|
166
|
+
console.log(`${CYAN}Rule files:${RESET}`);
|
|
167
|
+
console.log(` .mdc files: ${stats.mdcFiles.length}`);
|
|
168
|
+
if (stats.hasCursorrules) console.log(` .cursorrules: 1 (legacy — run --migrate)`);
|
|
169
|
+
console.log(` Skill files: ${stats.skillFiles.length}`);
|
|
170
|
+
console.log(` Total tokens: ~${stats.totalTokens}`);
|
|
171
|
+
console.log();
|
|
172
|
+
|
|
173
|
+
// Tier breakdown
|
|
174
|
+
console.log(`${CYAN}Rule tiers:${RESET}`);
|
|
175
|
+
console.log(` Always active: ${stats.tiers.always}`);
|
|
176
|
+
console.log(` Glob-matched: ${stats.tiers.glob}`);
|
|
177
|
+
console.log(` Manual only: ${stats.tiers.manual}`);
|
|
178
|
+
console.log();
|
|
179
|
+
|
|
180
|
+
// Token breakdown by file
|
|
181
|
+
if (stats.mdcFiles.length > 0) {
|
|
182
|
+
console.log(`${CYAN}Token breakdown:${RESET}`);
|
|
183
|
+
const sorted = [...stats.mdcFiles].sort((a, b) => b.tokens - a.tokens);
|
|
184
|
+
for (const f of sorted) {
|
|
185
|
+
const bar = '█'.repeat(Math.max(1, Math.round(f.tokens / 50)));
|
|
186
|
+
const pct = Math.round((f.tokens / stats.totalTokens) * 100);
|
|
187
|
+
console.log(` ${f.file.padEnd(30)} ${String(f.tokens).padStart(5)} tokens (${String(pct).padStart(2)}%) ${DIM}${bar}${RESET}`);
|
|
188
|
+
}
|
|
189
|
+
console.log();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Coverage gaps
|
|
193
|
+
if (stats.coverageGaps.length > 0) {
|
|
194
|
+
console.log(`${YELLOW}Coverage gaps:${RESET}`);
|
|
195
|
+
for (const gap of stats.coverageGaps) {
|
|
196
|
+
console.log(` ${YELLOW}⚠${RESET} ${gap.ext} files found but no matching rule`);
|
|
197
|
+
console.log(` ${DIM}→ Try: --generate (suggests ${gap.suggestedRules.join(', ')})${RESET}`);
|
|
198
|
+
}
|
|
199
|
+
} else if (stats.mdcFiles.length > 0) {
|
|
200
|
+
console.log(`${GREEN}✓ No coverage gaps detected${RESET}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log();
|
|
204
|
+
process.exit(0);
|
|
205
|
+
|
|
206
|
+
} else if (isMigrate) {
|
|
207
|
+
console.log(`\n🔄 cursor-lint v${VERSION} --migrate\n`);
|
|
208
|
+
const result = migrate(cwd);
|
|
209
|
+
|
|
210
|
+
if (result.error) {
|
|
211
|
+
console.log(`${RED}✗${RESET} ${result.error}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log(`${CYAN}Source:${RESET} .cursorrules (${result.source.lines} lines, ${result.source.chars} chars)\n`);
|
|
216
|
+
|
|
217
|
+
if (result.created.length > 0) {
|
|
218
|
+
console.log(`${GREEN}Created:${RESET}`);
|
|
219
|
+
for (const f of result.created) {
|
|
220
|
+
console.log(` ${GREEN}✓${RESET} .cursor/rules/${f}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (result.skipped.length > 0) {
|
|
225
|
+
console.log(`${YELLOW}Skipped (already exists):${RESET}`);
|
|
226
|
+
for (const f of result.skipped) {
|
|
227
|
+
console.log(` ${YELLOW}⚠${RESET} .cursor/rules/${f}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log();
|
|
232
|
+
console.log(`${DIM}Your .cursorrules file was NOT deleted — verify the migration, then remove it manually.${RESET}`);
|
|
233
|
+
console.log(`${DIM}Run cursor-lint to check the new rules.${RESET}\n`);
|
|
234
|
+
process.exit(0);
|
|
235
|
+
|
|
236
|
+
} else if (isDoctor) {
|
|
237
|
+
console.log(`\n🏥 cursor-lint v${VERSION} --doctor\n`);
|
|
238
|
+
console.log(`Running full health check on ${cwd}...\n`);
|
|
239
|
+
const report = await doctor(cwd);
|
|
240
|
+
|
|
241
|
+
// Grade display
|
|
242
|
+
const gradeColors = { A: GREEN, B: GREEN, C: YELLOW, D: YELLOW, F: RED };
|
|
243
|
+
const gradeColor = gradeColors[report.grade] || RESET;
|
|
244
|
+
console.log(` ${gradeColor}${'━'.repeat(30)}${RESET}`);
|
|
245
|
+
console.log(` ${gradeColor} Project Health: ${report.grade} (${report.percentage}%) ${RESET}`);
|
|
246
|
+
console.log(` ${gradeColor}${'━'.repeat(30)}${RESET}\n`);
|
|
247
|
+
|
|
248
|
+
// Check results
|
|
249
|
+
for (const check of report.checks) {
|
|
250
|
+
let icon;
|
|
251
|
+
if (check.status === 'pass') icon = `${GREEN}✓${RESET}`;
|
|
252
|
+
else if (check.status === 'warn') icon = `${YELLOW}⚠${RESET}`;
|
|
253
|
+
else if (check.status === 'fail') icon = `${RED}✗${RESET}`;
|
|
254
|
+
else icon = `${BLUE}ℹ${RESET}`;
|
|
255
|
+
|
|
256
|
+
console.log(` ${icon} ${check.name}`);
|
|
257
|
+
console.log(` ${DIM}${check.detail}${RESET}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log();
|
|
261
|
+
|
|
262
|
+
// Suggestions based on grade
|
|
263
|
+
if (report.grade === 'F' || report.grade === 'D') {
|
|
264
|
+
console.log(`${YELLOW}Quick wins:${RESET}`);
|
|
265
|
+
console.log(` • Run ${CYAN}cursor-lint --init${RESET} to create starter rules`);
|
|
266
|
+
console.log(` • Run ${CYAN}cursor-lint --generate${RESET} to download rules for your stack`);
|
|
267
|
+
if (report.checks.some(c => c.name === 'No legacy .cursorrules' && c.status === 'warn')) {
|
|
268
|
+
console.log(` • Run ${CYAN}cursor-lint --migrate${RESET} to convert .cursorrules to .mdc`);
|
|
269
|
+
}
|
|
270
|
+
} else if (report.grade === 'C') {
|
|
271
|
+
console.log(`${YELLOW}Improvements:${RESET}`);
|
|
272
|
+
console.log(` • Run ${CYAN}cursor-lint --fix${RESET} to auto-repair common issues`);
|
|
273
|
+
console.log(` • Run ${CYAN}cursor-lint --stats${RESET} to find token waste`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.log();
|
|
277
|
+
process.exit(report.grade === 'F' ? 1 : 0);
|
|
278
|
+
|
|
279
|
+
} else if (isDiff) {
|
|
280
|
+
const isDiffSave = args.includes('save');
|
|
281
|
+
|
|
282
|
+
if (isDiffSave) {
|
|
283
|
+
console.log(`\n📸 cursor-lint v${VERSION} --diff save\n`);
|
|
284
|
+
const { path: snapPath, state } = saveSnapshot(cwd);
|
|
285
|
+
const ruleCount = Object.keys(state.rules).length;
|
|
286
|
+
console.log(`${GREEN}✓${RESET} Snapshot saved to ${path.basename(snapPath)}`);
|
|
287
|
+
console.log(` ${DIM}${ruleCount} rule${ruleCount !== 1 ? 's' : ''} captured at ${state.timestamp}${RESET}\n`);
|
|
288
|
+
console.log(`${DIM}Add ${SNAPSHOT_FILE} to .gitignore, or commit it to track rule changes.${RESET}\n`);
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
console.log(`\n📊 cursor-lint v${VERSION} --diff\n`);
|
|
293
|
+
const changes = diffSnapshot(cwd);
|
|
294
|
+
|
|
295
|
+
if (changes.error) {
|
|
296
|
+
console.log(`${RED}✗${RESET} ${changes.error}\n`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
console.log(`${DIM}Comparing to snapshot from ${changes.savedAt}${RESET}\n`);
|
|
301
|
+
|
|
302
|
+
if (!changes.hasChanges) {
|
|
303
|
+
console.log(`${GREEN}✓ No changes since last snapshot${RESET}\n`);
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (changes.added.length > 0) {
|
|
308
|
+
console.log(`${GREEN}Added:${RESET}`);
|
|
309
|
+
for (const f of changes.added) {
|
|
310
|
+
console.log(` ${GREEN}+${RESET} ${f.file} (${f.tokens} tokens, ${f.lines} lines)`);
|
|
311
|
+
}
|
|
312
|
+
console.log();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (changes.removed.length > 0) {
|
|
316
|
+
console.log(`${RED}Removed:${RESET}`);
|
|
317
|
+
for (const f of changes.removed) {
|
|
318
|
+
console.log(` ${RED}-${RESET} ${f.file} (${f.tokens} tokens, ${f.lines} lines)`);
|
|
319
|
+
}
|
|
320
|
+
console.log();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (changes.modified.length > 0) {
|
|
324
|
+
console.log(`${YELLOW}Modified:${RESET}`);
|
|
325
|
+
for (const f of changes.modified) {
|
|
326
|
+
const tokenDiff = f.newTokens - f.oldTokens;
|
|
327
|
+
const sign = tokenDiff >= 0 ? '+' : '';
|
|
328
|
+
console.log(` ${YELLOW}~${RESET} ${f.file} (${sign}${tokenDiff} tokens, ${f.oldLines}→${f.newLines} lines)`);
|
|
329
|
+
}
|
|
330
|
+
console.log();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Summary
|
|
334
|
+
const sign = changes.tokenDelta >= 0 ? '+' : '';
|
|
335
|
+
console.log(`${CYAN}Summary:${RESET} ${changes.added.length} added, ${changes.removed.length} removed, ${changes.modified.length} modified (${sign}${changes.tokenDelta} tokens)\n`);
|
|
336
|
+
|
|
337
|
+
// Exit 1 if changes detected (useful for CI)
|
|
338
|
+
process.exit(1);
|
|
339
|
+
|
|
145
340
|
} else if (isOrder) {
|
|
146
341
|
const { showLoadOrder } = require('./order');
|
|
147
342
|
console.log(`\n📋 cursor-lint v${VERSION} --order\n`);
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const SNAPSHOT_FILE = '.cursor-lint-snapshot.json';
|
|
6
|
+
|
|
7
|
+
function hashContent(content) {
|
|
8
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function estimateTokens(text) {
|
|
12
|
+
return Math.ceil(text.length / 4);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function captureState(dir) {
|
|
16
|
+
const state = { rules: {}, cursorrules: null, timestamp: new Date().toISOString() };
|
|
17
|
+
|
|
18
|
+
// .cursorrules
|
|
19
|
+
const cursorrules = path.join(dir, '.cursorrules');
|
|
20
|
+
if (fs.existsSync(cursorrules)) {
|
|
21
|
+
const content = fs.readFileSync(cursorrules, 'utf-8');
|
|
22
|
+
state.cursorrules = { hash: hashContent(content), tokens: estimateTokens(content), lines: content.split('\n').length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// .cursor/rules/*.mdc
|
|
26
|
+
const rulesDir = path.join(dir, '.cursor', 'rules');
|
|
27
|
+
if (fs.existsSync(rulesDir) && fs.statSync(rulesDir).isDirectory()) {
|
|
28
|
+
for (const entry of fs.readdirSync(rulesDir).filter(f => f.endsWith('.mdc')).sort()) {
|
|
29
|
+
const content = fs.readFileSync(path.join(rulesDir, entry), 'utf-8');
|
|
30
|
+
state.rules[entry] = { hash: hashContent(content), tokens: estimateTokens(content), lines: content.split('\n').length };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveSnapshot(dir) {
|
|
38
|
+
const state = captureState(dir);
|
|
39
|
+
const snapshotPath = path.join(dir, SNAPSHOT_FILE);
|
|
40
|
+
fs.writeFileSync(snapshotPath, JSON.stringify(state, null, 2), 'utf-8');
|
|
41
|
+
return { path: snapshotPath, state };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function diffSnapshot(dir) {
|
|
45
|
+
const snapshotPath = path.join(dir, SNAPSHOT_FILE);
|
|
46
|
+
if (!fs.existsSync(snapshotPath)) {
|
|
47
|
+
return { error: 'No snapshot found. Run cursor-lint --diff save first.' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const saved = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
|
|
51
|
+
const current = captureState(dir);
|
|
52
|
+
|
|
53
|
+
const changes = { added: [], removed: [], modified: [], unchanged: [], tokenDelta: 0, savedAt: saved.timestamp };
|
|
54
|
+
|
|
55
|
+
// Compare rules
|
|
56
|
+
const allFiles = new Set([...Object.keys(saved.rules), ...Object.keys(current.rules)]);
|
|
57
|
+
|
|
58
|
+
for (const file of allFiles) {
|
|
59
|
+
const s = saved.rules[file];
|
|
60
|
+
const c = current.rules[file];
|
|
61
|
+
|
|
62
|
+
if (!s && c) {
|
|
63
|
+
changes.added.push({ file, tokens: c.tokens, lines: c.lines });
|
|
64
|
+
changes.tokenDelta += c.tokens;
|
|
65
|
+
} else if (s && !c) {
|
|
66
|
+
changes.removed.push({ file, tokens: s.tokens, lines: s.lines });
|
|
67
|
+
changes.tokenDelta -= s.tokens;
|
|
68
|
+
} else if (s.hash !== c.hash) {
|
|
69
|
+
changes.modified.push({ file, oldTokens: s.tokens, newTokens: c.tokens, oldLines: s.lines, newLines: c.lines });
|
|
70
|
+
changes.tokenDelta += (c.tokens - s.tokens);
|
|
71
|
+
} else {
|
|
72
|
+
changes.unchanged.push(file);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// .cursorrules changes
|
|
77
|
+
if (!saved.cursorrules && current.cursorrules) {
|
|
78
|
+
changes.added.push({ file: '.cursorrules', tokens: current.cursorrules.tokens, lines: current.cursorrules.lines });
|
|
79
|
+
changes.tokenDelta += current.cursorrules.tokens;
|
|
80
|
+
} else if (saved.cursorrules && !current.cursorrules) {
|
|
81
|
+
changes.removed.push({ file: '.cursorrules', tokens: saved.cursorrules.tokens, lines: saved.cursorrules.lines });
|
|
82
|
+
changes.tokenDelta -= saved.cursorrules.tokens;
|
|
83
|
+
} else if (saved.cursorrules && current.cursorrules && saved.cursorrules.hash !== current.cursorrules.hash) {
|
|
84
|
+
changes.modified.push({ file: '.cursorrules', oldTokens: saved.cursorrules.tokens, newTokens: current.cursorrules.tokens, oldLines: saved.cursorrules.lines, newLines: current.cursorrules.lines });
|
|
85
|
+
changes.tokenDelta += (current.cursorrules.tokens - saved.cursorrules.tokens);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
changes.hasChanges = changes.added.length > 0 || changes.removed.length > 0 || changes.modified.length > 0;
|
|
89
|
+
|
|
90
|
+
return changes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { saveSnapshot, diffSnapshot, captureState };
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { lintProject } = require('./index');
|
|
4
|
+
const { showStats } = require('./stats');
|
|
5
|
+
|
|
6
|
+
async function doctor(dir) {
|
|
7
|
+
const report = {
|
|
8
|
+
checks: [],
|
|
9
|
+
score: 0,
|
|
10
|
+
maxScore: 0,
|
|
11
|
+
grade: 'F',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// 1. Check if any rules exist at all
|
|
15
|
+
report.maxScore += 20;
|
|
16
|
+
const rulesDir = path.join(dir, '.cursor', 'rules');
|
|
17
|
+
const hasMdc = fs.existsSync(rulesDir) && fs.readdirSync(rulesDir).filter(f => f.endsWith('.mdc')).length > 0;
|
|
18
|
+
const hasCursorrules = fs.existsSync(path.join(dir, '.cursorrules'));
|
|
19
|
+
|
|
20
|
+
if (hasMdc) {
|
|
21
|
+
report.score += 20;
|
|
22
|
+
report.checks.push({ name: 'Rules exist', status: 'pass', detail: '.cursor/rules/ found with .mdc files' });
|
|
23
|
+
} else if (hasCursorrules) {
|
|
24
|
+
report.score += 5;
|
|
25
|
+
report.checks.push({ name: 'Rules exist', status: 'warn', detail: 'Only .cursorrules found — run --migrate to convert to .mdc format' });
|
|
26
|
+
} else {
|
|
27
|
+
report.checks.push({ name: 'Rules exist', status: 'fail', detail: 'No rules found. Run --init or --generate to create rules.' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. Check for .cursorrules (should be migrated)
|
|
31
|
+
report.maxScore += 10;
|
|
32
|
+
if (hasCursorrules && hasMdc) {
|
|
33
|
+
report.score += 5;
|
|
34
|
+
report.checks.push({ name: 'No legacy .cursorrules', status: 'warn', detail: '.cursorrules exists alongside .mdc rules — may cause conflicts. Consider removing it.' });
|
|
35
|
+
} else if (!hasCursorrules) {
|
|
36
|
+
report.score += 10;
|
|
37
|
+
report.checks.push({ name: 'No legacy .cursorrules', status: 'pass', detail: 'Good — using modern .mdc format only' });
|
|
38
|
+
} else {
|
|
39
|
+
report.checks.push({ name: 'No legacy .cursorrules', status: 'warn', detail: 'Using legacy .cursorrules — run --migrate to convert' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Run lint checks and count issues
|
|
43
|
+
report.maxScore += 30;
|
|
44
|
+
const lintResults = await lintProject(dir);
|
|
45
|
+
let errors = 0;
|
|
46
|
+
let warnings = 0;
|
|
47
|
+
for (const r of lintResults) {
|
|
48
|
+
for (const i of r.issues) {
|
|
49
|
+
if (i.severity === 'error') errors++;
|
|
50
|
+
else if (i.severity === 'warning') warnings++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (errors === 0 && warnings === 0) {
|
|
55
|
+
report.score += 30;
|
|
56
|
+
report.checks.push({ name: 'Lint checks', status: 'pass', detail: 'All rules pass lint checks' });
|
|
57
|
+
} else if (errors === 0) {
|
|
58
|
+
report.score += 20;
|
|
59
|
+
report.checks.push({ name: 'Lint checks', status: 'warn', detail: `${warnings} warning${warnings !== 1 ? 's' : ''} found. Run cursor-lint to see details.` });
|
|
60
|
+
} else {
|
|
61
|
+
report.score += Math.max(0, 10 - errors * 2);
|
|
62
|
+
report.checks.push({ name: 'Lint checks', status: 'fail', detail: `${errors} error${errors !== 1 ? 's' : ''}, ${warnings} warning${warnings !== 1 ? 's' : ''}. Run cursor-lint to fix.` });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 4. Token budget check
|
|
66
|
+
report.maxScore += 15;
|
|
67
|
+
const stats = showStats(dir);
|
|
68
|
+
if (stats.totalTokens === 0) {
|
|
69
|
+
report.checks.push({ name: 'Token budget', status: 'info', detail: 'No rules to measure' });
|
|
70
|
+
} else if (stats.totalTokens < 2000) {
|
|
71
|
+
report.score += 15;
|
|
72
|
+
report.checks.push({ name: 'Token budget', status: 'pass', detail: `~${stats.totalTokens} tokens — well within budget` });
|
|
73
|
+
} else if (stats.totalTokens < 5000) {
|
|
74
|
+
report.score += 10;
|
|
75
|
+
report.checks.push({ name: 'Token budget', status: 'warn', detail: `~${stats.totalTokens} tokens — getting heavy. Consider trimming or splitting rules.` });
|
|
76
|
+
} else {
|
|
77
|
+
report.score += 5;
|
|
78
|
+
report.checks.push({ name: 'Token budget', status: 'fail', detail: `~${stats.totalTokens} tokens — very heavy. This eats into your context window every request.` });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 5. Coverage gaps
|
|
82
|
+
report.maxScore += 15;
|
|
83
|
+
if (stats.coverageGaps.length === 0) {
|
|
84
|
+
report.score += 15;
|
|
85
|
+
report.checks.push({ name: 'Coverage', status: 'pass', detail: 'Rules cover your project file types' });
|
|
86
|
+
} else if (stats.coverageGaps.length <= 2) {
|
|
87
|
+
report.score += 10;
|
|
88
|
+
const gaps = stats.coverageGaps.map(g => g.ext).join(', ');
|
|
89
|
+
report.checks.push({ name: 'Coverage', status: 'warn', detail: `Missing rules for: ${gaps}. Run --generate to add them.` });
|
|
90
|
+
} else {
|
|
91
|
+
report.score += 5;
|
|
92
|
+
const gaps = stats.coverageGaps.map(g => g.ext).join(', ');
|
|
93
|
+
report.checks.push({ name: 'Coverage', status: 'fail', detail: `Missing rules for: ${gaps}. Run --generate to add them.` });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 6. Skills check
|
|
97
|
+
report.maxScore += 10;
|
|
98
|
+
const skillDirs = [
|
|
99
|
+
path.join(dir, '.claude', 'skills'),
|
|
100
|
+
path.join(dir, '.cursor', 'skills'),
|
|
101
|
+
path.join(dir, 'skills'),
|
|
102
|
+
];
|
|
103
|
+
const hasSkills = skillDirs.some(sd => {
|
|
104
|
+
if (!fs.existsSync(sd)) return false;
|
|
105
|
+
try {
|
|
106
|
+
return fs.readdirSync(sd).some(e => {
|
|
107
|
+
const sub = path.join(sd, e);
|
|
108
|
+
return fs.statSync(sub).isDirectory() && fs.existsSync(path.join(sub, 'SKILL.md'));
|
|
109
|
+
});
|
|
110
|
+
} catch { return false; }
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (hasSkills) {
|
|
114
|
+
report.score += 10;
|
|
115
|
+
report.checks.push({ name: 'Agent skills', status: 'pass', detail: 'Skills directory found' });
|
|
116
|
+
} else {
|
|
117
|
+
report.score += 5; // not having skills is fine, just not optimal
|
|
118
|
+
report.checks.push({ name: 'Agent skills', status: 'info', detail: 'No agent skills found. Skills are optional but can improve agent behavior for complex workflows.' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Calculate grade
|
|
122
|
+
const pct = (report.score / report.maxScore) * 100;
|
|
123
|
+
if (pct >= 90) report.grade = 'A';
|
|
124
|
+
else if (pct >= 75) report.grade = 'B';
|
|
125
|
+
else if (pct >= 60) report.grade = 'C';
|
|
126
|
+
else if (pct >= 40) report.grade = 'D';
|
|
127
|
+
else report.grade = 'F';
|
|
128
|
+
|
|
129
|
+
report.percentage = Math.round(pct);
|
|
130
|
+
|
|
131
|
+
return report;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { doctor };
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function migrate(dir) {
|
|
5
|
+
const cursorrules = path.join(dir, '.cursorrules');
|
|
6
|
+
const result = { created: [], skipped: [], source: null, error: null };
|
|
7
|
+
|
|
8
|
+
if (!fs.existsSync(cursorrules)) {
|
|
9
|
+
result.error = 'No .cursorrules file found in this directory';
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const content = fs.readFileSync(cursorrules, 'utf-8').trim();
|
|
14
|
+
result.source = { file: '.cursorrules', chars: content.length, lines: content.split('\n').length };
|
|
15
|
+
|
|
16
|
+
if (content.length === 0) {
|
|
17
|
+
result.error = '.cursorrules file is empty';
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const rulesDir = path.join(dir, '.cursor', 'rules');
|
|
22
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
// Try to split by markdown headings (## or #)
|
|
25
|
+
const sections = splitBySections(content);
|
|
26
|
+
|
|
27
|
+
if (sections.length <= 1) {
|
|
28
|
+
// Single file migration
|
|
29
|
+
const filename = 'project-rules.mdc';
|
|
30
|
+
const destPath = path.join(rulesDir, filename);
|
|
31
|
+
if (fs.existsSync(destPath)) {
|
|
32
|
+
result.skipped.push(filename);
|
|
33
|
+
} else {
|
|
34
|
+
const mdc = wrapInMdc(content, 'Project rules migrated from .cursorrules');
|
|
35
|
+
fs.writeFileSync(destPath, mdc, 'utf-8');
|
|
36
|
+
result.created.push(filename);
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
// Multi-file migration
|
|
40
|
+
for (const section of sections) {
|
|
41
|
+
const filename = slugify(section.title) + '.mdc';
|
|
42
|
+
const destPath = path.join(rulesDir, filename);
|
|
43
|
+
if (fs.existsSync(destPath)) {
|
|
44
|
+
result.skipped.push(filename);
|
|
45
|
+
} else {
|
|
46
|
+
const mdc = wrapInMdc(section.body, section.title);
|
|
47
|
+
fs.writeFileSync(destPath, mdc, 'utf-8');
|
|
48
|
+
result.created.push(filename);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function splitBySections(content) {
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
const sections = [];
|
|
59
|
+
let currentTitle = null;
|
|
60
|
+
let currentBody = [];
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const headingMatch = line.match(/^#{1,2}\s+(.+)/);
|
|
64
|
+
if (headingMatch) {
|
|
65
|
+
if (currentTitle !== null) {
|
|
66
|
+
sections.push({ title: currentTitle, body: currentBody.join('\n').trim() });
|
|
67
|
+
}
|
|
68
|
+
currentTitle = headingMatch[1].trim();
|
|
69
|
+
currentBody = [];
|
|
70
|
+
} else {
|
|
71
|
+
currentBody.push(line);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Push last section
|
|
76
|
+
if (currentTitle !== null) {
|
|
77
|
+
sections.push({ title: currentTitle, body: currentBody.join('\n').trim() });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If no headings found, check for content before first heading
|
|
81
|
+
if (sections.length === 0) {
|
|
82
|
+
return [{ title: 'Project Rules', body: content }];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// If there's content before the first heading, include it
|
|
86
|
+
const firstHeadingIdx = content.search(/^#{1,2}\s+/m);
|
|
87
|
+
if (firstHeadingIdx > 0) {
|
|
88
|
+
const preamble = content.slice(0, firstHeadingIdx).trim();
|
|
89
|
+
if (preamble.length > 20) {
|
|
90
|
+
sections.unshift({ title: 'General', body: preamble });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Filter out empty sections
|
|
95
|
+
return sections.filter(s => s.body.length > 10);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function slugify(title) {
|
|
99
|
+
return title
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
102
|
+
.replace(/\s+/g, '-')
|
|
103
|
+
.replace(/-+/g, '-')
|
|
104
|
+
.replace(/^-|-$/g, '')
|
|
105
|
+
.slice(0, 40) || 'rule';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function wrapInMdc(body, description) {
|
|
109
|
+
return `---
|
|
110
|
+
description: "${description.replace(/"/g, '\\"')}"
|
|
111
|
+
alwaysApply: true
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
${body}
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { migrate };
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function estimateTokens(text) {
|
|
5
|
+
return Math.ceil(text.length / 4);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function parseFrontmatter(content) {
|
|
9
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
10
|
+
if (!match) return { found: false, data: null };
|
|
11
|
+
const data = {};
|
|
12
|
+
const lines = match[1].split('\n');
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const colonIdx = line.indexOf(':');
|
|
15
|
+
if (colonIdx === -1) continue;
|
|
16
|
+
const key = line.slice(0, colonIdx).trim();
|
|
17
|
+
const rawVal = line.slice(colonIdx + 1).trim();
|
|
18
|
+
if (rawVal === 'true') data[key] = true;
|
|
19
|
+
else if (rawVal === 'false') data[key] = false;
|
|
20
|
+
else if (rawVal.startsWith('"') && rawVal.endsWith('"')) data[key] = rawVal.slice(1, -1);
|
|
21
|
+
else data[key] = rawVal;
|
|
22
|
+
}
|
|
23
|
+
return { found: true, data };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseGlobs(globVal) {
|
|
27
|
+
if (!globVal) return [];
|
|
28
|
+
if (typeof globVal === 'string') {
|
|
29
|
+
const trimmed = globVal.trim();
|
|
30
|
+
if (trimmed.startsWith('[')) {
|
|
31
|
+
return trimmed.slice(1, -1).split(',').map(g => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
return trimmed.split(',').map(g => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(globVal)) return globVal;
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getProjectFileExtensions(dir) {
|
|
40
|
+
const extensions = new Set();
|
|
41
|
+
const ignoreDirs = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.cursor', '__pycache__', '.venv', 'venv']);
|
|
42
|
+
|
|
43
|
+
function walk(d, depth) {
|
|
44
|
+
if (depth > 3) return; // don't go too deep
|
|
45
|
+
try {
|
|
46
|
+
for (const entry of fs.readdirSync(d)) {
|
|
47
|
+
if (ignoreDirs.has(entry)) continue;
|
|
48
|
+
const full = path.join(d, entry);
|
|
49
|
+
try {
|
|
50
|
+
const stat = fs.statSync(full);
|
|
51
|
+
if (stat.isDirectory()) {
|
|
52
|
+
walk(full, depth + 1);
|
|
53
|
+
} else {
|
|
54
|
+
const ext = path.extname(entry);
|
|
55
|
+
if (ext) extensions.add(ext);
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
walk(dir, 0);
|
|
62
|
+
return extensions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Map file extensions to what kind of rules would cover them
|
|
66
|
+
const EXT_TO_CATEGORY = {
|
|
67
|
+
'.ts': ['typescript', 'react', 'nextjs', 'angular', 'nestjs'],
|
|
68
|
+
'.tsx': ['typescript', 'react', 'nextjs'],
|
|
69
|
+
'.js': ['javascript', 'react', 'nextjs', 'express', 'node'],
|
|
70
|
+
'.jsx': ['javascript', 'react'],
|
|
71
|
+
'.py': ['python', 'django', 'fastapi', 'flask'],
|
|
72
|
+
'.rb': ['ruby', 'rails'],
|
|
73
|
+
'.go': ['go'],
|
|
74
|
+
'.rs': ['rust'],
|
|
75
|
+
'.java': ['java', 'spring-boot'],
|
|
76
|
+
'.kt': ['kotlin'],
|
|
77
|
+
'.swift': ['swift'],
|
|
78
|
+
'.php': ['php', 'laravel'],
|
|
79
|
+
'.vue': ['vue', 'nuxt'],
|
|
80
|
+
'.svelte': ['svelte', 'sveltekit'],
|
|
81
|
+
'.css': ['tailwind-css'],
|
|
82
|
+
'.scss': ['tailwind-css'],
|
|
83
|
+
'.html': [],
|
|
84
|
+
'.json': [],
|
|
85
|
+
'.yaml': [],
|
|
86
|
+
'.yml': [],
|
|
87
|
+
'.md': [],
|
|
88
|
+
'.mdc': [],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function showStats(dir) {
|
|
92
|
+
const stats = {
|
|
93
|
+
mdcFiles: [],
|
|
94
|
+
hasCursorrules: false,
|
|
95
|
+
cursorrulesTokens: 0,
|
|
96
|
+
skillFiles: [],
|
|
97
|
+
totalTokens: 0,
|
|
98
|
+
tiers: { always: 0, glob: 0, manual: 0 },
|
|
99
|
+
coverageGaps: [],
|
|
100
|
+
projectExtensions: new Set(),
|
|
101
|
+
coveredExtensions: new Set(),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// .cursorrules
|
|
105
|
+
const cursorrules = path.join(dir, '.cursorrules');
|
|
106
|
+
if (fs.existsSync(cursorrules)) {
|
|
107
|
+
stats.hasCursorrules = true;
|
|
108
|
+
const content = fs.readFileSync(cursorrules, 'utf-8');
|
|
109
|
+
stats.cursorrulesTokens = estimateTokens(content);
|
|
110
|
+
stats.totalTokens += stats.cursorrulesTokens;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// .cursor/rules/*.mdc
|
|
114
|
+
const rulesDir = path.join(dir, '.cursor', 'rules');
|
|
115
|
+
if (fs.existsSync(rulesDir) && fs.statSync(rulesDir).isDirectory()) {
|
|
116
|
+
for (const entry of fs.readdirSync(rulesDir)) {
|
|
117
|
+
if (!entry.endsWith('.mdc')) continue;
|
|
118
|
+
const filePath = path.join(rulesDir, entry);
|
|
119
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
120
|
+
const tokens = estimateTokens(content);
|
|
121
|
+
const fm = parseFrontmatter(content);
|
|
122
|
+
|
|
123
|
+
let tier = 'manual';
|
|
124
|
+
let globs = [];
|
|
125
|
+
if (fm.found && fm.data) {
|
|
126
|
+
globs = parseGlobs(fm.data.globs);
|
|
127
|
+
if (fm.data.alwaysApply === true) tier = 'always';
|
|
128
|
+
else if (globs.length > 0) tier = 'glob';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
stats.tiers[tier]++;
|
|
132
|
+
stats.totalTokens += tokens;
|
|
133
|
+
stats.mdcFiles.push({ file: entry, tokens, tier, globs });
|
|
134
|
+
|
|
135
|
+
// Track covered extensions from globs
|
|
136
|
+
for (const g of globs) {
|
|
137
|
+
const extMatch = g.match(/\*\.(\w+)$/);
|
|
138
|
+
if (extMatch) stats.coveredExtensions.add('.' + extMatch[1]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Skill files
|
|
144
|
+
const skillDirs = [
|
|
145
|
+
path.join(dir, '.claude', 'skills'),
|
|
146
|
+
path.join(dir, '.cursor', 'skills'),
|
|
147
|
+
path.join(dir, 'skills'),
|
|
148
|
+
];
|
|
149
|
+
for (const sd of skillDirs) {
|
|
150
|
+
if (!fs.existsSync(sd)) continue;
|
|
151
|
+
try {
|
|
152
|
+
for (const entry of fs.readdirSync(sd)) {
|
|
153
|
+
const sub = path.join(sd, entry);
|
|
154
|
+
if (fs.statSync(sub).isDirectory()) {
|
|
155
|
+
const skillMd = path.join(sub, 'SKILL.md');
|
|
156
|
+
if (fs.existsSync(skillMd)) {
|
|
157
|
+
const content = fs.readFileSync(skillMd, 'utf-8');
|
|
158
|
+
stats.skillFiles.push({ file: path.relative(dir, skillMd), tokens: estimateTokens(content) });
|
|
159
|
+
stats.totalTokens += estimateTokens(content);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Coverage analysis
|
|
167
|
+
stats.projectExtensions = getProjectFileExtensions(dir);
|
|
168
|
+
|
|
169
|
+
// Find gaps: project has files of type X but no rule covers them
|
|
170
|
+
const ruleNames = stats.mdcFiles.map(f => f.file.replace('.mdc', '').toLowerCase());
|
|
171
|
+
for (const ext of stats.projectExtensions) {
|
|
172
|
+
const categories = EXT_TO_CATEGORY[ext];
|
|
173
|
+
if (!categories || categories.length === 0) continue;
|
|
174
|
+
const covered = categories.some(cat => ruleNames.some(r => r.includes(cat)));
|
|
175
|
+
if (!covered && !stats.coveredExtensions.has(ext)) {
|
|
176
|
+
stats.coverageGaps.push({ ext, suggestedRules: categories });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return stats;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { showStats };
|