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 +5 -1
- package/package.json +1 -1
- package/src/cli.js +103 -5
- package/src/generate.js +205 -7
- package/src/index.js +185 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/cursor-lint)
|
|
4
4
|
[](https://www.npmjs.com/package/cursor-lint)
|
|
5
5
|
|
|
6
|
-
Lint your [Cursor](https://cursor.com) rules.
|
|
6
|
+
Lint your [Cursor](https://cursor.com) rules. Find problems before they silently break your output.
|
|
7
7
|
|
|
8
8
|

|
|
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
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
|
+
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(', ')}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|