cursor-lint 0.5.0 → 0.7.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.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Lint your Cursor rules \u2014 catch common mistakes before they break your workflow",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ const { initProject } = require('./init');
7
7
  const { fixProject } = require('./fix');
8
8
  const { generateRules } = require('./generate');
9
9
 
10
- const VERSION = '0.5.0';
10
+ const VERSION = '0.7.0';
11
11
 
12
12
  const RED = '\x1b[31m';
13
13
  const YELLOW = '\x1b[33m';
@@ -32,6 +32,7 @@ ${YELLOW}Options:${RESET}
32
32
  --init Generate starter .mdc rules (auto-detects your stack)
33
33
  --fix Auto-fix common issues (missing frontmatter, alwaysApply)
34
34
  --generate Auto-detect stack & download matching .mdc rules from GitHub
35
+ --order Show rule load order, priority tiers, and token estimates
35
36
 
36
37
  ${YELLOW}What it checks (default):${RESET}
37
38
  • .cursorrules files (warns about agent mode compatibility)
@@ -88,8 +89,76 @@ async function main() {
88
89
  const isInit = args.includes('--init');
89
90
  const isFix = args.includes('--fix');
90
91
  const isGenerate = args.includes('--generate');
92
+ const isOrder = args.includes('--order');
91
93
 
92
- if (isGenerate) {
94
+ if (isOrder) {
95
+ const { showLoadOrder } = require('./order');
96
+ console.log(`\nšŸ“‹ cursor-lint v${VERSION} --order\n`);
97
+ const dir = args.find(a => !a.startsWith('-')) ? path.resolve(args.find(a => !a.startsWith('-'))) : cwd;
98
+ console.log(`Analyzing rule load order in ${dir}...\n`);
99
+
100
+ const results = showLoadOrder(dir);
101
+
102
+ if (results.rules.length === 0) {
103
+ console.log(`${YELLOW}No rules found.${RESET}\n`);
104
+ process.exit(0);
105
+ }
106
+
107
+ // Show .cursorrules warning if present
108
+ if (results.hasCursorrules) {
109
+ console.log(`${YELLOW}⚠ .cursorrules found${RESET} — overridden by any .mdc rule covering the same topic`);
110
+ console.log(`${DIM} .mdc files always take precedence when both exist${RESET}\n`);
111
+ }
112
+
113
+ // Group by priority tier
114
+ const tiers = {
115
+ 'always': { label: 'Always Active', color: GREEN, rules: [] },
116
+ 'glob': { label: 'File-Scoped (glob match)', color: CYAN, rules: [] },
117
+ 'manual': { label: 'Manual Only (no alwaysApply, no globs)', color: DIM, rules: [] },
118
+ };
119
+
120
+ for (const rule of results.rules) {
121
+ tiers[rule.tier].rules.push(rule);
122
+ }
123
+
124
+ let position = 1;
125
+ for (const [key, tier] of Object.entries(tiers)) {
126
+ if (tier.rules.length === 0) continue;
127
+ console.log(`${tier.color}── ${tier.label} ──${RESET}`);
128
+ for (const rule of tier.rules) {
129
+ const globs = rule.globs.length > 0 ? ` ${DIM}[${rule.globs.join(', ')}]${RESET}` : '';
130
+ const desc = rule.description ? ` ${DIM}— ${rule.description}${RESET}` : '';
131
+ const size = ` ${DIM}(${rule.lines} lines, ~${rule.tokens} tokens)${RESET}`;
132
+ console.log(` ${position}. ${rule.file}${globs}${desc}${size}`);
133
+ position++;
134
+ }
135
+ console.log();
136
+ }
137
+
138
+ // Token budget warning
139
+ const totalTokens = results.rules.reduce((s, r) => s + r.tokens, 0);
140
+ const alwaysTokens = tiers.always.rules.reduce((s, r) => s + r.tokens, 0);
141
+ console.log('─'.repeat(50));
142
+ console.log(`${CYAN}Total rules:${RESET} ${results.rules.length}`);
143
+ console.log(`${CYAN}Always-active token estimate:${RESET} ~${alwaysTokens} tokens`);
144
+ console.log(`${CYAN}All rules token estimate:${RESET} ~${totalTokens} tokens`);
145
+
146
+ if (alwaysTokens > 4000) {
147
+ console.log(`\n${YELLOW}⚠ Your always-active rules use ~${alwaysTokens} tokens.${RESET}`);
148
+ console.log(`${DIM} Large rule sets eat into your context window. Consider moving some to glob-scoped rules.${RESET}`);
149
+ }
150
+
151
+ if (results.warnings.length > 0) {
152
+ console.log();
153
+ for (const w of results.warnings) {
154
+ console.log(`${YELLOW}⚠ ${w}${RESET}`);
155
+ }
156
+ }
157
+
158
+ console.log();
159
+ process.exit(0);
160
+
161
+ } else if (isGenerate) {
93
162
  console.log(`\nšŸš€ cursor-lint v${VERSION} --generate\n`);
94
163
  console.log(`Detecting stack in ${cwd}...\n`);
95
164
 
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
- module.exports = { lintProject, lintMdcFile, lintCursorrules };
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 };
package/src/order.js ADDED
@@ -0,0 +1,131 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function parseFrontmatter(content) {
5
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
6
+ if (!match) return { found: false, data: null };
7
+
8
+ const data = {};
9
+ const lines = match[1].split('\n');
10
+ for (const line of lines) {
11
+ const colonIdx = line.indexOf(':');
12
+ if (colonIdx === -1) continue;
13
+ const key = line.slice(0, colonIdx).trim();
14
+ const rawVal = line.slice(colonIdx + 1).trim();
15
+ if (rawVal === 'true') data[key] = true;
16
+ else if (rawVal === 'false') data[key] = false;
17
+ else if (rawVal.startsWith('"') && rawVal.endsWith('"')) data[key] = rawVal.slice(1, -1);
18
+ else data[key] = rawVal;
19
+ }
20
+ return { found: true, data };
21
+ }
22
+
23
+ function parseGlobs(globVal) {
24
+ if (!globVal) return [];
25
+ if (typeof globVal === 'string') {
26
+ const trimmed = globVal.trim();
27
+ if (trimmed.startsWith('[')) {
28
+ return trimmed.slice(1, -1).split(',').map(g => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
29
+ }
30
+ return trimmed.split(',').map(g => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
31
+ }
32
+ return [];
33
+ }
34
+
35
+ function estimateTokens(text) {
36
+ // Rough estimate: ~4 chars per token for English text
37
+ return Math.ceil(text.length / 4);
38
+ }
39
+
40
+ function showLoadOrder(dir) {
41
+ const results = {
42
+ hasCursorrules: false,
43
+ rules: [],
44
+ warnings: [],
45
+ };
46
+
47
+ // Check for .cursorrules
48
+ const cursorrules = path.join(dir, '.cursorrules');
49
+ if (fs.existsSync(cursorrules)) {
50
+ results.hasCursorrules = true;
51
+ const content = fs.readFileSync(cursorrules, 'utf-8');
52
+ const lines = content.split('\n').length;
53
+ results.rules.push({
54
+ file: '.cursorrules',
55
+ tier: 'always',
56
+ globs: [],
57
+ description: '(legacy format)',
58
+ alwaysApply: true,
59
+ lines,
60
+ tokens: estimateTokens(content),
61
+ priority: 0, // lowest priority — overridden by .mdc
62
+ });
63
+ }
64
+
65
+ // Check .cursor/rules/*.mdc
66
+ const rulesDir = path.join(dir, '.cursor', 'rules');
67
+ if (fs.existsSync(rulesDir) && fs.statSync(rulesDir).isDirectory()) {
68
+ const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.mdc')).sort();
69
+
70
+ for (const file of files) {
71
+ const filePath = path.join(rulesDir, file);
72
+ const content = fs.readFileSync(filePath, 'utf-8');
73
+ const fm = parseFrontmatter(content);
74
+ const lines = content.split('\n').length;
75
+ const tokens = estimateTokens(content);
76
+
77
+ if (!fm.found || !fm.data) {
78
+ results.rules.push({
79
+ file,
80
+ tier: 'manual',
81
+ globs: [],
82
+ description: '(no frontmatter)',
83
+ alwaysApply: false,
84
+ lines,
85
+ tokens,
86
+ });
87
+ results.warnings.push(`${file}: Missing frontmatter — rule may not load at all`);
88
+ continue;
89
+ }
90
+
91
+ const globs = parseGlobs(fm.data.globs);
92
+ const alwaysApply = fm.data.alwaysApply === true;
93
+ const description = fm.data.description || '';
94
+
95
+ let tier;
96
+ if (alwaysApply) {
97
+ tier = 'always';
98
+ } else if (globs.length > 0) {
99
+ tier = 'glob';
100
+ } else {
101
+ tier = 'manual';
102
+ results.warnings.push(`${file}: No alwaysApply and no globs — this rule may never activate in agent mode`);
103
+ }
104
+
105
+ results.rules.push({
106
+ file,
107
+ tier,
108
+ globs,
109
+ description,
110
+ alwaysApply,
111
+ lines,
112
+ tokens,
113
+ });
114
+ }
115
+ }
116
+
117
+ // Sort within tiers: always first, then glob, then manual
118
+ // Within each tier, sort by filename (alphabetical = filesystem order)
119
+ const tierOrder = { always: 0, glob: 1, manual: 2 };
120
+ results.rules.sort((a, b) => {
121
+ if (tierOrder[a.tier] !== tierOrder[b.tier]) return tierOrder[a.tier] - tierOrder[b.tier];
122
+ // .cursorrules always last within 'always' tier (lowest priority)
123
+ if (a.file === '.cursorrules') return -1;
124
+ if (b.file === '.cursorrules') return 1;
125
+ return a.file.localeCompare(b.file);
126
+ });
127
+
128
+ return results;
129
+ }
130
+
131
+ module.exports = { showLoadOrder };