cursor-lint 0.13.0 → 0.15.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/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 };