Skip to content

Commit 527de28

Browse files
author
shijiashuai
committed
feat: add validator for .mdc rule files
Validate rule frontmatter and heading structure so contributions stay consistent without adding a heavier tooling stack.
1 parent 9d72d84 commit 527de28

3 files changed

Lines changed: 322 additions & 4 deletions

File tree

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![GitHub stars](https://img.shields.io/github/stars/LessUp/cursor-rules?style=flat-square&logo=github)](https://github.com/LessUp/cursor-rules/stargazers)
88
[![GitHub forks](https://img.shields.io/github/forks/LessUp/cursor-rules?style=flat-square&logo=github)](https://github.com/LessUp/cursor-rules/network/members)
99
[![License](https://img.shields.io/github/license/LessUp/cursor-rules?style=flat-square)](LICENSE)
10-
[![Rules](https://img.shields.io/badge/rules-28-blue?style=flat-square)](#-支持的技术栈)
10+
[![Rules](https://img.shields.io/badge/rules-26-blue?style=flat-square)](#-支持的技术栈)
1111

1212
帮助开发者和团队统一代码风格、提升代码质量,从而更高效地进行协作开发。
1313

@@ -23,6 +23,7 @@
2323
- [支持的技术栈](#-支持的技术栈)
2424
- [快速开始](#-快速开始)
2525
- [规则文件说明](#-规则文件说明)
26+
- [验证规则文件](#-验证规则文件)
2627
- [贡献指南](#-贡献指南)
2728
- [许可证](#-许可证)
2829
- [致谢](#-致谢)
@@ -144,9 +145,42 @@ globs: **/*.py, src/**/*.py # 适用的文件类型
144145

145146
- **`globs` 不为空** → 规则仅在匹配的文件类型上生效(如 `**/*.py` 只对 Python 文件生效)。
146147
- **`globs` 为空** → 规则作为通用规范全局生效。
148+
- 当前 validator 接受两种 `globs` 写法:
149+
- 不带整体引号的逗号分隔字符串:`**/*.py, src/**/*.py`
150+
- 带整体引号的逗号分隔字符串:`"**/*.c,**/*.cpp,Makefile"`
151+
152+
## ✅ 验证规则文件
153+
154+
仓库内置了一个零依赖的 validator,用于检查根目录下 `.mdc` 规则文件的基本结构是否正确。
155+
156+
```bash
157+
node scripts/validate-rules.mjs
158+
```
159+
160+
也可以只校验指定文件:
161+
162+
```bash
163+
node scripts/validate-rules.mjs python.mdc medusa.mdc
164+
```
165+
166+
validator 默认会检查:
167+
- 文件是否以 `---` frontmatter 开始并正确结束
168+
- 是否包含 `description``globs`
169+
- `description` 是否为空
170+
- 非空 `globs` 是否能解析为有效的逗号分隔条目
171+
- frontmatter 后是否存在正文
172+
- 正文中是否至少包含一个 H1 标题
173+
174+
validator 还会给出非阻塞 warning,例如:
175+
- 首个 H1 之前出现正文内容
176+
- 同一文件中存在多个 H1
177+
- 出现未知 frontmatter 字段
147178

148179
## 🤝 贡献指南
149180

181+
在提交 Pull Request 前,建议先运行一次 validator,确保新增或修改的规则文件符合仓库约定。
182+
183+
150184
我们非常欢迎社区的贡献!
151185

152186
### 参与方式

medusa.mdc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ description: Medusa 规则和最佳实践。在使用 Medusa 构建应用程序
33
globs: **/*.tsx, **/*.ts, src/**/*.ts, src/**/*.tsx, src/**/*.js, src/**/*.jsx
44
---
55

6-
您是一位专注于现代 Web 开发的资深高级软件工程师,在 TypeScript、Medusa、React.js 和 TailwindCSS 方面拥有深厚的专业知识。
7-
86
# Medusa 规则
97

8+
您是一位专注于现代 Web 开发的资深高级软件工程师,在 TypeScript、Medusa、React.js 和 TailwindCSS 方面拥有深厚的专业知识。
9+
1010
## 通用规则
1111

1212
- 导入文件时不要使用类型别名。
@@ -43,6 +43,6 @@ globs: **/*.tsx, **/*.ts, src/**/*.ts, src/**/*.tsx, src/**/*.js, src/**/*.jsx
4343
- 在管理后台定制中发送请求时,总是使用 Medusa 的 JS SDK。
4444
- 使用 TailwindCSS 进行样式设计。
4545

46-
# 额外资源
46+
## 额外资源
4747

4848
- [Medusa 文档](https://docs.medusajs.com/llms-full.txt)

scripts/validate-rules.mjs

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import process from 'node:process';
6+
7+
const ROOT_DIR = process.cwd();
8+
const ALLOWED_KEYS = new Set(['description', 'globs']);
9+
10+
function toPosixRelative(filePath) {
11+
return path.relative(ROOT_DIR, filePath).split(path.sep).join('/');
12+
}
13+
14+
function normalizeNewlines(text) {
15+
return text.replace(/\r\n/g, '\n');
16+
}
17+
18+
function createFinding(severity, filePath, line, code, message) {
19+
return {
20+
severity,
21+
filePath,
22+
line,
23+
code,
24+
message,
25+
};
26+
}
27+
28+
function isStandaloneDelimiter(line) {
29+
return line.trim() === '---';
30+
}
31+
32+
function unwrapMatchingQuotes(value) {
33+
if (value.length >= 2) {
34+
const first = value[0];
35+
const last = value[value.length - 1];
36+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
37+
return value.slice(1, -1);
38+
}
39+
}
40+
41+
return value;
42+
}
43+
44+
function parseFrontmatter(content, filePath) {
45+
const findings = [];
46+
const lines = content.split('\n');
47+
48+
if (!lines.length || !isStandaloneDelimiter(lines[0])) {
49+
findings.push(createFinding('ERROR', filePath, 1, 'E001', 'Missing opening frontmatter delimiter'));
50+
return { findings, frontmatter: null, body: '', bodyStartLine: 1 };
51+
}
52+
53+
let closingIndex = -1;
54+
for (let index = 1; index < lines.length; index += 1) {
55+
if (isStandaloneDelimiter(lines[index])) {
56+
closingIndex = index;
57+
break;
58+
}
59+
}
60+
61+
if (closingIndex === -1) {
62+
findings.push(createFinding('ERROR', filePath, 1, 'E002', 'Missing closing frontmatter delimiter'));
63+
return { findings, frontmatter: null, body: '', bodyStartLine: 1 };
64+
}
65+
66+
const frontmatter = new Map();
67+
68+
for (let index = 1; index < closingIndex; index += 1) {
69+
const line = lines[index];
70+
if (!line.trim()) {
71+
continue;
72+
}
73+
74+
const separatorIndex = line.indexOf(':');
75+
if (separatorIndex === -1) {
76+
findings.push(createFinding('ERROR', filePath, index + 1, 'E003', 'Invalid frontmatter line; expected key: value'));
77+
continue;
78+
}
79+
80+
const key = line.slice(0, separatorIndex).trim();
81+
const value = line.slice(separatorIndex + 1).trim();
82+
83+
if (!key) {
84+
findings.push(createFinding('ERROR', filePath, index + 1, 'E003', 'Invalid frontmatter line; key must not be empty'));
85+
continue;
86+
}
87+
88+
if (frontmatter.has(key)) {
89+
findings.push(createFinding('ERROR', filePath, index + 1, 'E003', `Duplicate frontmatter key: ${key}`));
90+
continue;
91+
}
92+
93+
frontmatter.set(key, value);
94+
95+
if (!ALLOWED_KEYS.has(key)) {
96+
findings.push(createFinding('WARN', filePath, index + 1, 'W003', `Unknown frontmatter key: ${key}`));
97+
}
98+
}
99+
100+
const bodyLines = lines.slice(closingIndex + 1);
101+
return {
102+
findings,
103+
frontmatter,
104+
body: bodyLines.join('\n'),
105+
bodyStartLine: closingIndex + 2,
106+
};
107+
}
108+
109+
function validateDescription(frontmatter, filePath) {
110+
const findings = [];
111+
112+
if (!frontmatter.has('description')) {
113+
findings.push(createFinding('ERROR', filePath, 1, 'E004', 'Missing required frontmatter key: description'));
114+
return findings;
115+
}
116+
117+
const description = unwrapMatchingQuotes(frontmatter.get('description') ?? '').trim();
118+
if (!description) {
119+
findings.push(createFinding('ERROR', filePath, 2, 'E005', 'description must not be empty'));
120+
}
121+
122+
return findings;
123+
}
124+
125+
function validateGlobs(frontmatter, filePath) {
126+
const findings = [];
127+
128+
if (!frontmatter.has('globs')) {
129+
findings.push(createFinding('ERROR', filePath, 1, 'E006', 'Missing required frontmatter key: globs'));
130+
return findings;
131+
}
132+
133+
const rawGlobs = frontmatter.get('globs') ?? '';
134+
const trimmed = rawGlobs.trim();
135+
if (!trimmed) {
136+
return findings;
137+
}
138+
139+
const normalized = unwrapMatchingQuotes(trimmed);
140+
const entries = normalized.split(',').map((entry) => entry.trim());
141+
142+
if (!entries.length || entries.some((entry) => !entry)) {
143+
findings.push(createFinding('ERROR', filePath, 3, 'E007', 'globs must contain non-empty comma-separated entries'));
144+
}
145+
146+
return findings;
147+
}
148+
149+
function validateBody(body, filePath, bodyStartLine) {
150+
const findings = [];
151+
const bodyLines = body.split('\n');
152+
153+
const firstNonEmptyIndex = bodyLines.findIndex((line) => line.trim());
154+
if (firstNonEmptyIndex === -1) {
155+
findings.push(createFinding('ERROR', filePath, bodyStartLine, 'E008', 'Rule body must not be empty'));
156+
return findings;
157+
}
158+
159+
const h1Indexes = [];
160+
for (let index = 0; index < bodyLines.length; index += 1) {
161+
if (/^#\s+\S/.test(bodyLines[index])) {
162+
h1Indexes.push(index);
163+
}
164+
}
165+
166+
if (!h1Indexes.length) {
167+
findings.push(createFinding('ERROR', filePath, bodyStartLine + firstNonEmptyIndex, 'E009', 'Rule body must contain at least one H1 heading'));
168+
return findings;
169+
}
170+
171+
const firstH1Index = h1Indexes[0];
172+
const hasContentBeforeFirstH1 = bodyLines
173+
.slice(0, firstH1Index)
174+
.some((line) => line.trim());
175+
176+
if (hasContentBeforeFirstH1) {
177+
findings.push(createFinding('WARN', filePath, bodyStartLine + firstNonEmptyIndex, 'W001', 'Content appears before the first H1 heading'));
178+
}
179+
180+
if (h1Indexes.length > 1) {
181+
findings.push(createFinding('WARN', filePath, bodyStartLine + h1Indexes[1], 'W002', `Multiple H1 headings found (${h1Indexes.length})`));
182+
}
183+
184+
return findings;
185+
}
186+
187+
function validateFile(filePath) {
188+
const content = normalizeNewlines(fs.readFileSync(filePath, 'utf8'));
189+
const findings = [];
190+
191+
const parsed = parseFrontmatter(content, filePath);
192+
findings.push(...parsed.findings);
193+
194+
if (!parsed.frontmatter) {
195+
return findings;
196+
}
197+
198+
findings.push(...validateDescription(parsed.frontmatter, filePath));
199+
findings.push(...validateGlobs(parsed.frontmatter, filePath));
200+
findings.push(...validateBody(parsed.body, filePath, parsed.bodyStartLine));
201+
202+
return findings;
203+
}
204+
205+
function compareFindings(left, right) {
206+
if (left.filePath !== right.filePath) {
207+
return left.filePath.localeCompare(right.filePath);
208+
}
209+
210+
if (left.line !== right.line) {
211+
return left.line - right.line;
212+
}
213+
214+
if (left.severity !== right.severity) {
215+
return left.severity.localeCompare(right.severity);
216+
}
217+
218+
return left.code.localeCompare(right.code);
219+
}
220+
221+
function printFindings(findings) {
222+
const sorted = [...findings].sort(compareFindings);
223+
224+
for (const finding of sorted) {
225+
const relativePath = toPosixRelative(finding.filePath);
226+
console.log(`${finding.severity} ${relativePath}:${finding.line} ${finding.code} ${finding.message}`);
227+
}
228+
}
229+
230+
function getDefaultTargets(rootDir) {
231+
return fs
232+
.readdirSync(rootDir, { withFileTypes: true })
233+
.filter((entry) => entry.isFile() && entry.name.endsWith('.mdc'))
234+
.map((entry) => path.join(rootDir, entry.name))
235+
.sort((left, right) => left.localeCompare(right));
236+
}
237+
238+
function resolveTargets(args, rootDir) {
239+
if (!args.length) {
240+
return getDefaultTargets(rootDir);
241+
}
242+
243+
return args.map((inputPath) => {
244+
const resolvedPath = path.resolve(rootDir, inputPath);
245+
246+
if (!fs.existsSync(resolvedPath)) {
247+
throw new Error(`Path does not exist: ${inputPath}`);
248+
}
249+
250+
const stats = fs.statSync(resolvedPath);
251+
if (!stats.isFile()) {
252+
throw new Error(`Path is not a file: ${inputPath}`);
253+
}
254+
255+
if (!resolvedPath.endsWith('.mdc')) {
256+
throw new Error(`Path is not an .mdc file: ${inputPath}`);
257+
}
258+
259+
return resolvedPath;
260+
});
261+
}
262+
263+
function main() {
264+
try {
265+
const args = process.argv.slice(2);
266+
const targets = resolveTargets(args, ROOT_DIR);
267+
const findings = targets.flatMap((filePath) => validateFile(filePath));
268+
269+
printFindings(findings);
270+
271+
const errorCount = findings.filter((finding) => finding.severity === 'ERROR').length;
272+
const warningCount = findings.filter((finding) => finding.severity === 'WARN').length;
273+
274+
console.log(`Checked ${targets.length} file${targets.length === 1 ? '' : 's'}: ${errorCount} error${errorCount === 1 ? '' : 's'}, ${warningCount} warning${warningCount === 1 ? '' : 's'}`);
275+
276+
process.exitCode = errorCount > 0 ? 1 : 0;
277+
} catch (error) {
278+
const message = error instanceof Error ? error.message : String(error);
279+
console.error(`ERROR ${message}`);
280+
process.exitCode = 2;
281+
}
282+
}
283+
284+
main();

0 commit comments

Comments
 (0)