prd-workflow-cli 1.3.4 → 1.4.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/.agent/workflows/prd-b1-planning-draft.md +45 -0
- package/.agent/workflows/prd-b2-planning-breakdown.md +53 -0
- package/.agent/workflows/prd-c1-requirement-list.md +63 -0
- package/.agent/workflows/prd-p0-project-info.md +45 -0
- package/.agent/workflows/prd-r1-review.md +45 -0
- package/.agent/workflows/prd-r2-review.md +43 -0
- package/.antigravity/rules.md +14 -0
- package/.cursorrules +26 -0
- package/bin/prd-cli.js +21 -0
- package/commands/check.js +509 -0
- package/commands/stats.js +192 -0
- package/docs/RULE-SYSTEM-ROADMAP.md +286 -0
- package/package.json +3 -1
- package/rules/index.json +614 -0
- package/rules/schemas/a2ui.schema.json +545 -0
- package/rules/schemas/rules.schema.json +221 -0
- package/scripts/inject-rules.js +167 -0
- package/templates/a2ui-standalone.html +3 -3
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prd check - 规则校验命令
|
|
3
|
+
*
|
|
4
|
+
* 用于检查当前项目是否符合 PRD 规则
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* prd check # 运行所有校验
|
|
8
|
+
* prd check --json # 输出 JSON 格式(供 AI 读取)
|
|
9
|
+
* prd check --category D # 只运行文档状态类规则
|
|
10
|
+
* prd check --rule D001 # 只运行指定规则
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const chalk = require('chalk');
|
|
16
|
+
|
|
17
|
+
// 加载规则索引
|
|
18
|
+
function loadRules() {
|
|
19
|
+
const rulesPath = path.join(__dirname, '../rules/index.json');
|
|
20
|
+
if (!fs.existsSync(rulesPath)) {
|
|
21
|
+
throw new Error('规则索引文件不存在: rules/index.json');
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(fs.readFileSync(rulesPath, 'utf-8'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 加载项目配置
|
|
27
|
+
function loadProjectConfig() {
|
|
28
|
+
const configPath = path.join(process.cwd(), '.prd-config.json');
|
|
29
|
+
if (!fs.existsSync(configPath)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 校验结果类
|
|
36
|
+
class CheckResult {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.passed = true;
|
|
39
|
+
this.violations = [];
|
|
40
|
+
this.warnings = [];
|
|
41
|
+
this.skipped = [];
|
|
42
|
+
this.checkedRules = [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addViolation(ruleId, message, location = null, severity = 'CRITICAL') {
|
|
46
|
+
this.passed = false;
|
|
47
|
+
this.violations.push({ rule_id: ruleId, message, location, severity });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
addWarning(ruleId, message, location = null) {
|
|
51
|
+
this.warnings.push({ rule_id: ruleId, message, location });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
addSkipped(ruleId, reason) {
|
|
55
|
+
this.skipped.push({ rule_id: ruleId, reason });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
markChecked(ruleId) {
|
|
59
|
+
this.checkedRules.push(ruleId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
toJSON() {
|
|
63
|
+
return {
|
|
64
|
+
passed: this.passed,
|
|
65
|
+
summary: {
|
|
66
|
+
total: this.checkedRules.length,
|
|
67
|
+
violations: this.violations.length,
|
|
68
|
+
warnings: this.warnings.length,
|
|
69
|
+
skipped: this.skipped.length
|
|
70
|
+
},
|
|
71
|
+
violations: this.violations,
|
|
72
|
+
warnings: this.warnings,
|
|
73
|
+
skipped: this.skipped
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
print() {
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(chalk.bold('📋 PRD 规则校验报告'));
|
|
80
|
+
console.log('─'.repeat(50));
|
|
81
|
+
|
|
82
|
+
if (this.passed && this.violations.length === 0) {
|
|
83
|
+
console.log(chalk.green('✅ 所有规则校验通过!'));
|
|
84
|
+
} else {
|
|
85
|
+
console.log(chalk.red(`❌ 发现 ${this.violations.length} 个违规`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (this.warnings.length > 0) {
|
|
89
|
+
console.log(chalk.yellow(`⚠️ ${this.warnings.length} 个警告`));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(chalk.gray(`📊 已检查 ${this.checkedRules.length} 条规则`));
|
|
93
|
+
console.log('');
|
|
94
|
+
|
|
95
|
+
// 输出违规详情
|
|
96
|
+
if (this.violations.length > 0) {
|
|
97
|
+
console.log(chalk.red.bold('违规列表:'));
|
|
98
|
+
this.violations.forEach((v, i) => {
|
|
99
|
+
console.log(` ${i + 1}. [${v.rule_id}] ${v.message}`);
|
|
100
|
+
if (v.location) {
|
|
101
|
+
console.log(chalk.gray(` 位置: ${v.location}`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
console.log('');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 输出警告
|
|
108
|
+
if (this.warnings.length > 0) {
|
|
109
|
+
console.log(chalk.yellow.bold('警告列表:'));
|
|
110
|
+
this.warnings.forEach((w, i) => {
|
|
111
|
+
console.log(` ${i + 1}. [${w.rule_id}] ${w.message}`);
|
|
112
|
+
});
|
|
113
|
+
console.log('');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 保存校验日志到 .prd-logs/check-history.json
|
|
119
|
+
*/
|
|
120
|
+
saveLog() {
|
|
121
|
+
const logsDir = path.join(process.cwd(), '.prd-logs');
|
|
122
|
+
const logPath = path.join(logsDir, 'check-history.json');
|
|
123
|
+
|
|
124
|
+
// 确保日志目录存在
|
|
125
|
+
if (!fs.existsSync(logsDir)) {
|
|
126
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 读取现有日志
|
|
130
|
+
let history = [];
|
|
131
|
+
if (fs.existsSync(logPath)) {
|
|
132
|
+
try {
|
|
133
|
+
history = JSON.parse(fs.readFileSync(logPath, 'utf-8'));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
history = [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 添加新记录
|
|
140
|
+
const logEntry = {
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
passed: this.passed,
|
|
143
|
+
summary: {
|
|
144
|
+
total: this.checkedRules.length,
|
|
145
|
+
violations: this.violations.length,
|
|
146
|
+
warnings: this.warnings.length,
|
|
147
|
+
skipped: this.skipped.length
|
|
148
|
+
},
|
|
149
|
+
violations_by_rule: this.getViolationsByRule(),
|
|
150
|
+
warnings_by_rule: this.getWarningsByRule()
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
history.push(logEntry);
|
|
154
|
+
|
|
155
|
+
// 只保留最近 100 条记录
|
|
156
|
+
if (history.length > 100) {
|
|
157
|
+
history = history.slice(-100);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 保存日志
|
|
161
|
+
fs.writeFileSync(logPath, JSON.stringify(history, null, 2));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 按规则 ID 统计违规
|
|
166
|
+
*/
|
|
167
|
+
getViolationsByRule() {
|
|
168
|
+
const counts = {};
|
|
169
|
+
this.violations.forEach(v => {
|
|
170
|
+
counts[v.rule_id] = (counts[v.rule_id] || 0) + 1;
|
|
171
|
+
});
|
|
172
|
+
return counts;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 按规则 ID 统计警告
|
|
177
|
+
*/
|
|
178
|
+
getWarningsByRule() {
|
|
179
|
+
const counts = {};
|
|
180
|
+
this.warnings.forEach(w => {
|
|
181
|
+
counts[w.rule_id] = (counts[w.rule_id] || 0) + 1;
|
|
182
|
+
});
|
|
183
|
+
return counts;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============ 校验器实现 ============
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 校验器:冻结状态检查 (D001-D004)
|
|
191
|
+
*/
|
|
192
|
+
function checkFrozenStatus(config, result) {
|
|
193
|
+
if (!config) {
|
|
194
|
+
result.addSkipped('D001', '未找到项目配置文件');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const planningFrozen = config.planning?.frozen === true;
|
|
199
|
+
const versionFrozen = config.version?.frozen === true;
|
|
200
|
+
|
|
201
|
+
// D001: B3 冻结状态
|
|
202
|
+
result.markChecked('D001');
|
|
203
|
+
if (planningFrozen) {
|
|
204
|
+
// 检查 B1/B2 文件是否在冻结后被修改(这里只记录状态)
|
|
205
|
+
result.addWarning('D001', 'B3 已冻结,请勿修改规划文档 (B1/B2/B3)');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// D002: C3 冻结状态
|
|
209
|
+
result.markChecked('D002');
|
|
210
|
+
if (versionFrozen) {
|
|
211
|
+
result.addWarning('D002', 'C3 已冻结,请勿修改版本文档 (C0/C1/C3)');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// D003 & D004: 组合检查
|
|
215
|
+
result.markChecked('D003');
|
|
216
|
+
result.markChecked('D004');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 校验器:流程顺序检查 (F001-F003)
|
|
221
|
+
*/
|
|
222
|
+
function checkFlowOrder(config, result) {
|
|
223
|
+
if (!config) {
|
|
224
|
+
result.addSkipped('F001', '未找到项目配置文件');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const currentIteration = config.currentIteration;
|
|
229
|
+
if (!currentIteration) {
|
|
230
|
+
result.addSkipped('F001', '当前没有活跃迭代');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const iterationDir = path.join(process.cwd(), '02_迭代记录', `第${String(currentIteration).padStart(2, '0')}轮迭代`);
|
|
235
|
+
|
|
236
|
+
// F001: B3 冻结前必须有 R1
|
|
237
|
+
result.markChecked('F001');
|
|
238
|
+
if (config.planning?.frozen) {
|
|
239
|
+
const r1Path = path.join(iterationDir, 'R1_规划审视报告.md');
|
|
240
|
+
if (!fs.existsSync(r1Path)) {
|
|
241
|
+
result.addViolation('F001', 'B3 已冻结但缺少 R1 审视报告', r1Path);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// F002: C3 冻结前必须有 R2
|
|
246
|
+
result.markChecked('F002');
|
|
247
|
+
if (config.version?.frozen) {
|
|
248
|
+
const r2Path = path.join(iterationDir, 'R2_版本审视报告.md');
|
|
249
|
+
if (!fs.existsSync(r2Path)) {
|
|
250
|
+
result.addViolation('F002', 'C3 已冻结但缺少 R2 审视报告', r2Path);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// F003: 创建 B1 前必须有 R1 启动检查
|
|
255
|
+
result.markChecked('F003');
|
|
256
|
+
const b1Path = path.join(iterationDir, 'B1_规划草案.md');
|
|
257
|
+
if (fs.existsSync(b1Path)) {
|
|
258
|
+
// 检查是否有 R1 启动检查记录(简化:检查目录下是否有相关文件或 config 标记)
|
|
259
|
+
const r1StartCheck = config.r1StartCheckPassed === true;
|
|
260
|
+
if (!r1StartCheck) {
|
|
261
|
+
result.addWarning('F003', '建议:创建 B1 前应完成 R1 启动检查');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 校验器:A2UI 文件检查 (V003-V006)
|
|
268
|
+
*/
|
|
269
|
+
function checkA2UIFiles(config, result) {
|
|
270
|
+
const currentIteration = config?.currentIteration;
|
|
271
|
+
if (!currentIteration) {
|
|
272
|
+
result.addSkipped('V004', '当前没有活跃迭代');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const uiDir = path.join(
|
|
277
|
+
process.cwd(),
|
|
278
|
+
'02_迭代记录',
|
|
279
|
+
`第${String(currentIteration).padStart(2, '0')}轮迭代`,
|
|
280
|
+
'C1_UI原型'
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (!fs.existsSync(uiDir)) {
|
|
284
|
+
result.addSkipped('V004', 'C1_UI原型 目录不存在');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// V003: current.json 检查
|
|
289
|
+
result.markChecked('V003');
|
|
290
|
+
const currentJsonPath = path.join(process.cwd(), '.a2ui', 'current.json');
|
|
291
|
+
// 这个只是个存在性检查,不是必须失败
|
|
292
|
+
|
|
293
|
+
// V004: .json 和 .html 成对检查
|
|
294
|
+
result.markChecked('V004');
|
|
295
|
+
const files = fs.readdirSync(uiDir);
|
|
296
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
297
|
+
const htmlFiles = files.filter(f => f.endsWith('.html'));
|
|
298
|
+
|
|
299
|
+
jsonFiles.forEach(jsonFile => {
|
|
300
|
+
const htmlFile = jsonFile.replace('.json', '.html');
|
|
301
|
+
if (!htmlFiles.includes(htmlFile)) {
|
|
302
|
+
result.addViolation('V004', `缺少配对的 HTML 文件: ${htmlFile}`, path.join(uiDir, jsonFile));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// V005: 命名规范检查
|
|
307
|
+
result.markChecked('V005');
|
|
308
|
+
const namingPattern = /^REQ-\d{3}-[\u4e00-\u9fa5a-zA-Z0-9_-]+\.(json|html)$/;
|
|
309
|
+
const allUIFiles = [...jsonFiles, ...htmlFiles];
|
|
310
|
+
allUIFiles.forEach(file => {
|
|
311
|
+
// 排除 index.md
|
|
312
|
+
if (file === 'index.md') return;
|
|
313
|
+
if (!namingPattern.test(file)) {
|
|
314
|
+
result.addWarning('V005', `文件名不符合规范: ${file}(应为 REQ-XXX-名称.json/html)`, path.join(uiDir, file));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// V006: index.md 检查
|
|
319
|
+
result.markChecked('V006');
|
|
320
|
+
const indexPath = path.join(uiDir, 'index.md');
|
|
321
|
+
if (jsonFiles.length > 0 && !fs.existsSync(indexPath)) {
|
|
322
|
+
result.addViolation('V006', '存在原型文件但缺少 index.md 索引', uiDir);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// V007: Schema 校验(检查组件类型是否合法)
|
|
326
|
+
result.markChecked('V007');
|
|
327
|
+
const validComponentTypes = [
|
|
328
|
+
'Page', 'Panel', 'Row', 'Col', 'Input', 'Textarea', 'Select', 'Button',
|
|
329
|
+
'Text', 'Table', 'Tabs', 'Badge', 'Card', 'Upload', 'Alert', 'Divider',
|
|
330
|
+
'Diagram', 'Box', 'Arrow', 'Layer', 'DiagramGroup'
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
jsonFiles.forEach(jsonFile => {
|
|
334
|
+
try {
|
|
335
|
+
const jsonPath = path.join(uiDir, jsonFile);
|
|
336
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
337
|
+
const invalidTypes = findInvalidComponentTypes(data, validComponentTypes);
|
|
338
|
+
if (invalidTypes.length > 0) {
|
|
339
|
+
result.addViolation(
|
|
340
|
+
'V007',
|
|
341
|
+
`发现未定义的组件类型: ${invalidTypes.join(', ')}`,
|
|
342
|
+
jsonPath
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
result.addWarning('V007', `无法解析 JSON 文件: ${jsonFile}`, path.join(uiDir, jsonFile));
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 递归查找无效的组件类型
|
|
353
|
+
*/
|
|
354
|
+
function findInvalidComponentTypes(node, validTypes, found = new Set()) {
|
|
355
|
+
if (!node || typeof node !== 'object') return [];
|
|
356
|
+
|
|
357
|
+
if (node.type && !validTypes.includes(node.type)) {
|
|
358
|
+
found.add(node.type);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (Array.isArray(node.children)) {
|
|
362
|
+
node.children.forEach(child => findInvalidComponentTypes(child, validTypes, found));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return Array.from(found);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 校验器:需求范围检查 (S002-S003)
|
|
370
|
+
* 简化版:检查配置中记录的需求范围
|
|
371
|
+
*/
|
|
372
|
+
function checkRequirementScope(config, result) {
|
|
373
|
+
if (!config) {
|
|
374
|
+
result.addSkipped('S002', '未找到项目配置文件');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const currentIteration = config.currentIteration;
|
|
379
|
+
if (!currentIteration) {
|
|
380
|
+
result.addSkipped('S002', '当前没有活跃迭代');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// S002: C0 只含首批需求(检查配置标记)
|
|
385
|
+
result.markChecked('S002');
|
|
386
|
+
if (config.version?.currentBatch && config.version?.totalBatches) {
|
|
387
|
+
const { currentBatch, totalBatches } = config.version;
|
|
388
|
+
if (totalBatches > 1) {
|
|
389
|
+
result.addWarning(
|
|
390
|
+
'S002',
|
|
391
|
+
`当前是第 ${currentBatch}/${totalBatches} 批次,请确保 C0 只包含当前批次的需求`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// S003: C1 需求必须在 B3 范围内(检查文件是否存在)
|
|
397
|
+
result.markChecked('S003');
|
|
398
|
+
const iterationDir = path.join(
|
|
399
|
+
process.cwd(),
|
|
400
|
+
'02_迭代记录',
|
|
401
|
+
`第${String(currentIteration).padStart(2, '0')}轮迭代`
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const b3Path = path.join(iterationDir, 'B3_规划冻结.md');
|
|
405
|
+
const c1Dir = path.join(iterationDir, 'C1_需求清单');
|
|
406
|
+
|
|
407
|
+
// 如果 B3 存在但 C1 目录不存在,跳过
|
|
408
|
+
if (!fs.existsSync(b3Path)) {
|
|
409
|
+
result.addSkipped('S003', 'B3 文档尚未冻结');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!fs.existsSync(c1Dir)) {
|
|
414
|
+
result.addSkipped('S003', 'C1 目录不存在');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 尝试从 B3 提取需求编号(简单版本:查找 REQ-XXX 模式)
|
|
419
|
+
try {
|
|
420
|
+
const b3Content = fs.readFileSync(b3Path, 'utf-8');
|
|
421
|
+
const b3ReqPattern = /REQ-(\d{3})/g;
|
|
422
|
+
const b3Reqs = new Set();
|
|
423
|
+
let match;
|
|
424
|
+
while ((match = b3ReqPattern.exec(b3Content)) !== null) {
|
|
425
|
+
b3Reqs.add(match[1]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 从 C1 目录获取需求文件
|
|
429
|
+
const c1Files = fs.readdirSync(c1Dir).filter(f => f.endsWith('.md'));
|
|
430
|
+
const c1Reqs = new Set();
|
|
431
|
+
c1Files.forEach(f => {
|
|
432
|
+
const reqMatch = f.match(/REQ-(\d{3})/);
|
|
433
|
+
if (reqMatch) {
|
|
434
|
+
c1Reqs.add(reqMatch[1]);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// 检查 C1 中是否有 B3 范围外的需求
|
|
439
|
+
c1Reqs.forEach(req => {
|
|
440
|
+
if (b3Reqs.size > 0 && !b3Reqs.has(req)) {
|
|
441
|
+
result.addViolation(
|
|
442
|
+
'S003',
|
|
443
|
+
`C1 中的 REQ-${req} 不在 B3 范围内`,
|
|
444
|
+
path.join(c1Dir, `REQ-${req}*.md`)
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
} catch (e) {
|
|
449
|
+
result.addWarning('S003', `无法解析 B3 文档: ${e.message}`, b3Path);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 主检查函数
|
|
455
|
+
*/
|
|
456
|
+
async function runCheck(options = {}) {
|
|
457
|
+
const result = new CheckResult();
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const rulesIndex = loadRules();
|
|
461
|
+
const config = loadProjectConfig();
|
|
462
|
+
|
|
463
|
+
if (!config) {
|
|
464
|
+
console.log(chalk.yellow('⚠️ 当前目录不是 PRD 项目(缺少 .prd-config.json)'));
|
|
465
|
+
console.log(chalk.gray(' 运行 `prd init` 初始化项目'));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
console.log(chalk.blue('🔍 正在检查 PRD 规则...'));
|
|
470
|
+
console.log('');
|
|
471
|
+
|
|
472
|
+
// 运行所有校验器
|
|
473
|
+
checkFrozenStatus(config, result);
|
|
474
|
+
checkFlowOrder(config, result);
|
|
475
|
+
checkA2UIFiles(config, result);
|
|
476
|
+
checkRequirementScope(config, result);
|
|
477
|
+
|
|
478
|
+
// 输出结果
|
|
479
|
+
if (options.json) {
|
|
480
|
+
console.log(JSON.stringify(result.toJSON(), null, 2));
|
|
481
|
+
} else {
|
|
482
|
+
result.print();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 保存日志(除非指定 --no-log)
|
|
486
|
+
if (!options.noLog) {
|
|
487
|
+
try {
|
|
488
|
+
result.saveLog();
|
|
489
|
+
if (!options.json) {
|
|
490
|
+
console.log(chalk.gray('📝 日志已保存到 .prd-logs/check-history.json'));
|
|
491
|
+
}
|
|
492
|
+
} catch (e) {
|
|
493
|
+
// 日志保存失败不影响主流程
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 如果有违规,退出码为 1
|
|
498
|
+
if (!result.passed) {
|
|
499
|
+
process.exitCode = 1;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error(chalk.red('校验失败:'), error.message);
|
|
504
|
+
process.exitCode = 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = runCheck;
|
|
509
|
+
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prd stats - 规则统计命令
|
|
3
|
+
*
|
|
4
|
+
* 基于 .prd-logs/check-history.json 生成统计报告
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* prd stats # 显示统计报告
|
|
8
|
+
* prd stats --json # 输出 JSON 格式
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
|
|
15
|
+
// 加载日志历史
|
|
16
|
+
function loadHistory() {
|
|
17
|
+
const logPath = path.join(process.cwd(), '.prd-logs', 'check-history.json');
|
|
18
|
+
if (!fs.existsSync(logPath)) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(logPath, 'utf-8'));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 加载规则索引
|
|
29
|
+
function loadRules() {
|
|
30
|
+
const rulesPath = path.join(__dirname, '../rules/index.json');
|
|
31
|
+
if (!fs.existsSync(rulesPath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return JSON.parse(fs.readFileSync(rulesPath, 'utf-8'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 生成统计报告
|
|
38
|
+
function generateStats(history) {
|
|
39
|
+
const stats = {
|
|
40
|
+
totalChecks: history.length,
|
|
41
|
+
passRate: 0,
|
|
42
|
+
firstPassRate: 0,
|
|
43
|
+
violationsByRule: {},
|
|
44
|
+
warningsByRule: {},
|
|
45
|
+
recentTrend: []
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (history.length === 0) {
|
|
49
|
+
return stats;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 计算通过率
|
|
53
|
+
const passed = history.filter(h => h.passed).length;
|
|
54
|
+
stats.passRate = Math.round((passed / history.length) * 100);
|
|
55
|
+
|
|
56
|
+
// 合并所有违规统计
|
|
57
|
+
history.forEach(h => {
|
|
58
|
+
if (h.violations_by_rule) {
|
|
59
|
+
Object.entries(h.violations_by_rule).forEach(([rule, count]) => {
|
|
60
|
+
stats.violationsByRule[rule] = (stats.violationsByRule[rule] || 0) + count;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (h.warnings_by_rule) {
|
|
64
|
+
Object.entries(h.warnings_by_rule).forEach(([rule, count]) => {
|
|
65
|
+
stats.warningsByRule[rule] = (stats.warningsByRule[rule] || 0) + count;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 最近 7 天趋势
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
73
|
+
const recent = history.filter(h => new Date(h.timestamp) >= sevenDaysAgo);
|
|
74
|
+
|
|
75
|
+
// 按天分组
|
|
76
|
+
const byDay = {};
|
|
77
|
+
recent.forEach(h => {
|
|
78
|
+
const day = h.timestamp.split('T')[0];
|
|
79
|
+
if (!byDay[day]) {
|
|
80
|
+
byDay[day] = { total: 0, passed: 0, violations: 0 };
|
|
81
|
+
}
|
|
82
|
+
byDay[day].total++;
|
|
83
|
+
if (h.passed) byDay[day].passed++;
|
|
84
|
+
byDay[day].violations += h.summary?.violations || 0;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
stats.recentTrend = Object.entries(byDay).map(([date, data]) => ({
|
|
88
|
+
date,
|
|
89
|
+
checks: data.total,
|
|
90
|
+
passRate: Math.round((data.passed / data.total) * 100),
|
|
91
|
+
violations: data.violations
|
|
92
|
+
})).sort((a, b) => a.date.localeCompare(b.date));
|
|
93
|
+
|
|
94
|
+
return stats;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 打印报告
|
|
98
|
+
function printReport(stats, rulesIndex) {
|
|
99
|
+
console.log('');
|
|
100
|
+
console.log(chalk.bold('📊 PRD 规则统计报告'));
|
|
101
|
+
console.log('─'.repeat(50));
|
|
102
|
+
console.log('');
|
|
103
|
+
|
|
104
|
+
// 总体统计
|
|
105
|
+
console.log(chalk.blue('📈 总体统计'));
|
|
106
|
+
console.log(` 总检查次数: ${stats.totalChecks}`);
|
|
107
|
+
console.log(` 通过率: ${stats.passRate}%`);
|
|
108
|
+
console.log('');
|
|
109
|
+
|
|
110
|
+
// 高频违规规则 Top 5
|
|
111
|
+
const topViolations = Object.entries(stats.violationsByRule)
|
|
112
|
+
.sort((a, b) => b[1] - a[1])
|
|
113
|
+
.slice(0, 5);
|
|
114
|
+
|
|
115
|
+
if (topViolations.length > 0) {
|
|
116
|
+
console.log(chalk.red('🔴 高频违规规则 Top 5'));
|
|
117
|
+
topViolations.forEach(([ruleId, count], i) => {
|
|
118
|
+
const rule = rulesIndex?.rules?.find(r => r.id === ruleId);
|
|
119
|
+
const desc = rule?.description?.substring(0, 30) || '未知规则';
|
|
120
|
+
console.log(` ${i + 1}. [${ruleId}] ${desc}... (${count} 次)`);
|
|
121
|
+
});
|
|
122
|
+
console.log('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 高频警告规则 Top 5
|
|
126
|
+
const topWarnings = Object.entries(stats.warningsByRule)
|
|
127
|
+
.sort((a, b) => b[1] - a[1])
|
|
128
|
+
.slice(0, 5);
|
|
129
|
+
|
|
130
|
+
if (topWarnings.length > 0) {
|
|
131
|
+
console.log(chalk.yellow('🟡 高频警告规则 Top 5'));
|
|
132
|
+
topWarnings.forEach(([ruleId, count], i) => {
|
|
133
|
+
const rule = rulesIndex?.rules?.find(r => r.id === ruleId);
|
|
134
|
+
const desc = rule?.description?.substring(0, 30) || '未知规则';
|
|
135
|
+
console.log(` ${i + 1}. [${ruleId}] ${desc}... (${count} 次)`);
|
|
136
|
+
});
|
|
137
|
+
console.log('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 最近 7 天趋势
|
|
141
|
+
if (stats.recentTrend.length > 0) {
|
|
142
|
+
console.log(chalk.cyan('📅 最近 7 天趋势'));
|
|
143
|
+
console.log(' 日期 | 检查 | 通过率 | 违规');
|
|
144
|
+
console.log(' -----------|------|--------|------');
|
|
145
|
+
stats.recentTrend.forEach(day => {
|
|
146
|
+
const passRateBar = day.passRate >= 80 ? chalk.green(`${day.passRate}%`) :
|
|
147
|
+
day.passRate >= 50 ? chalk.yellow(`${day.passRate}%`) :
|
|
148
|
+
chalk.red(`${day.passRate}%`);
|
|
149
|
+
console.log(` ${day.date} | ${String(day.checks).padStart(4)} | ${passRateBar.padStart(6)} | ${day.violations}`);
|
|
150
|
+
});
|
|
151
|
+
console.log('');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 建议
|
|
155
|
+
if (topViolations.length > 0) {
|
|
156
|
+
console.log(chalk.green('💡 改进建议'));
|
|
157
|
+
const topRule = topViolations[0][0];
|
|
158
|
+
const rule = rulesIndex?.rules?.find(r => r.id === topRule);
|
|
159
|
+
if (rule) {
|
|
160
|
+
console.log(` 最需要关注的规则: [${topRule}]`);
|
|
161
|
+
console.log(` ${rule.description}`);
|
|
162
|
+
if (rule.validatorType === 'program') {
|
|
163
|
+
console.log(chalk.gray(` 该规则由 prd check 自动校验`));
|
|
164
|
+
} else {
|
|
165
|
+
console.log(chalk.gray(` 该规则需要 AI 自检,请确保 AI 阅读了 workflow 中的规则表`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
console.log('');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 主函数
|
|
173
|
+
async function runStats(options = {}) {
|
|
174
|
+
const history = loadHistory();
|
|
175
|
+
const rulesIndex = loadRules();
|
|
176
|
+
|
|
177
|
+
if (history.length === 0) {
|
|
178
|
+
console.log(chalk.yellow('⚠️ 暂无校验历史记录'));
|
|
179
|
+
console.log(chalk.gray(' 运行 `prd check` 后会自动记录日志'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const stats = generateStats(history);
|
|
184
|
+
|
|
185
|
+
if (options.json) {
|
|
186
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
187
|
+
} else {
|
|
188
|
+
printReport(stats, rulesIndex);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = runStats;
|