cursor-lint 0.11.1 → 0.13.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/dw/cursor-lint)](https://www.npmjs.com/package/cursor-lint)
4
4
  [![npm version](https://img.shields.io/npm/v/cursor-lint)](https://www.npmjs.com/package/cursor-lint)
5
5
 
6
- Lint your [Cursor](https://cursor.com) rules. Catch common mistakes before they silently break your workflow.
6
+ Lint your [Cursor](https://cursor.com) rules. Find problems before they silently break your output.
7
7
 
8
8
  ![cursor-lint demo](demo.png)
9
9
 
@@ -133,6 +133,10 @@ Made by [nedcodes](https://dev.to/nedcodes) · [Free rules collection](https://g
133
133
 
134
134
  ---
135
135
 
136
+ ## Stay Updated
137
+
138
+ Get experiment results and Cursor findings in your inbox: [subscribe](https://buttondown.com/nedcodes)
139
+
136
140
  ## Related
137
141
 
138
142
  - [cursorrules-collection](https://github.com/nedcodes-ok/cursorrules-collection) — 104 free .mdc rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-lint",
3
- "version": "0.11.1",
3
+ "version": "0.13.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,15 +5,16 @@ 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
10
 
11
- const VERSION = '0.11.0';
11
+ const VERSION = '0.13.0';
12
12
 
13
13
  const RED = '\x1b[31m';
14
14
  const YELLOW = '\x1b[33m';
15
15
  const GREEN = '\x1b[32m';
16
16
  const CYAN = '\x1b[36m';
17
+ const BLUE = '\x1b[34m';
17
18
  const DIM = '\x1b[2m';
18
19
  const RESET = '\x1b[0m';
19
20
 
@@ -33,6 +34,8 @@ ${YELLOW}Options:${RESET}
33
34
  --init Generate starter .mdc rules (auto-detects your stack)
34
35
  --fix Auto-fix common issues (missing frontmatter, alwaysApply)
35
36
  --generate Auto-detect stack & download matching .mdc rules from GitHub
37
+ --generate --preset <name> Install rules for a popular stack preset
38
+ --generate --preset list Show available presets
36
39
  --order Show rule load order, priority tiers, and token estimates
37
40
  --version-check Detect installed package versions and show relevant rule tips
38
41
 
@@ -42,6 +45,14 @@ ${YELLOW}What it checks (default):${RESET}
42
45
  • Agent skill files (SKILL.md in .claude/skills/, skills/)
43
46
  • Vague rules that won't change AI behavior
44
47
  • YAML syntax errors
48
+ • Rule length (token waste detection)
49
+ • Missing code examples in rules
50
+ • Empty rule bodies, URL-only rules
51
+ • Description quality (too short/long)
52
+ • Glob pattern issues (too broad, spaces, missing extensions)
53
+ • Excessive rule count (>20 files)
54
+ • Duplicate/near-duplicate rules across files
55
+ • Conflicting directives between rules
45
56
 
46
57
  ${YELLOW}What --verify checks:${RESET}
47
58
  • Scans code files matching rule globs
@@ -199,13 +210,85 @@ async function main() {
199
210
  process.exit(0);
200
211
 
201
212
  } else if (isGenerate) {
213
+ // Check for --preset flag
214
+ const presetIndex = args.indexOf('--preset');
215
+ const hasPreset = presetIndex !== -1;
216
+ const presetValue = hasPreset ? args[presetIndex + 1] : null;
217
+
218
+ // Handle --preset list
219
+ if (hasPreset && presetValue === 'list') {
220
+ console.log(`\n🚀 cursor-lint v${VERSION} --generate --preset list\n`);
221
+ console.log(`${CYAN}Available presets:${RESET}\n`);
222
+
223
+ const presets = listPresets();
224
+ for (const [key, preset] of Object.entries(presets)) {
225
+ const paddedKey = key.padEnd(12);
226
+ console.log(` ${GREEN}${paddedKey}${RESET} ${preset.name} — ${DIM}${preset.description}${RESET}`);
227
+ }
228
+
229
+ console.log(`\n${YELLOW}Usage:${RESET} cursor-lint --generate --preset t3\n`);
230
+ process.exit(0);
231
+ }
232
+
233
+ // Handle --preset <name>
234
+ if (hasPreset && presetValue && presetValue !== 'list') {
235
+ console.log(`\n🚀 cursor-lint v${VERSION} --generate --preset ${presetValue}\n`);
236
+
237
+ const presets = listPresets();
238
+ if (!presets[presetValue]) {
239
+ console.log(`${RED}Unknown preset: ${presetValue}${RESET}\n`);
240
+ console.log(`Run ${CYAN}cursor-lint --generate --preset list${RESET} to see available presets\n`);
241
+ process.exit(1);
242
+ }
243
+
244
+ const results = await generateFromPreset(cwd, presetValue);
245
+
246
+ console.log(`${CYAN}Preset:${RESET} ${results.presetInfo.name}`);
247
+ console.log(`${DIM}${results.presetInfo.description}${RESET}\n`);
248
+
249
+ if (results.created.length > 0) {
250
+ console.log(`${GREEN}Downloaded:${RESET}`);
251
+ for (const r of results.created) {
252
+ console.log(` ${GREEN}✓${RESET} .cursor/rules/${r.file}`);
253
+ }
254
+ }
255
+
256
+ if (results.skipped.length > 0) {
257
+ console.log(`\n${YELLOW}Skipped (already exist):${RESET}`);
258
+ for (const r of results.skipped) {
259
+ console.log(` ${YELLOW}⚠${RESET} .cursor/rules/${r.file}`);
260
+ }
261
+ }
262
+
263
+ if (results.failed.length > 0) {
264
+ console.log(`\n${RED}Failed:${RESET}`);
265
+ for (const r of results.failed) {
266
+ console.log(` ${RED}✗${RESET} ${r.file} — ${r.error}`);
267
+ }
268
+ }
269
+
270
+ if (results.created.length > 0) {
271
+ console.log(`\n${DIM}Run cursor-lint to check these rules${RESET}\n`);
272
+ }
273
+
274
+ process.exit(results.failed.length > 0 ? 1 : 0);
275
+ }
276
+
277
+ // Regular --generate (no preset)
202
278
  console.log(`\n🚀 cursor-lint v${VERSION} --generate\n`);
203
279
  console.log(`Detecting stack in ${cwd}...\n`);
204
280
 
205
281
  const results = await generateRules(cwd);
206
282
 
207
283
  if (results.detected.length > 0) {
208
- console.log(`${CYAN}Detected:${RESET} ${results.detected.join(', ')}\n`);
284
+ console.log(`${CYAN}Detected stack:${RESET} ${results.detected.join(', ')}`);
285
+
286
+ // Show versions if available
287
+ if (results.versions && Object.keys(results.versions).length > 0) {
288
+ const versionStrs = Object.entries(results.versions).map(([dep, ver]) => `${dep}@${ver}`);
289
+ console.log(`${CYAN}Versions:${RESET} ${versionStrs.join(', ')}`);
290
+ }
291
+ console.log();
209
292
  } else {
210
293
  console.log(`${YELLOW}No recognized stack detected.${RESET}`);
211
294
  console.log(`${DIM}Supports: package.json, tsconfig.json, requirements.txt, pyproject.toml,${RESET}`);
@@ -241,6 +324,13 @@ async function main() {
241
324
  }
242
325
  }
243
326
 
327
+ if (results.fallbacks && results.fallbacks.length > 0) {
328
+ console.log(`\n${YELLOW}Fallbacks (version-specific rule not found):${RESET}`);
329
+ for (const r of results.fallbacks) {
330
+ console.log(` ${YELLOW}⚠${RESET} ${r.from} → ${r.to} ${DIM}(${r.stack})${RESET}`);
331
+ }
332
+ }
333
+
244
334
  if (results.failed.length > 0) {
245
335
  console.log(`\n${RED}Failed:${RESET}`);
246
336
  for (const r of results.failed) {
@@ -397,6 +487,7 @@ async function main() {
397
487
 
398
488
  let totalErrors = 0;
399
489
  let totalWarnings = 0;
490
+ let totalInfo = 0;
400
491
  let totalPassed = 0;
401
492
 
402
493
  for (const result of results) {
@@ -408,7 +499,11 @@ async function main() {
408
499
  totalPassed++;
409
500
  } else {
410
501
  for (const issue of result.issues) {
411
- const icon = issue.severity === 'error' ? `${RED}✗${RESET}` : `${YELLOW}⚠${RESET}`;
502
+ let icon;
503
+ if (issue.severity === 'error') icon = `${RED}✗${RESET}`;
504
+ else if (issue.severity === 'info') icon = `${BLUE}ℹ${RESET}`;
505
+ else icon = `${YELLOW}⚠${RESET}`;
506
+
412
507
  const lineInfo = issue.line ? ` ${DIM}(line ${issue.line})${RESET}` : '';
413
508
  console.log(` ${icon} ${issue.message}${lineInfo}`);
414
509
  if (issue.hint) {
@@ -417,9 +512,11 @@ async function main() {
417
512
  }
418
513
  const errors = result.issues.filter(i => i.severity === 'error').length;
419
514
  const warnings = result.issues.filter(i => i.severity === 'warning').length;
515
+ const infos = result.issues.filter(i => i.severity === 'info').length;
420
516
  totalErrors += errors;
421
517
  totalWarnings += warnings;
422
- if (errors === 0 && warnings === 0) totalPassed++;
518
+ totalInfo += infos;
519
+ if (errors === 0 && warnings === 0 && infos === 0) totalPassed++;
423
520
  }
424
521
  console.log();
425
522
  }
@@ -428,6 +525,7 @@ async function main() {
428
525
  const parts = [];
429
526
  if (totalErrors > 0) parts.push(`${RED}${totalErrors} error${totalErrors !== 1 ? 's' : ''}${RESET}`);
430
527
  if (totalWarnings > 0) parts.push(`${YELLOW}${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}${RESET}`);
528
+ if (totalInfo > 0) parts.push(`${BLUE}${totalInfo} info${RESET}`);
431
529
  if (totalPassed > 0) parts.push(`${GREEN}${totalPassed} passed${RESET}`);
432
530
  console.log(parts.join(', ') + '\n');
433
531
 
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/index.js CHANGED
@@ -59,6 +59,37 @@ function parseFrontmatter(content) {
59
59
  }
60
60
  }
61
61
 
62
+ // Helper: Get body content after frontmatter
63
+ function getBody(content) {
64
+ const match = content.match(/^---\n[\s\S]*?\n---\n?/);
65
+ if (!match) return content;
66
+ return content.slice(match[0].length);
67
+ }
68
+
69
+ // Helper: Calculate similarity between two texts using Jaccard similarity
70
+ function similarity(textA, textB) {
71
+ const normalize = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
72
+ const normA = normalize(textA);
73
+ const normB = normalize(textB);
74
+
75
+ // Check if one is substring of the other
76
+ if (normA.includes(normB) || normB.includes(normA)) {
77
+ return 1.0;
78
+ }
79
+
80
+ // Word-based Jaccard similarity
81
+ const wordsA = new Set(normA.split(/\s+/));
82
+ const wordsB = new Set(normB.split(/\s+/));
83
+
84
+ if (wordsA.size === 0 && wordsB.size === 0) return 1.0;
85
+ if (wordsA.size === 0 || wordsB.size === 0) return 0.0;
86
+
87
+ const intersection = new Set([...wordsA].filter(w => wordsB.has(w)));
88
+ const union = new Set([...wordsA, ...wordsB]);
89
+
90
+ return intersection.size / union.size;
91
+ }
92
+
62
93
  async function lintMdcFile(filePath) {
63
94
  const content = fs.readFileSync(filePath, 'utf-8');
64
95
  const issues = [];
@@ -91,6 +122,112 @@ async function lintMdcFile(filePath) {
91
122
  }
92
123
  }
93
124
 
125
+ // Get body content for additional checks
126
+ const body = getBody(content);
127
+
128
+ // 1. Rule too long
129
+ if (body.length > 2000) {
130
+ issues.push({
131
+ severity: 'warning',
132
+ message: 'Rule body is very long (>2000 chars, ~500+ tokens)',
133
+ hint: 'Shorter, specific rules outperform long generic ones. Consider splitting into focused rules.',
134
+ });
135
+ }
136
+
137
+ // 2. No examples
138
+ const hasCodeBlocks = /```/.test(body) || /\n {4,}\S/.test(body);
139
+ if (body.length > 200 && !hasCodeBlocks) {
140
+ issues.push({
141
+ severity: 'warning',
142
+ message: 'Rule has no code examples',
143
+ hint: 'Rules with examples get followed more reliably by the AI model.',
144
+ });
145
+ }
146
+
147
+ // 3. Empty rule body
148
+ if (fm.found && body.trim().length === 0) {
149
+ issues.push({
150
+ severity: 'error',
151
+ message: 'Rule file has frontmatter but no instructions',
152
+ hint: 'Add rule instructions after the --- frontmatter block.',
153
+ });
154
+ }
155
+
156
+ // 4. Description too short
157
+ if (fm.data && fm.data.description && fm.data.description.length < 10) {
158
+ issues.push({
159
+ severity: 'warning',
160
+ message: 'Description is very short (<10 chars)',
161
+ hint: 'A descriptive description helps Cursor decide when to apply this rule.',
162
+ });
163
+ }
164
+
165
+ // 5. Description too long
166
+ if (fm.data && fm.data.description && fm.data.description.length > 200) {
167
+ issues.push({
168
+ severity: 'warning',
169
+ message: 'Description is very long (>200 chars)',
170
+ hint: 'Keep descriptions concise. Put detailed instructions in the rule body, not the description.',
171
+ });
172
+ }
173
+
174
+ // 6. Glob pattern issues
175
+ if (fm.data && fm.data.globs) {
176
+ const globs = parseGlobs(fm.data.globs);
177
+ for (const glob of globs) {
178
+ // Overly broad glob
179
+ if (glob === '*' || glob === '**') {
180
+ issues.push({
181
+ severity: 'warning',
182
+ message: 'Overly broad glob pattern',
183
+ hint: 'This matches everything. Consider using more specific patterns or just alwaysApply: true.',
184
+ });
185
+ }
186
+ // Glob contains spaces
187
+ if (glob.includes(' ') && !glob.includes('"') && !glob.includes("'")) {
188
+ issues.push({
189
+ severity: 'warning',
190
+ message: 'Glob pattern contains spaces',
191
+ hint: 'Glob patterns with spaces may not match correctly.',
192
+ });
193
+ }
194
+ // Glob is *.
195
+ if (glob === '*.') {
196
+ issues.push({
197
+ severity: 'warning',
198
+ message: 'Glob pattern has no file extension after dot',
199
+ });
200
+ }
201
+ }
202
+ }
203
+
204
+ // 7. alwaysApply + globs info
205
+ if (fm.data && fm.data.alwaysApply === true && fm.data.globs) {
206
+ const globs = parseGlobs(fm.data.globs);
207
+ if (globs.length > 0) {
208
+ issues.push({
209
+ severity: 'info',
210
+ message: 'alwaysApply is true with globs set',
211
+ hint: 'When alwaysApply is true, globs serve as a hint to the model but don\'t filter. This is fine if intentional.',
212
+ });
213
+ }
214
+ }
215
+
216
+ // 8. Rule body is just a URL
217
+ const bodyTrimmed = body.trim();
218
+ const urlMatch = bodyTrimmed.match(/^https?:\/\//);
219
+ if (urlMatch) {
220
+ const lines = bodyTrimmed.split('\n').filter(line => line.trim().length > 0);
221
+ const nonUrlLines = lines.filter(line => !line.trim().match(/^https?:\/\//));
222
+ if (nonUrlLines.length < 2) {
223
+ issues.push({
224
+ severity: 'warning',
225
+ message: 'Rule body appears to be just a URL',
226
+ hint: 'Cursor cannot follow URLs. Put the actual instructions in the rule body.',
227
+ });
228
+ }
229
+ }
230
+
94
231
  return { file: filePath, issues };
95
232
  }
96
233
 
@@ -249,6 +386,54 @@ async function lintProject(dir) {
249
386
  });
250
387
  }
251
388
 
389
+ // 9. Excessive rules count & 10. Duplicate rule content
390
+ const rulesDirPath = path.join(dir, '.cursor', 'rules');
391
+ if (fs.existsSync(rulesDirPath) && fs.statSync(rulesDirPath).isDirectory()) {
392
+ const mdcFiles = fs.readdirSync(rulesDirPath).filter(f => f.endsWith('.mdc'));
393
+
394
+ if (mdcFiles.length > 20) {
395
+ results.push({
396
+ file: rulesDirPath,
397
+ issues: [{
398
+ severity: 'warning',
399
+ message: `Project has ${mdcFiles.length} rule files`,
400
+ hint: 'More rules means more tokens consumed per request. Consider consolidating related rules.',
401
+ }],
402
+ });
403
+ }
404
+
405
+ // 10. Duplicate rule content
406
+ if (mdcFiles.length > 1) {
407
+ const parsed = [];
408
+ for (const file of mdcFiles) {
409
+ const filePath = path.join(rulesDirPath, file);
410
+ const content = fs.readFileSync(filePath, 'utf-8');
411
+ const body = getBody(content);
412
+ parsed.push({ file, filePath, body });
413
+ }
414
+
415
+ // Compare each pair
416
+ for (let i = 0; i < parsed.length; i++) {
417
+ for (let j = i + 1; j < parsed.length; j++) {
418
+ const a = parsed[i];
419
+ const b = parsed[j];
420
+ const sim = similarity(a.body, b.body);
421
+
422
+ if (sim > 0.8) {
423
+ results.push({
424
+ file: rulesDirPath,
425
+ issues: [{
426
+ severity: 'warning',
427
+ message: `Possible duplicate rules: ${a.file} and ${b.file}`,
428
+ hint: 'These rules have very similar content. Consider merging them.',
429
+ }],
430
+ });
431
+ }
432
+ }
433
+ }
434
+ }
435
+ }
436
+
252
437
  return results;
253
438
  }
254
439