cursor-lint 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-lint",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Lint your Cursor rules — catch common mistakes before they break your workflow",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -5,10 +5,14 @@ const { lintProject } = require('./index');
5
5
  const { verifyProject } = require('./verify');
6
6
  const { initProject } = require('./init');
7
7
  const { fixProject } = require('./fix');
8
- const { generateRules, suggestSkills } = require('./generate');
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
- const VERSION = '0.12.0';
15
+ const VERSION = '0.13.0';
12
16
 
13
17
  const RED = '\x1b[31m';
14
18
  const YELLOW = '\x1b[33m';
@@ -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}
@@ -34,8 +40,15 @@ ${YELLOW}Options:${RESET}
34
40
  --init Generate starter .mdc rules (auto-detects your stack)
35
41
  --fix Auto-fix common issues (missing frontmatter, alwaysApply)
36
42
  --generate Auto-detect stack & download matching .mdc rules from GitHub
43
+ --generate --preset <name> Install rules for a popular stack preset
44
+ --generate --preset list Show available presets
37
45
  --order Show rule load order, priority tiers, and token estimates
38
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
39
52
 
40
53
  ${YELLOW}What it checks (default):${RESET}
41
54
  • .cursorrules files (warns about agent mode compatibility)
@@ -103,6 +116,10 @@ async function main() {
103
116
  const isGenerate = args.includes('--generate');
104
117
  const isOrder = args.includes('--order');
105
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');
106
123
 
107
124
  if (isVersionCheck) {
108
125
  console.log(`\n📦 cursor-lint v${VERSION} --version-check\n`);
@@ -140,6 +157,186 @@ async function main() {
140
157
  console.log(`${DIM}Use these notes to customize your .mdc rules for your exact versions.${RESET}\n`);
141
158
  process.exit(mismatches.length > 0 ? 1 : 0);
142
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
+
143
340
  } else if (isOrder) {
144
341
  const { showLoadOrder } = require('./order');
145
342
  console.log(`\n📋 cursor-lint v${VERSION} --order\n`);
@@ -208,13 +405,85 @@ async function main() {
208
405
  process.exit(0);
209
406
 
210
407
  } else if (isGenerate) {
408
+ // Check for --preset flag
409
+ const presetIndex = args.indexOf('--preset');
410
+ const hasPreset = presetIndex !== -1;
411
+ const presetValue = hasPreset ? args[presetIndex + 1] : null;
412
+
413
+ // Handle --preset list
414
+ if (hasPreset && presetValue === 'list') {
415
+ console.log(`\n🚀 cursor-lint v${VERSION} --generate --preset list\n`);
416
+ console.log(`${CYAN}Available presets:${RESET}\n`);
417
+
418
+ const presets = listPresets();
419
+ for (const [key, preset] of Object.entries(presets)) {
420
+ const paddedKey = key.padEnd(12);
421
+ console.log(` ${GREEN}${paddedKey}${RESET} ${preset.name} — ${DIM}${preset.description}${RESET}`);
422
+ }
423
+
424
+ console.log(`\n${YELLOW}Usage:${RESET} cursor-lint --generate --preset t3\n`);
425
+ process.exit(0);
426
+ }
427
+
428
+ // Handle --preset <name>
429
+ if (hasPreset && presetValue && presetValue !== 'list') {
430
+ console.log(`\n🚀 cursor-lint v${VERSION} --generate --preset ${presetValue}\n`);
431
+
432
+ const presets = listPresets();
433
+ if (!presets[presetValue]) {
434
+ console.log(`${RED}Unknown preset: ${presetValue}${RESET}\n`);
435
+ console.log(`Run ${CYAN}cursor-lint --generate --preset list${RESET} to see available presets\n`);
436
+ process.exit(1);
437
+ }
438
+
439
+ const results = await generateFromPreset(cwd, presetValue);
440
+
441
+ console.log(`${CYAN}Preset:${RESET} ${results.presetInfo.name}`);
442
+ console.log(`${DIM}${results.presetInfo.description}${RESET}\n`);
443
+
444
+ if (results.created.length > 0) {
445
+ console.log(`${GREEN}Downloaded:${RESET}`);
446
+ for (const r of results.created) {
447
+ console.log(` ${GREEN}✓${RESET} .cursor/rules/${r.file}`);
448
+ }
449
+ }
450
+
451
+ if (results.skipped.length > 0) {
452
+ console.log(`\n${YELLOW}Skipped (already exist):${RESET}`);
453
+ for (const r of results.skipped) {
454
+ console.log(` ${YELLOW}⚠${RESET} .cursor/rules/${r.file}`);
455
+ }
456
+ }
457
+
458
+ if (results.failed.length > 0) {
459
+ console.log(`\n${RED}Failed:${RESET}`);
460
+ for (const r of results.failed) {
461
+ console.log(` ${RED}✗${RESET} ${r.file} — ${r.error}`);
462
+ }
463
+ }
464
+
465
+ if (results.created.length > 0) {
466
+ console.log(`\n${DIM}Run cursor-lint to check these rules${RESET}\n`);
467
+ }
468
+
469
+ process.exit(results.failed.length > 0 ? 1 : 0);
470
+ }
471
+
472
+ // Regular --generate (no preset)
211
473
  console.log(`\n🚀 cursor-lint v${VERSION} --generate\n`);
212
474
  console.log(`Detecting stack in ${cwd}...\n`);
213
475
 
214
476
  const results = await generateRules(cwd);
215
477
 
216
478
  if (results.detected.length > 0) {
217
- console.log(`${CYAN}Detected:${RESET} ${results.detected.join(', ')}\n`);
479
+ console.log(`${CYAN}Detected stack:${RESET} ${results.detected.join(', ')}`);
480
+
481
+ // Show versions if available
482
+ if (results.versions && Object.keys(results.versions).length > 0) {
483
+ const versionStrs = Object.entries(results.versions).map(([dep, ver]) => `${dep}@${ver}`);
484
+ console.log(`${CYAN}Versions:${RESET} ${versionStrs.join(', ')}`);
485
+ }
486
+ console.log();
218
487
  } else {
219
488
  console.log(`${YELLOW}No recognized stack detected.${RESET}`);
220
489
  console.log(`${DIM}Supports: package.json, tsconfig.json, requirements.txt, pyproject.toml,${RESET}`);
@@ -250,6 +519,13 @@ async function main() {
250
519
  }
251
520
  }
252
521
 
522
+ if (results.fallbacks && results.fallbacks.length > 0) {
523
+ console.log(`\n${YELLOW}Fallbacks (version-specific rule not found):${RESET}`);
524
+ for (const r of results.fallbacks) {
525
+ console.log(` ${YELLOW}⚠${RESET} ${r.from} → ${r.to} ${DIM}(${r.stack})${RESET}`);
526
+ }
527
+ }
528
+
253
529
  if (results.failed.length > 0) {
254
530
  console.log(`\n${RED}Failed:${RESET}`);
255
531
  for (const r of results.failed) {
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/generate.js CHANGED
@@ -4,6 +4,32 @@ const https = require('https');
4
4
 
5
5
  const BASE_URL = 'https://raw.githubusercontent.com/nedcodes-ok/cursorrules-collection/main/rules-mdc/';
6
6
 
7
+ // Version-specific rule overrides: if user has react 19, use react-19.mdc instead of react.mdc
8
+ const VERSION_RULES = {
9
+ 'react': {
10
+ '19': 'frameworks/react-19.mdc',
11
+ '18': 'frameworks/react-18.mdc',
12
+ },
13
+ 'next': {
14
+ '15': 'frameworks/nextjs-15.mdc',
15
+ '14': 'frameworks/nextjs-14.mdc',
16
+ '13': 'frameworks/nextjs-13.mdc',
17
+ },
18
+ 'vue': {
19
+ '3': 'frameworks/vue-3.mdc',
20
+ '2': 'frameworks/vue-2.mdc',
21
+ },
22
+ '@angular/core': {
23
+ '19': 'frameworks/angular-19.mdc',
24
+ '18': 'frameworks/angular-18.mdc',
25
+ '17': 'frameworks/angular-17.mdc',
26
+ },
27
+ 'svelte': {
28
+ '5': 'frameworks/svelte-5.mdc',
29
+ '4': 'frameworks/svelte-4.mdc',
30
+ },
31
+ };
32
+
7
33
  // package.json dependencies → rule files
8
34
  const PKG_DEP_MAP = {
9
35
  // Frameworks
@@ -189,6 +215,7 @@ function detectStack(cwd) {
189
215
  const detected = [];
190
216
  const rules = new Map(); // rulePath -> stackName
191
217
  const allDetectedDeps = [];
218
+ const versions = {}; // dep -> version string
192
219
 
193
220
  // package.json
194
221
  const pkgPath = path.join(cwd, 'package.json');
@@ -201,7 +228,21 @@ function detectStack(cwd) {
201
228
  if (pkgDeps[dep]) {
202
229
  detected.push(dep);
203
230
  allDetectedDeps.push(dep);
204
- rules.set(rule, dep);
231
+
232
+ // Extract version
233
+ const rawVersion = pkgDeps[dep];
234
+ versions[dep] = rawVersion;
235
+
236
+ // Parse major version (strip ^, ~, >=, etc.)
237
+ const versionMatch = rawVersion.match(/(\d+)/);
238
+ const majorVersion = versionMatch ? versionMatch[1] : null;
239
+
240
+ // Check if version-specific rule exists
241
+ if (majorVersion && VERSION_RULES[dep] && VERSION_RULES[dep][majorVersion]) {
242
+ rules.set(VERSION_RULES[dep][majorVersion], dep);
243
+ } else {
244
+ rules.set(rule, dep);
245
+ }
205
246
  }
206
247
  }
207
248
  } catch {}
@@ -406,18 +447,19 @@ function detectStack(cwd) {
406
447
  }
407
448
  }
408
449
 
409
- return { detected, rules };
450
+ return { detected, rules, versions };
410
451
  }
411
452
 
412
453
  async function generateRules(cwd) {
413
- const { detected, rules } = detectStack(cwd);
454
+ const { detected, rules, versions } = detectStack(cwd);
414
455
  const rulesDir = path.join(cwd, '.cursor', 'rules');
415
456
  const created = [];
416
457
  const skipped = [];
417
458
  const failed = [];
459
+ const fallbacks = [];
418
460
 
419
461
  if (rules.size === 0) {
420
- return { detected, created, skipped, failed };
462
+ return { detected, created, skipped, failed, versions };
421
463
  }
422
464
 
423
465
  fs.mkdirSync(rulesDir, { recursive: true });
@@ -437,13 +479,130 @@ async function generateRules(cwd) {
437
479
  fs.writeFileSync(destPath, content, 'utf8');
438
480
  created.push({ file: filename, stack: stackName });
439
481
  } catch (err) {
440
- failed.push({ file: filename, stack: stackName, error: err.message });
482
+ // Try fallback to generic rule if this was a version-specific rule
483
+ const genericRule = PKG_DEP_MAP[stackName];
484
+ if (genericRule && genericRule !== rulePath) {
485
+ try {
486
+ const fallbackUrl = BASE_URL + genericRule;
487
+ const content = await fetchFile(fallbackUrl);
488
+ const genericFilename = path.basename(genericRule);
489
+ const genericDestPath = path.join(rulesDir, genericFilename);
490
+ fs.writeFileSync(genericDestPath, content, 'utf8');
491
+ created.push({ file: genericFilename, stack: stackName });
492
+ fallbacks.push({ from: filename, to: genericFilename, stack: stackName });
493
+ } catch (fallbackErr) {
494
+ failed.push({ file: filename, stack: stackName, error: err.message });
495
+ }
496
+ } else {
497
+ failed.push({ file: filename, stack: stackName, error: err.message });
498
+ }
441
499
  }
442
500
  }
443
501
 
444
- return { detected, created, skipped, failed };
502
+ return { detected, created, skipped, failed, versions, fallbacks };
445
503
  }
446
504
 
505
+ const STACK_PRESETS = {
506
+ 't3': {
507
+ name: 'T3 Stack',
508
+ description: 'Next.js + TypeScript + Tailwind + tRPC + Prisma + NextAuth',
509
+ rules: [
510
+ 'languages/typescript.mdc',
511
+ 'frameworks/nextjs.mdc',
512
+ 'frameworks/tailwind-css.mdc',
513
+ 'frameworks/zod.mdc',
514
+ 'frameworks/t3-stack.mdc',
515
+ 'tools/trpc.mdc',
516
+ 'tools/prisma.mdc',
517
+ 'tools/nextauth.mdc',
518
+ 'practices/clean-code.mdc',
519
+ 'practices/error-handling.mdc',
520
+ ],
521
+ },
522
+ 'mern': {
523
+ name: 'MERN Stack',
524
+ description: 'MongoDB + Express + React + Node.js',
525
+ rules: [
526
+ 'languages/typescript.mdc',
527
+ 'languages/javascript.mdc',
528
+ 'frameworks/react.mdc',
529
+ 'frameworks/express.mdc',
530
+ 'tools/mongodb.mdc',
531
+ 'practices/api-design.mdc',
532
+ 'practices/error-handling.mdc',
533
+ 'practices/clean-code.mdc',
534
+ ],
535
+ },
536
+ 'fastapi': {
537
+ name: 'Python FastAPI',
538
+ description: 'FastAPI + Pydantic + SQLAlchemy + pytest',
539
+ rules: [
540
+ 'languages/python.mdc',
541
+ 'frameworks/fastapi.mdc',
542
+ 'tools/pydantic.mdc',
543
+ 'tools/sqlalchemy.mdc',
544
+ 'tools/pytest.mdc',
545
+ 'practices/api-design.mdc',
546
+ 'practices/error-handling.mdc',
547
+ 'practices/clean-code.mdc',
548
+ ],
549
+ },
550
+ 'sveltekit': {
551
+ name: 'SvelteKit Full Stack',
552
+ description: 'SvelteKit + Svelte + Tailwind + Prisma + TypeScript',
553
+ rules: [
554
+ 'languages/typescript.mdc',
555
+ 'frameworks/sveltekit.mdc',
556
+ 'frameworks/svelte.mdc',
557
+ 'frameworks/tailwind-css.mdc',
558
+ 'tools/prisma.mdc',
559
+ 'practices/clean-code.mdc',
560
+ 'practices/error-handling.mdc',
561
+ ],
562
+ },
563
+ 'rails': {
564
+ name: 'Ruby on Rails',
565
+ description: 'Rails + Ruby + PostgreSQL + Testing',
566
+ rules: [
567
+ 'languages/ruby.mdc',
568
+ 'frameworks/rails.mdc',
569
+ 'tools/postgresql.mdc',
570
+ 'practices/testing.mdc',
571
+ 'practices/api-design.mdc',
572
+ 'practices/database-migrations.mdc',
573
+ 'practices/clean-code.mdc',
574
+ ],
575
+ },
576
+ 'nextjs': {
577
+ name: 'Next.js Full Stack',
578
+ description: 'Next.js + React + TypeScript + Tailwind + Prisma',
579
+ rules: [
580
+ 'languages/typescript.mdc',
581
+ 'frameworks/nextjs.mdc',
582
+ 'frameworks/react.mdc',
583
+ 'frameworks/tailwind-css.mdc',
584
+ 'tools/prisma.mdc',
585
+ 'practices/clean-code.mdc',
586
+ 'practices/performance.mdc',
587
+ 'practices/error-handling.mdc',
588
+ ],
589
+ },
590
+ 'django': {
591
+ name: 'Django Full Stack',
592
+ description: 'Django + Python + PostgreSQL + pytest',
593
+ rules: [
594
+ 'languages/python.mdc',
595
+ 'frameworks/django.mdc',
596
+ 'tools/postgresql.mdc',
597
+ 'tools/pytest.mdc',
598
+ 'practices/api-design.mdc',
599
+ 'practices/database-migrations.mdc',
600
+ 'practices/security.mdc',
601
+ 'practices/clean-code.mdc',
602
+ ],
603
+ },
604
+ };
605
+
447
606
  const SKILLS_API = 'https://skills.sh/api/search';
448
607
 
449
608
  function searchSkillsAPI(query, limit) {
@@ -497,4 +656,43 @@ async function suggestSkills(detected) {
497
656
  return allResults.slice(0, 10);
498
657
  }
499
658
 
500
- module.exports = { generateRules, suggestSkills };
659
+ function listPresets() {
660
+ return STACK_PRESETS;
661
+ }
662
+
663
+ async function generateFromPreset(cwd, presetName) {
664
+ const preset = STACK_PRESETS[presetName];
665
+ if (!preset) {
666
+ throw new Error(`Unknown preset: ${presetName}`);
667
+ }
668
+
669
+ const rulesDir = path.join(cwd, '.cursor', 'rules');
670
+ const created = [];
671
+ const skipped = [];
672
+ const failed = [];
673
+
674
+ fs.mkdirSync(rulesDir, { recursive: true });
675
+
676
+ for (const rulePath of preset.rules) {
677
+ const filename = path.basename(rulePath);
678
+ const destPath = path.join(rulesDir, filename);
679
+
680
+ if (fs.existsSync(destPath)) {
681
+ skipped.push({ file: filename, rule: rulePath });
682
+ continue;
683
+ }
684
+
685
+ try {
686
+ const url = BASE_URL + rulePath;
687
+ const content = await fetchFile(url);
688
+ fs.writeFileSync(destPath, content, 'utf8');
689
+ created.push({ file: filename, rule: rulePath });
690
+ } catch (err) {
691
+ failed.push({ file: filename, rule: rulePath, error: err.message });
692
+ }
693
+ }
694
+
695
+ return { preset: presetName, presetInfo: preset, created, skipped, failed };
696
+ }
697
+
698
+ module.exports = { generateRules, suggestSkills, listPresets, generateFromPreset };
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 };