cursor-lint 0.4.1 ā 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.js +47 -2
- package/src/generate.js +156 -0
- package/src/index.js +141 -2
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -5,8 +5,9 @@ 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 } = require('./generate');
|
|
8
9
|
|
|
9
|
-
const VERSION = '0.
|
|
10
|
+
const VERSION = '0.6.0';
|
|
10
11
|
|
|
11
12
|
const RED = '\x1b[31m';
|
|
12
13
|
const YELLOW = '\x1b[33m';
|
|
@@ -30,6 +31,7 @@ ${YELLOW}Options:${RESET}
|
|
|
30
31
|
--verify Check if code follows rules with verify: blocks
|
|
31
32
|
--init Generate starter .mdc rules (auto-detects your stack)
|
|
32
33
|
--fix Auto-fix common issues (missing frontmatter, alwaysApply)
|
|
34
|
+
--generate Auto-detect stack & download matching .mdc rules from GitHub
|
|
33
35
|
|
|
34
36
|
${YELLOW}What it checks (default):${RESET}
|
|
35
37
|
⢠.cursorrules files (warns about agent mode compatibility)
|
|
@@ -61,6 +63,7 @@ ${YELLOW}Examples:${RESET}
|
|
|
61
63
|
npx cursor-lint # Lint rule files
|
|
62
64
|
npx cursor-lint --verify # Check code against rules
|
|
63
65
|
npx cursor-lint --init # Generate starter rules for your project
|
|
66
|
+
npx cursor-lint --generate # Download community rules for your stack
|
|
64
67
|
|
|
65
68
|
${YELLOW}More info:${RESET}
|
|
66
69
|
https://github.com/cursorrulespacks/cursor-lint
|
|
@@ -84,8 +87,50 @@ async function main() {
|
|
|
84
87
|
const isVerify = args.includes('--verify');
|
|
85
88
|
const isInit = args.includes('--init');
|
|
86
89
|
const isFix = args.includes('--fix');
|
|
90
|
+
const isGenerate = args.includes('--generate');
|
|
87
91
|
|
|
88
|
-
if (
|
|
92
|
+
if (isGenerate) {
|
|
93
|
+
console.log(`\nš cursor-lint v${VERSION} --generate\n`);
|
|
94
|
+
console.log(`Detecting stack in ${cwd}...\n`);
|
|
95
|
+
|
|
96
|
+
const results = await generateRules(cwd);
|
|
97
|
+
|
|
98
|
+
if (results.detected.length > 0) {
|
|
99
|
+
console.log(`${CYAN}Detected:${RESET} ${results.detected.join(', ')}\n`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`${YELLOW}No recognized stack detected.${RESET}`);
|
|
102
|
+
console.log(`${DIM}Supports: package.json, tsconfig.json, requirements.txt, Cargo.toml, go.mod, Dockerfile${RESET}\n`);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (results.created.length > 0) {
|
|
107
|
+
console.log(`${GREEN}Downloaded:${RESET}`);
|
|
108
|
+
for (const r of results.created) {
|
|
109
|
+
console.log(` ${GREEN}ā${RESET} .cursor/rules/${r.file} ${DIM}(${r.stack})${RESET}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (results.skipped.length > 0) {
|
|
114
|
+
console.log(`\n${YELLOW}Skipped (already exist):${RESET}`);
|
|
115
|
+
for (const r of results.skipped) {
|
|
116
|
+
console.log(` ${YELLOW}ā ${RESET} .cursor/rules/${r.file}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (results.failed.length > 0) {
|
|
121
|
+
console.log(`\n${RED}Failed:${RESET}`);
|
|
122
|
+
for (const r of results.failed) {
|
|
123
|
+
console.log(` ${RED}ā${RESET} ${r.file} ā ${r.error}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (results.created.length > 0) {
|
|
128
|
+
console.log(`\n${DIM}Run cursor-lint to check these rules${RESET}\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
process.exit(results.failed.length > 0 ? 1 : 0);
|
|
132
|
+
|
|
133
|
+
} else if (isFix) {
|
|
89
134
|
console.log(`\nš§ cursor-lint v${VERSION} --fix\n`);
|
|
90
135
|
console.log(`Scanning ${cwd} for fixable issues...\n`);
|
|
91
136
|
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
|
|
5
|
+
const BASE_URL = 'https://raw.githubusercontent.com/cursorrulespacks/cursorrules-collection/main/rules-mdc/';
|
|
6
|
+
|
|
7
|
+
const PKG_DEP_MAP = {
|
|
8
|
+
'react': 'frameworks/react.mdc',
|
|
9
|
+
'next': 'frameworks/nextjs.mdc',
|
|
10
|
+
'vue': 'frameworks/vue.mdc',
|
|
11
|
+
'svelte': 'frameworks/svelte.mdc',
|
|
12
|
+
'express': 'frameworks/express.mdc',
|
|
13
|
+
'@nestjs/core': 'frameworks/nestjs.mdc',
|
|
14
|
+
'prisma': 'tools/prisma.mdc',
|
|
15
|
+
'drizzle-orm': 'tools/drizzle.mdc',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const REQ_DEP_MAP = {
|
|
19
|
+
'django': 'frameworks/django.mdc',
|
|
20
|
+
'fastapi': 'frameworks/fastapi.mdc',
|
|
21
|
+
'flask': 'frameworks/flask.mdc',
|
|
22
|
+
'pydantic': 'tools/pydantic.mdc',
|
|
23
|
+
'sqlalchemy': 'tools/sqlalchemy.mdc',
|
|
24
|
+
'pytest': 'tools/pytest.mdc',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function fetchFile(url) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const get = (u) => {
|
|
30
|
+
https.get(u, (res) => {
|
|
31
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
32
|
+
get(res.headers.location);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (res.statusCode !== 200) {
|
|
36
|
+
reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
|
37
|
+
res.resume();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let data = '';
|
|
41
|
+
res.on('data', (c) => data += c);
|
|
42
|
+
res.on('end', () => resolve(data));
|
|
43
|
+
res.on('error', reject);
|
|
44
|
+
}).on('error', reject);
|
|
45
|
+
};
|
|
46
|
+
get(url);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectStack(cwd) {
|
|
51
|
+
const detected = [];
|
|
52
|
+
const rules = new Map(); // rulePath -> stackName
|
|
53
|
+
|
|
54
|
+
// package.json
|
|
55
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
56
|
+
if (fs.existsSync(pkgPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
59
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
60
|
+
for (const [dep, rule] of Object.entries(PKG_DEP_MAP)) {
|
|
61
|
+
if (allDeps[dep]) {
|
|
62
|
+
detected.push(dep);
|
|
63
|
+
rules.set(rule, dep);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// tsconfig.json
|
|
70
|
+
if (fs.existsSync(path.join(cwd, 'tsconfig.json'))) {
|
|
71
|
+
detected.push('TypeScript');
|
|
72
|
+
rules.set('languages/typescript.mdc', 'TypeScript');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Python
|
|
76
|
+
const reqPath = path.join(cwd, 'requirements.txt');
|
|
77
|
+
const hasPy = fs.existsSync(reqPath) || fs.readdirSync(cwd).some(f => f.endsWith('.py'));
|
|
78
|
+
if (hasPy) {
|
|
79
|
+
detected.push('Python');
|
|
80
|
+
rules.set('languages/python.mdc', 'Python');
|
|
81
|
+
}
|
|
82
|
+
if (fs.existsSync(reqPath)) {
|
|
83
|
+
try {
|
|
84
|
+
const req = fs.readFileSync(reqPath, 'utf8').toLowerCase();
|
|
85
|
+
for (const [dep, rule] of Object.entries(REQ_DEP_MAP)) {
|
|
86
|
+
if (req.includes(dep)) {
|
|
87
|
+
detected.push(dep);
|
|
88
|
+
rules.set(rule, dep);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cargo.toml
|
|
95
|
+
if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) {
|
|
96
|
+
detected.push('Rust');
|
|
97
|
+
rules.set('languages/rust.mdc', 'Rust');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// go.mod
|
|
101
|
+
if (fs.existsSync(path.join(cwd, 'go.mod'))) {
|
|
102
|
+
detected.push('Go');
|
|
103
|
+
rules.set('languages/go.mdc', 'Go');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Dockerfile
|
|
107
|
+
if (fs.existsSync(path.join(cwd, 'Dockerfile'))) {
|
|
108
|
+
detected.push('Docker');
|
|
109
|
+
rules.set('tools/docker.mdc', 'Docker');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// CI/CD
|
|
113
|
+
if (fs.existsSync(path.join(cwd, '.github', 'workflows'))) {
|
|
114
|
+
detected.push('CI/CD');
|
|
115
|
+
rules.set('tools/ci-cd.mdc', 'CI/CD');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { detected, rules };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function generateRules(cwd) {
|
|
122
|
+
const { detected, rules } = detectStack(cwd);
|
|
123
|
+
const rulesDir = path.join(cwd, '.cursor', 'rules');
|
|
124
|
+
const created = [];
|
|
125
|
+
const skipped = [];
|
|
126
|
+
const failed = [];
|
|
127
|
+
|
|
128
|
+
if (rules.size === 0) {
|
|
129
|
+
return { detected, created, skipped, failed };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
for (const [rulePath, stackName] of rules) {
|
|
135
|
+
const filename = path.basename(rulePath);
|
|
136
|
+
const destPath = path.join(rulesDir, filename);
|
|
137
|
+
|
|
138
|
+
if (fs.existsSync(destPath)) {
|
|
139
|
+
skipped.push({ file: filename, stack: stackName });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const url = BASE_URL + rulePath;
|
|
145
|
+
const content = await fetchFile(url);
|
|
146
|
+
fs.writeFileSync(destPath, content, 'utf8');
|
|
147
|
+
created.push({ file: filename, stack: stackName });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
failed.push({ file: filename, stack: stackName, error: err.message });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { detected, created, skipped, failed };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { generateRules };
|
package/src/index.js
CHANGED
|
@@ -76,7 +76,7 @@ async function lintMdcFile(filePath) {
|
|
|
76
76
|
if (!fm.data.description) {
|
|
77
77
|
issues.push({ severity: 'warning', message: 'Missing description in frontmatter', hint: 'Add a description so Cursor knows when to apply this rule' });
|
|
78
78
|
}
|
|
79
|
-
if (fm.data.globs && typeof fm.data.globs === 'string' && fm.data.globs.includes(',')) {
|
|
79
|
+
if (fm.data.globs && typeof fm.data.globs === 'string' && fm.data.globs.includes(',') && !fm.data.globs.trim().startsWith('[')) {
|
|
80
80
|
issues.push({ severity: 'error', message: 'Globs should be YAML array, not comma-separated string', hint: 'Use globs:\\n - "*.ts"\\n - "*.tsx"' });
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -141,7 +141,146 @@ async function lintProject(dir) {
|
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// Conflict detection across .mdc files
|
|
145
|
+
const conflicts = detectConflicts(dir);
|
|
146
|
+
if (conflicts.length > 0) {
|
|
147
|
+
results.push({
|
|
148
|
+
file: path.join(dir, '.cursor/rules/'),
|
|
149
|
+
issues: conflicts,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
return results;
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
|
|
156
|
+
function parseGlobs(globVal) {
|
|
157
|
+
if (!globVal) return [];
|
|
158
|
+
if (typeof globVal === 'string') {
|
|
159
|
+
// Handle both YAML array syntax and comma-separated
|
|
160
|
+
const trimmed = globVal.trim();
|
|
161
|
+
if (trimmed.startsWith('[')) {
|
|
162
|
+
// ["*.ts", "*.tsx"] format
|
|
163
|
+
return trimmed.slice(1, -1).split(',').map(g => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
return trimmed.split(',').map(g => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(globVal)) return globVal;
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function globsOverlap(globsA, globsB) {
|
|
172
|
+
// If either has no globs (alwaysApply), they overlap with everything
|
|
173
|
+
if (globsA.length === 0 || globsB.length === 0) return true;
|
|
174
|
+
|
|
175
|
+
for (const a of globsA) {
|
|
176
|
+
for (const b of globsB) {
|
|
177
|
+
// Exact match
|
|
178
|
+
if (a === b) return true;
|
|
179
|
+
// Both are wildcards covering same extension
|
|
180
|
+
const extA = a.match(/^\*\.(\w+)$/);
|
|
181
|
+
const extB = b.match(/^\*\.(\w+)$/);
|
|
182
|
+
if (extA && extB && extA[1] === extB[1]) return true;
|
|
183
|
+
// One is a superset pattern like **/*.ts
|
|
184
|
+
if (a.includes('**') || b.includes('**')) {
|
|
185
|
+
const extA2 = a.match(/\*\.(\w+)$/);
|
|
186
|
+
const extB2 = b.match(/\*\.(\w+)$/);
|
|
187
|
+
if (extA2 && extB2 && extA2[1] === extB2[1]) return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractDirectives(content) {
|
|
195
|
+
// Extract actionable instructions from rule body (after frontmatter)
|
|
196
|
+
const body = content.replace(/^---[\s\S]*?---\n?/, '').toLowerCase();
|
|
197
|
+
const directives = [];
|
|
198
|
+
|
|
199
|
+
// Look for contradictory patterns: "always use X" vs "never use X"
|
|
200
|
+
const alwaysMatch = body.match(/always\s+use\s+(\S+)/g) || [];
|
|
201
|
+
const neverMatch = body.match(/never\s+use\s+(\S+)/g) || [];
|
|
202
|
+
const preferMatch = body.match(/prefer\s+(\S+)/g) || [];
|
|
203
|
+
const avoidMatch = body.match(/avoid\s+(\S+)/g) || [];
|
|
204
|
+
const doNotMatch = body.match(/do\s+not\s+use\s+(\S+)/g) || [];
|
|
205
|
+
|
|
206
|
+
for (const m of alwaysMatch) directives.push({ type: 'require', subject: m.replace(/^always\s+use\s+/, '') });
|
|
207
|
+
for (const m of neverMatch) directives.push({ type: 'forbid', subject: m.replace(/^never\s+use\s+/, '') });
|
|
208
|
+
for (const m of preferMatch) directives.push({ type: 'prefer', subject: m.replace(/^prefer\s+/, '') });
|
|
209
|
+
for (const m of avoidMatch) directives.push({ type: 'avoid', subject: m.replace(/^avoid\s+/, '') });
|
|
210
|
+
for (const m of doNotMatch) directives.push({ type: 'forbid', subject: m.replace(/^do\s+not\s+use\s+/, '') });
|
|
211
|
+
|
|
212
|
+
return directives;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function detectConflicts(dir) {
|
|
216
|
+
const rulesDir = path.join(dir, '.cursor', 'rules');
|
|
217
|
+
if (!fs.existsSync(rulesDir) || !fs.statSync(rulesDir).isDirectory()) return [];
|
|
218
|
+
|
|
219
|
+
const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.mdc'));
|
|
220
|
+
if (files.length < 2) return [];
|
|
221
|
+
|
|
222
|
+
const parsed = [];
|
|
223
|
+
for (const file of files) {
|
|
224
|
+
const filePath = path.join(rulesDir, file);
|
|
225
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
226
|
+
const fm = parseFrontmatter(content);
|
|
227
|
+
const globs = fm.data ? parseGlobs(fm.data.globs) : [];
|
|
228
|
+
const alwaysApply = fm.data && fm.data.alwaysApply;
|
|
229
|
+
const directives = extractDirectives(content);
|
|
230
|
+
parsed.push({ file, filePath, globs, alwaysApply, directives, content });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const issues = [];
|
|
234
|
+
|
|
235
|
+
// Check for duplicate alwaysApply rules with overlapping globs
|
|
236
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
237
|
+
for (let j = i + 1; j < parsed.length; j++) {
|
|
238
|
+
const a = parsed[i];
|
|
239
|
+
const b = parsed[j];
|
|
240
|
+
|
|
241
|
+
// Check glob overlap
|
|
242
|
+
const aGlobs = a.alwaysApply && a.globs.length === 0 ? [] : a.globs;
|
|
243
|
+
const bGlobs = b.alwaysApply && b.globs.length === 0 ? [] : b.globs;
|
|
244
|
+
const overlap = globsOverlap(aGlobs, bGlobs);
|
|
245
|
+
|
|
246
|
+
if (!overlap) continue;
|
|
247
|
+
|
|
248
|
+
// Check for contradictory directives
|
|
249
|
+
for (const dA of a.directives) {
|
|
250
|
+
for (const dB of b.directives) {
|
|
251
|
+
if (dA.subject !== dB.subject) continue;
|
|
252
|
+
|
|
253
|
+
const contradicts =
|
|
254
|
+
(dA.type === 'require' && (dB.type === 'forbid' || dB.type === 'avoid')) ||
|
|
255
|
+
(dA.type === 'forbid' && (dB.type === 'require' || dB.type === 'prefer')) ||
|
|
256
|
+
(dA.type === 'prefer' && dB.type === 'forbid') ||
|
|
257
|
+
(dA.type === 'avoid' && dB.type === 'require');
|
|
258
|
+
|
|
259
|
+
if (contradicts) {
|
|
260
|
+
issues.push({
|
|
261
|
+
severity: 'error',
|
|
262
|
+
message: `Conflicting rules: ${a.file} says "${dA.type} ${dA.subject}" but ${b.file} says "${dB.type} ${dB.subject}"`,
|
|
263
|
+
hint: 'Conflicting directives confuse the model. Remove or reconcile one of these rules.',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check for duplicate glob coverage (both alwaysApply targeting same files)
|
|
270
|
+
if (a.alwaysApply && b.alwaysApply && a.globs.length > 0 && b.globs.length > 0) {
|
|
271
|
+
const sharedGlobs = a.globs.filter(g => b.globs.includes(g));
|
|
272
|
+
if (sharedGlobs.length > 0) {
|
|
273
|
+
issues.push({
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
message: `Overlapping globs: ${a.file} and ${b.file} both target ${sharedGlobs.join(', ')}`,
|
|
276
|
+
hint: 'Multiple rules targeting the same files may cause unpredictable behavior. Consider merging them.',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return issues;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { lintProject, lintMdcFile, lintCursorrules, detectConflicts };
|