@zhuan-ai/zhuanspec 2.11.3 → 2.11.6
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/dist/commands/change.js +9 -4
- package/dist/commands/progress.js +3 -3
- package/dist/commands/spec.js +12 -4
- package/dist/commands/validate.js +15 -6
- package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
- package/dist/core/artifact-graph/instruction-loader.js +27 -2
- package/dist/core/hooks/deviation-check.js +16 -3
- package/dist/core/hooks/init.js +14 -1
- package/dist/core/hooks/notify-milestone.js +31 -5
- package/dist/core/hooks/pre-review.js +18 -5
- package/dist/core/hooks/record-progress.d.ts +12 -0
- package/dist/core/hooks/record-progress.js +3 -3
- package/dist/core/hooks/review-hooks.js +5 -4
- package/dist/core/hooks/review-orchestrator.js +9 -0
- package/dist/core/init.js +14 -2
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +11 -0
- package/dist/core/templates/slash-command-templates.js +25 -13
- package/dist/core/templates/tasks-template.d.ts +0 -7
- package/dist/core/templates/tasks-template.js +8 -45
- package/dist/core/templates/tdd-tasks-template.d.ts +0 -6
- package/dist/core/templates/tdd-tasks-template.js +159 -73
- package/dist/core/validation/strict-rules.d.ts +18 -8
- package/dist/core/validation/strict-rules.js +188 -70
- package/dist/core/validation/validator.js +21 -0
- package/dist/utils/task-progress.js +2 -2
- package/package.json +22 -20
- package/schemas/spec-driven/schema.yaml +10 -2
- package/schemas/spec-driven/templates/tasks.md +117 -4
|
@@ -44,16 +44,15 @@ const WAVE_HEADER_PATTERN = /^###\s+Wave\s+(\d+)/im;
|
|
|
44
44
|
export function checkTestCaseInquiryLogged(tasksContent) {
|
|
45
45
|
const result = {
|
|
46
46
|
ruleId: 'test-case-inquiry-logged',
|
|
47
|
-
ruleName: '
|
|
47
|
+
ruleName: '测试用例询问记录',
|
|
48
48
|
passed: false,
|
|
49
49
|
errors: [],
|
|
50
50
|
warnings: [],
|
|
51
51
|
};
|
|
52
52
|
const sectionMatch = tasksContent.match(TEST_CASE_SOURCE_LOG_PATTERN);
|
|
53
53
|
if (!sectionMatch) {
|
|
54
|
-
result.errors.push('
|
|
55
|
-
'
|
|
56
|
-
'Add a section with at least: "用户回答: 提供/跳过" and "TDD 模式: 启用/禁用".');
|
|
54
|
+
result.errors.push('缺少 "## Test Case Source Log" 章节 — AI 必须在提案阶段开始时询问用户测试用例并记录结果。' +
|
|
55
|
+
'至少包含:"用户回答: 提供/跳过" 和 "TDD 模式: 启用/禁用"。');
|
|
57
56
|
return result;
|
|
58
57
|
}
|
|
59
58
|
const sectionStartIndex = sectionMatch.index + sectionMatch[0].length;
|
|
@@ -64,11 +63,11 @@ export function checkTestCaseInquiryLogged(tasksContent) {
|
|
|
64
63
|
const sectionContent = tasksContent.slice(sectionStartIndex, sectionEndIndex);
|
|
65
64
|
const contentWithoutComments = sectionContent.replace(HTML_COMMENT_PATTERN, '').trim();
|
|
66
65
|
if (!contentWithoutComments) {
|
|
67
|
-
result.errors.push('"## Test Case Source Log"
|
|
66
|
+
result.errors.push('"## Test Case Source Log" 为空 — 请记录询问结果(用户回答 + TDD 模式状态)');
|
|
68
67
|
return result;
|
|
69
68
|
}
|
|
70
69
|
if (UNRESOLVED_MARKERS.test(contentWithoutComments)) {
|
|
71
|
-
result.errors.push('"## Test Case Source Log"
|
|
70
|
+
result.errors.push('"## Test Case Source Log" 包含未解决的标记(TODO / TBD / PENDING)');
|
|
72
71
|
return result;
|
|
73
72
|
}
|
|
74
73
|
const hasDeclaredStatus = TDD_MODE_DECLARED_PATTERNS.some(p => p.test(contentWithoutComments));
|
|
@@ -77,7 +76,7 @@ export function checkTestCaseInquiryLogged(tasksContent) {
|
|
|
77
76
|
result.passed = true;
|
|
78
77
|
}
|
|
79
78
|
else {
|
|
80
|
-
result.errors.push('"## Test Case Source Log"
|
|
79
|
+
result.errors.push('"## Test Case Source Log" 内容过简 — 至少包含用户回答和 TDD 模式状态');
|
|
81
80
|
}
|
|
82
81
|
return result;
|
|
83
82
|
}
|
|
@@ -98,7 +97,7 @@ export function checkTestCaseInquiryLogged(tasksContent) {
|
|
|
98
97
|
export function checkPreClarification(tasksContent) {
|
|
99
98
|
const result = {
|
|
100
99
|
ruleId: 'pre-clarification-completed',
|
|
101
|
-
ruleName: '
|
|
100
|
+
ruleName: '前置澄清记录',
|
|
102
101
|
passed: false,
|
|
103
102
|
errors: [],
|
|
104
103
|
warnings: [],
|
|
@@ -106,7 +105,7 @@ export function checkPreClarification(tasksContent) {
|
|
|
106
105
|
// Check if section exists
|
|
107
106
|
const sectionMatch = tasksContent.match(PRE_CLARIFICATION_SECTION_PATTERN);
|
|
108
107
|
if (!sectionMatch) {
|
|
109
|
-
result.errors.push('Pre-Clarification Log
|
|
108
|
+
result.errors.push('Pre-Clarification Log 章节缺失');
|
|
110
109
|
return result;
|
|
111
110
|
}
|
|
112
111
|
// Extract section content (from header to next ## or end)
|
|
@@ -120,12 +119,12 @@ export function checkPreClarification(tasksContent) {
|
|
|
120
119
|
const contentWithoutComments = sectionContent.replace(HTML_COMMENT_PATTERN, '').trim();
|
|
121
120
|
// Check if section is empty
|
|
122
121
|
if (!contentWithoutComments) {
|
|
123
|
-
result.errors.push('Pre-Clarification Log
|
|
122
|
+
result.errors.push('Pre-Clarification Log 章节为空或仅包含 HTML 注释');
|
|
124
123
|
return result;
|
|
125
124
|
}
|
|
126
125
|
// Check for unresolved markers
|
|
127
126
|
if (UNRESOLVED_MARKERS.test(contentWithoutComments)) {
|
|
128
|
-
result.errors.push('Pre-Clarification Log
|
|
127
|
+
result.errors.push('Pre-Clarification Log 包含未解决的标记(TODO、TBD、PENDING 或 ?)');
|
|
129
128
|
return result;
|
|
130
129
|
}
|
|
131
130
|
// Check for valid completion markers or actual content
|
|
@@ -135,7 +134,7 @@ export function checkPreClarification(tasksContent) {
|
|
|
135
134
|
result.passed = true;
|
|
136
135
|
}
|
|
137
136
|
else {
|
|
138
|
-
result.errors.push('Pre-Clarification Log
|
|
137
|
+
result.errors.push('Pre-Clarification Log 未包含有效的完成标记或实质性问答内容');
|
|
139
138
|
}
|
|
140
139
|
return result;
|
|
141
140
|
}
|
|
@@ -152,7 +151,7 @@ export function checkPreClarification(tasksContent) {
|
|
|
152
151
|
export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames) {
|
|
153
152
|
const result = {
|
|
154
153
|
ruleId: 'skill-tags-valid',
|
|
155
|
-
ruleName: 'Skill
|
|
154
|
+
ruleName: 'Skill 标签校验',
|
|
156
155
|
passed: false,
|
|
157
156
|
errors: [],
|
|
158
157
|
warnings: [],
|
|
@@ -160,7 +159,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
160
159
|
// a. Check Skill Mapping section exists with table data
|
|
161
160
|
const sectionMatch = tasksContent.match(SKILL_MAPPING_SECTION_PATTERN);
|
|
162
161
|
if (!sectionMatch) {
|
|
163
|
-
result.errors.push('Skill Mapping
|
|
162
|
+
result.errors.push('Skill Mapping 章节未找到');
|
|
164
163
|
}
|
|
165
164
|
else {
|
|
166
165
|
// Extract section content (from header to next ## or end)
|
|
@@ -183,7 +182,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
183
182
|
});
|
|
184
183
|
// Need at least 2 rows (header + 1 data row)
|
|
185
184
|
if (!dataRows || dataRows.length < 2) {
|
|
186
|
-
result.errors.push('Skill Mapping
|
|
185
|
+
result.errors.push('Skill Mapping 表格无数据行');
|
|
187
186
|
}
|
|
188
187
|
}
|
|
189
188
|
// If no tasks, consider it valid
|
|
@@ -196,7 +195,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
196
195
|
for (const task of parsedTasks) {
|
|
197
196
|
// b. Check if task has skill tag
|
|
198
197
|
if (task.skills.length === 0) {
|
|
199
|
-
result.errors.push(
|
|
198
|
+
result.errors.push(`任务 ${task.id}: 缺少 @skill 标签`);
|
|
200
199
|
continue;
|
|
201
200
|
}
|
|
202
201
|
// Validate each skill
|
|
@@ -214,7 +213,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
214
213
|
descLower.includes('verify') ||
|
|
215
214
|
task.description.length > 30; // Substantial description is acceptable
|
|
216
215
|
if (!hasReason) {
|
|
217
|
-
result.warnings.push(
|
|
216
|
+
result.warnings.push(`任务 ${task.id}: @skill:none 应在描述中说明原因`);
|
|
218
217
|
}
|
|
219
218
|
continue;
|
|
220
219
|
}
|
|
@@ -223,7 +222,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
223
222
|
const availableSkills = knownSkillNames.length <= 10
|
|
224
223
|
? knownSkillNames.join(', ')
|
|
225
224
|
: `${knownSkillNames.slice(0, 10).join(', ')}... (${knownSkillNames.length} total)`;
|
|
226
|
-
result.errors.push(
|
|
225
|
+
result.errors.push(`任务 ${task.id}: @skill:${skill} 不是已知的 skill。可用 skills: ${availableSkills}`);
|
|
227
226
|
}
|
|
228
227
|
}
|
|
229
228
|
}
|
|
@@ -242,7 +241,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
242
241
|
export function checkTaskOrderingByWave(tasksContent, parsedTasks) {
|
|
243
242
|
const result = {
|
|
244
243
|
ruleId: 'task-ordering-by-wave',
|
|
245
|
-
ruleName: '
|
|
244
|
+
ruleName: '任务 Wave 排序校验',
|
|
246
245
|
passed: false,
|
|
247
246
|
errors: [],
|
|
248
247
|
warnings: [],
|
|
@@ -263,13 +262,13 @@ export function checkTaskOrderingByWave(tasksContent, parsedTasks) {
|
|
|
263
262
|
});
|
|
264
263
|
}
|
|
265
264
|
if (waveHeaders.length === 0) {
|
|
266
|
-
result.errors.push('
|
|
265
|
+
result.errors.push('tasks.md 中未找到 Wave 头部。期望格式: "### Wave N"');
|
|
267
266
|
return result;
|
|
268
267
|
}
|
|
269
268
|
// Generate execution plan to get actual waves
|
|
270
269
|
const executionPlan = generateExecutionPlan(parsedTasks);
|
|
271
270
|
if (executionPlan.hasCycle) {
|
|
272
|
-
result.errors.push('
|
|
271
|
+
result.errors.push('任务依赖图存在循环,无法校验 Wave 排序');
|
|
273
272
|
return result;
|
|
274
273
|
}
|
|
275
274
|
// Build a map of task ID to actual wave number
|
|
@@ -302,7 +301,7 @@ export function checkTaskOrderingByWave(tasksContent, parsedTasks) {
|
|
|
302
301
|
let lastWave = 0;
|
|
303
302
|
for (const task of taskPositions) {
|
|
304
303
|
if (task.actualWave < lastWave) {
|
|
305
|
-
result.errors.push(
|
|
304
|
+
result.errors.push(`任务 ${task.id}(Wave ${task.actualWave})出现在 Wave ${lastWave} 的任务之后`);
|
|
306
305
|
}
|
|
307
306
|
lastWave = Math.max(lastWave, task.actualWave);
|
|
308
307
|
}
|
|
@@ -311,18 +310,18 @@ export function checkTaskOrderingByWave(tasksContent, parsedTasks) {
|
|
|
311
310
|
const declaredWaveNumbers = new Set(waveHeaders.map(h => h.wave));
|
|
312
311
|
for (const declared of declaredWaveNumbers) {
|
|
313
312
|
if (!actualWaveNumbers.has(declared)) {
|
|
314
|
-
result.warnings.push(`Wave
|
|
313
|
+
result.warnings.push(`Wave 头部 "### Wave ${declared}" 与计算得到的 Wave 不匹配`);
|
|
315
314
|
}
|
|
316
315
|
}
|
|
317
316
|
for (const actual of actualWaveNumbers) {
|
|
318
317
|
if (!declaredWaveNumbers.has(actual)) {
|
|
319
|
-
result.errors.push(
|
|
318
|
+
result.errors.push(`缺少计算得到的 Wave ${actual} 的头部`);
|
|
320
319
|
}
|
|
321
320
|
}
|
|
322
321
|
// Check wave header ordering
|
|
323
322
|
for (let i = 1; i < waveHeaders.length; i++) {
|
|
324
323
|
if (waveHeaders[i].wave <= waveHeaders[i - 1].wave) {
|
|
325
|
-
result.errors.push(`Wave
|
|
324
|
+
result.errors.push(`Wave 头部未按升序排列: Wave ${waveHeaders[i - 1].wave} 后面是 Wave ${waveHeaders[i].wave}`);
|
|
326
325
|
}
|
|
327
326
|
}
|
|
328
327
|
result.passed = result.errors.length === 0;
|
|
@@ -341,7 +340,7 @@ export function checkTaskOrderingByWave(tasksContent, parsedTasks) {
|
|
|
341
340
|
export function checkSubagentDirectives(parsedTasks) {
|
|
342
341
|
const result = {
|
|
343
342
|
ruleId: 'subagent-directives-valid',
|
|
344
|
-
ruleName: '
|
|
343
|
+
ruleName: '子代理指令校验',
|
|
345
344
|
passed: false,
|
|
346
345
|
errors: [],
|
|
347
346
|
warnings: [],
|
|
@@ -354,7 +353,7 @@ export function checkSubagentDirectives(parsedTasks) {
|
|
|
354
353
|
// Generate execution plan
|
|
355
354
|
const executionPlan = generateExecutionPlan(parsedTasks);
|
|
356
355
|
if (executionPlan.hasCycle) {
|
|
357
|
-
result.errors.push('
|
|
356
|
+
result.errors.push('任务依赖图存在循环,无法校验子代理指令');
|
|
358
357
|
return result;
|
|
359
358
|
}
|
|
360
359
|
if (executionPlan.waves.length === 0) {
|
|
@@ -370,24 +369,24 @@ export function checkSubagentDirectives(parsedTasks) {
|
|
|
370
369
|
// Check subagent_count attribute exists with correct value
|
|
371
370
|
const subagentCountPattern = new RegExp(`<wave[^>]*number="${wave.wave}"[^>]*subagent_count="${taskCount}"`);
|
|
372
371
|
if (!subagentCountPattern.test(xml)) {
|
|
373
|
-
result.errors.push(`Wave ${wave.wave}:
|
|
372
|
+
result.errors.push(`Wave ${wave.wave}: 缺少或错误的 subagent_count 属性(期望 ${taskCount})`);
|
|
374
373
|
}
|
|
375
374
|
// Check execution_mode attribute exists with correct value
|
|
376
375
|
const executionModePattern = new RegExp(`<wave[^>]*number="${wave.wave}"[^>]*execution_mode="${expectedMode}"`);
|
|
377
376
|
if (!executionModePattern.test(xml)) {
|
|
378
|
-
result.errors.push(`Wave ${wave.wave}:
|
|
377
|
+
result.errors.push(`Wave ${wave.wave}: 缺少或错误的 execution_mode 属性(期望 "${expectedMode}")`);
|
|
379
378
|
}
|
|
380
379
|
}
|
|
381
380
|
// Validate scheduling_summary section exists
|
|
382
381
|
if (!xml.includes('<scheduling_summary>')) {
|
|
383
|
-
result.errors.push('
|
|
382
|
+
result.errors.push('编排策略中缺少 <scheduling_summary> 节点');
|
|
384
383
|
}
|
|
385
384
|
else {
|
|
386
385
|
// Validate each wave has a scheduling summary entry
|
|
387
386
|
for (const wave of executionPlan.waves) {
|
|
388
387
|
const waveSummaryPattern = new RegExp(`Wave ${wave.wave} \u2192`);
|
|
389
388
|
if (!waveSummaryPattern.test(xml)) {
|
|
390
|
-
result.errors.push(`Wave ${wave.wave}:
|
|
389
|
+
result.errors.push(`Wave ${wave.wave}: <scheduling_summary> 中缺少调度摘要条目`);
|
|
391
390
|
}
|
|
392
391
|
}
|
|
393
392
|
}
|
|
@@ -402,7 +401,7 @@ export function checkSubagentDirectives(parsedTasks) {
|
|
|
402
401
|
export function checkReviewGateDefined(tasksContent) {
|
|
403
402
|
const result = {
|
|
404
403
|
ruleId: 'review-gate-defined',
|
|
405
|
-
ruleName: 'Review
|
|
404
|
+
ruleName: 'Review 门禁定义',
|
|
406
405
|
passed: false,
|
|
407
406
|
errors: [],
|
|
408
407
|
warnings: [],
|
|
@@ -411,7 +410,7 @@ export function checkReviewGateDefined(tasksContent) {
|
|
|
411
410
|
/review\s*gate/i.test(tasksContent) ||
|
|
412
411
|
/双轨审查/.test(tasksContent);
|
|
413
412
|
if (!hasReviewGate) {
|
|
414
|
-
result.errors.push('tasks.md
|
|
413
|
+
result.errors.push('tasks.md 必须包含 zhuanspec review 门禁');
|
|
415
414
|
return result;
|
|
416
415
|
}
|
|
417
416
|
result.passed = true;
|
|
@@ -428,7 +427,7 @@ export function checkReviewGateDefined(tasksContent) {
|
|
|
428
427
|
export function checkProposalFormatValid(proposalContent) {
|
|
429
428
|
const result = {
|
|
430
429
|
ruleId: 'proposal-format-valid',
|
|
431
|
-
ruleName: '
|
|
430
|
+
ruleName: '提案格式校验',
|
|
432
431
|
passed: false,
|
|
433
432
|
errors: [],
|
|
434
433
|
warnings: [],
|
|
@@ -571,7 +570,7 @@ function extractScenarioDimensions(name, description) {
|
|
|
571
570
|
function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
572
571
|
const result = {
|
|
573
572
|
ruleId: 'spec-code-consistent',
|
|
574
|
-
ruleName: 'Spec
|
|
573
|
+
ruleName: 'Spec-代码一致性',
|
|
575
574
|
passed: false,
|
|
576
575
|
errors: [],
|
|
577
576
|
warnings: [],
|
|
@@ -579,7 +578,7 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
|
579
578
|
// Skip this check in Propose phase
|
|
580
579
|
if (phase === 'propose') {
|
|
581
580
|
result.passed = true;
|
|
582
|
-
result.warnings.push('
|
|
581
|
+
result.warnings.push('跳过(Propose 阶段)— 还没有代码变更可校验');
|
|
583
582
|
return result;
|
|
584
583
|
}
|
|
585
584
|
// If specsDir doesn't exist, skip this check
|
|
@@ -756,7 +755,7 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
|
756
755
|
export function checkFileConflicts(parsedTasks) {
|
|
757
756
|
const result = {
|
|
758
757
|
ruleId: 'file-conflict-detection',
|
|
759
|
-
ruleName: '
|
|
758
|
+
ruleName: '文件冲突检测',
|
|
760
759
|
passed: true,
|
|
761
760
|
errors: [],
|
|
762
761
|
warnings: [],
|
|
@@ -784,8 +783,8 @@ export function checkFileConflicts(parsedTasks) {
|
|
|
784
783
|
for (const [file, taskIds] of fileToTasks) {
|
|
785
784
|
if (taskIds.length >= 2) {
|
|
786
785
|
result.passed = false;
|
|
787
|
-
result.errors.push(`
|
|
788
|
-
|
|
786
|
+
result.errors.push(`Wave ${wave} 文件冲突: "${file}" 被任务 [${taskIds.join(', ')}] 同时修改。` +
|
|
787
|
+
`请添加 @depends 指定执行顺序,否则任务将自动串行化。`);
|
|
789
788
|
}
|
|
790
789
|
}
|
|
791
790
|
}
|
|
@@ -805,7 +804,7 @@ export function checkFileConflicts(parsedTasks) {
|
|
|
805
804
|
export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
806
805
|
const result = {
|
|
807
806
|
ruleId: 'test-case-coverage',
|
|
808
|
-
ruleName: '
|
|
807
|
+
ruleName: '测试用例覆盖',
|
|
809
808
|
passed: false,
|
|
810
809
|
errors: [],
|
|
811
810
|
warnings: [],
|
|
@@ -813,13 +812,13 @@ export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
|
813
812
|
// If no test case mode, skip with warning
|
|
814
813
|
if (!hasTestCase) {
|
|
815
814
|
result.passed = true;
|
|
816
|
-
result.warnings.push('
|
|
815
|
+
result.warnings.push('跳过(无测试用例模式)— test-case-coverage 检查仅在提供测试用例时生效');
|
|
817
816
|
return result;
|
|
818
817
|
}
|
|
819
818
|
// Check for Test Case Coverage section (required when hasTestCase=true)
|
|
820
819
|
const testCaseSectionPattern = /^##\s+Test\s+Case\s+Coverage/im;
|
|
821
820
|
if (!testCaseSectionPattern.test(tasksContent)) {
|
|
822
|
-
result.errors.push('
|
|
821
|
+
result.errors.push('缺少 Test Case Coverage 章节 — 提供测试用例时必须填写(TDD 模式或用户提供)');
|
|
823
822
|
return result;
|
|
824
823
|
}
|
|
825
824
|
// Check for @test-case tags in tasks
|
|
@@ -829,7 +828,7 @@ export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
|
829
828
|
result.passed = true;
|
|
830
829
|
}
|
|
831
830
|
else {
|
|
832
|
-
result.errors.push('
|
|
831
|
+
result.errors.push('未找到 @test-case 标签 — 提供测试用例时必须使用 @test-case:TC-XXX 关联任务与测试用例');
|
|
833
832
|
}
|
|
834
833
|
// Validate Test Case Coverage table format (four columns required)
|
|
835
834
|
// Expected headers: Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景
|
|
@@ -841,7 +840,7 @@ export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
|
841
840
|
if (headerLine) {
|
|
842
841
|
const columns = headerLine.split('|').filter(col => col.trim() !== '');
|
|
843
842
|
if (columns.length < 4) {
|
|
844
|
-
result.warnings.push(`Test Case Coverage
|
|
843
|
+
result.warnings.push(`Test Case Coverage 表格应有 4 列(Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景),当前 ${columns.length} 列`);
|
|
845
844
|
}
|
|
846
845
|
else {
|
|
847
846
|
// Check column names contain expected keywords
|
|
@@ -851,13 +850,13 @@ export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
|
851
850
|
const hasPathCol = headerText.includes('文件路径') || headerText.includes('path') || headerText.includes('路径');
|
|
852
851
|
const hasScenarioCol = headerText.includes('覆盖场景') || headerText.includes('场景') || headerText.includes('scenario');
|
|
853
852
|
if (!hasTaskCol || !hasTestCaseIdCol || !hasPathCol || !hasScenarioCol) {
|
|
854
|
-
result.warnings.push('Test Case Coverage
|
|
853
|
+
result.warnings.push('Test Case Coverage 表格列头格式不匹配,应为: Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景');
|
|
855
854
|
}
|
|
856
855
|
}
|
|
857
856
|
}
|
|
858
857
|
}
|
|
859
858
|
else {
|
|
860
|
-
result.warnings.push('Test Case Coverage
|
|
859
|
+
result.warnings.push('Test Case Coverage 表格格式未检测到 — 请确保表格有 4 列且列头正确');
|
|
861
860
|
}
|
|
862
861
|
return result;
|
|
863
862
|
}
|
|
@@ -866,30 +865,64 @@ export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
|
866
865
|
*
|
|
867
866
|
* When a tech-spec document exists, validates that the technical spec
|
|
868
867
|
* adequately covers the proposal's requirements.
|
|
869
|
-
* This is a basic framework - full logic will be implemented in Task 7's Skill.
|
|
870
868
|
*
|
|
871
|
-
* Tech spec document lookup order:
|
|
872
|
-
* 1.
|
|
873
|
-
* 2. tech-spec.md
|
|
874
|
-
* 3.
|
|
869
|
+
* Tech spec document lookup order (priority):
|
|
870
|
+
* 1. 用户通过 --tech-spec 指定的外部路径 (externalTechSpecPath)
|
|
871
|
+
* 2. techDesign/ 目录下的任意 .md 文件(文件名不限定为 tech-spec.md)
|
|
872
|
+
* 3. 提案根目录下的任意技术方案相关 .md 文件
|
|
875
873
|
*
|
|
876
874
|
* Checks:
|
|
877
|
-
* - tech-spec
|
|
878
|
-
* - tech-spec
|
|
875
|
+
* - tech-spec file exists and is non-empty
|
|
876
|
+
* - tech-spec contains substantive content (not just placeholder)
|
|
877
|
+
* - structural elements (DB tables, APIs, classes, etc.) are covered in proposal files
|
|
879
878
|
*/
|
|
880
|
-
export function checkTechSpecCoverage(changeDir) {
|
|
879
|
+
export function checkTechSpecCoverage(changeDir, externalTechSpecPath) {
|
|
881
880
|
const result = {
|
|
882
881
|
ruleId: 'tech-spec-coverage',
|
|
883
|
-
ruleName: '
|
|
882
|
+
ruleName: '技术方案覆盖率',
|
|
884
883
|
passed: false,
|
|
885
884
|
errors: [],
|
|
886
885
|
warnings: [],
|
|
887
886
|
};
|
|
888
|
-
// Look for tech-spec
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
];
|
|
887
|
+
// Look for tech-spec from multiple sources (priority order):
|
|
888
|
+
// 1. User-specified external path via --tech-spec
|
|
889
|
+
// 2. Any .md file under techDesign/ directory (filename NOT restricted to tech-spec.md)
|
|
890
|
+
// 3. Any tech-spec-like .md file in proposal root directory
|
|
891
|
+
const candidatePaths = [];
|
|
892
|
+
if (externalTechSpecPath) {
|
|
893
|
+
// Resolve external path: could be absolute or relative to cwd
|
|
894
|
+
const resolved = path.isAbsolute(externalTechSpecPath)
|
|
895
|
+
? externalTechSpecPath
|
|
896
|
+
: join(process.cwd(), externalTechSpecPath);
|
|
897
|
+
candidatePaths.push(resolved);
|
|
898
|
+
}
|
|
899
|
+
// Scan techDesign/ directory for any .md files (not just tech-spec.md)
|
|
900
|
+
const techDesignDir = join(changeDir, 'techDesign');
|
|
901
|
+
if (existsSync(techDesignDir)) {
|
|
902
|
+
try {
|
|
903
|
+
const entries = readdirSync(techDesignDir);
|
|
904
|
+
for (const entry of entries) {
|
|
905
|
+
if (entry.endsWith('.md')) {
|
|
906
|
+
candidatePaths.push(join(techDesignDir, entry));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
// Ignore read errors
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Also check proposal root for any tech-spec-like files
|
|
915
|
+
try {
|
|
916
|
+
const rootEntries = readdirSync(changeDir);
|
|
917
|
+
for (const entry of rootEntries) {
|
|
918
|
+
if (entry.endsWith('.md') && entry !== 'design.md' && entry !== 'tasks.md' && entry !== 'proposal.md') {
|
|
919
|
+
candidatePaths.push(join(changeDir, entry));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
// Ignore read errors
|
|
925
|
+
}
|
|
893
926
|
let techSpecPath = null;
|
|
894
927
|
for (const candidate of candidatePaths) {
|
|
895
928
|
if (existsSync(candidate)) {
|
|
@@ -898,9 +931,9 @@ export function checkTechSpecCoverage(changeDir) {
|
|
|
898
931
|
}
|
|
899
932
|
}
|
|
900
933
|
if (!techSpecPath) {
|
|
901
|
-
// No tech-spec
|
|
934
|
+
// No tech-spec found anywhere — skip check gracefully
|
|
902
935
|
result.passed = true;
|
|
903
|
-
result.warnings.push('
|
|
936
|
+
result.warnings.push('未找到技术方案文档(已检查外部路径、techDesign/tech-spec.md 和提案根目录)— 跳过覆盖率检查');
|
|
904
937
|
return result;
|
|
905
938
|
}
|
|
906
939
|
let content;
|
|
@@ -908,13 +941,13 @@ export function checkTechSpecCoverage(changeDir) {
|
|
|
908
941
|
content = readFileSync(techSpecPath, 'utf-8');
|
|
909
942
|
}
|
|
910
943
|
catch {
|
|
911
|
-
result.errors.push(
|
|
944
|
+
result.errors.push(`无法读取 ${path.relative(changeDir, techSpecPath)}`);
|
|
912
945
|
return result;
|
|
913
946
|
}
|
|
914
947
|
// Check if content is just the placeholder template
|
|
915
948
|
const stripped = content.replace(/^#.*$/gm, '').replace(/^>.*$/gm, '').replace(/^(created|source):.*$/gm, '').trim();
|
|
916
949
|
if (stripped.length < 50) {
|
|
917
|
-
result.warnings.push('techDesign/tech-spec.md
|
|
950
|
+
result.warnings.push('techDesign/tech-spec.md 似乎是占位模板 — 请填充实际技术方案内容');
|
|
918
951
|
result.passed = true;
|
|
919
952
|
return result;
|
|
920
953
|
}
|
|
@@ -923,7 +956,7 @@ export function checkTechSpecCoverage(changeDir) {
|
|
|
923
956
|
const elements = extractTechSpecElements(content);
|
|
924
957
|
const totalElements = elements.length;
|
|
925
958
|
if (totalElements === 0) {
|
|
926
|
-
result.warnings.push('
|
|
959
|
+
result.warnings.push('tech-spec.md 中未检测到结构化元素(表、API、类、配置)— 跳过覆盖率检查');
|
|
927
960
|
result.passed = true;
|
|
928
961
|
return result;
|
|
929
962
|
}
|
|
@@ -1160,7 +1193,7 @@ const TASK_ID_PATTERN = /^\d+\.\d+$/;
|
|
|
1160
1193
|
export function checkTaskIdFormat(tasksContent) {
|
|
1161
1194
|
const result = {
|
|
1162
1195
|
ruleId: 'task-id-format',
|
|
1163
|
-
ruleName: '
|
|
1196
|
+
ruleName: '任务 ID 格式',
|
|
1164
1197
|
passed: false,
|
|
1165
1198
|
errors: [],
|
|
1166
1199
|
warnings: [],
|
|
@@ -1174,11 +1207,83 @@ export function checkTaskIdFormat(tasksContent) {
|
|
|
1174
1207
|
}
|
|
1175
1208
|
}
|
|
1176
1209
|
if (invalidIds.length > 0) {
|
|
1177
|
-
result.errors = invalidIds.map(id =>
|
|
1210
|
+
result.errors = invalidIds.map(id => `无效任务 ID "${id}": 必须为 N.M 格式(如 1.1, 2.3)`);
|
|
1178
1211
|
}
|
|
1179
1212
|
result.passed = invalidIds.length === 0;
|
|
1180
1213
|
return result;
|
|
1181
1214
|
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Rule: task-checkbox-format
|
|
1217
|
+
*
|
|
1218
|
+
* Validates that all tasks under Wave headers use standard GitHub Flavored Markdown
|
|
1219
|
+
* checkbox format: `- [ ] N.M description` or `- [x] N.M description`.
|
|
1220
|
+
* Rejects legacy formats like `#### Task N.M:` headers or `✅` emoji as completion marker.
|
|
1221
|
+
* This ensures automated progress tracking (parseTasks, record-progress, review gate) works correctly.
|
|
1222
|
+
*/
|
|
1223
|
+
export function checkTaskCheckboxFormat(tasksContent) {
|
|
1224
|
+
const result = {
|
|
1225
|
+
ruleId: 'task-checkbox-format',
|
|
1226
|
+
ruleName: '任务复选框格式',
|
|
1227
|
+
passed: false,
|
|
1228
|
+
errors: [],
|
|
1229
|
+
warnings: [],
|
|
1230
|
+
};
|
|
1231
|
+
// 1. Check for legacy #### Task headers (should not be used)
|
|
1232
|
+
const legacyTaskHeaders = tasksContent.match(/^####\s*(?:Task\s+\d+\.\d+|T\d+)\s*:.+$/gm) || [];
|
|
1233
|
+
if (legacyTaskHeaders.length > 0) {
|
|
1234
|
+
for (const header of legacyTaskHeaders) {
|
|
1235
|
+
result.errors.push(`检测到旧格式任务: "${header.trim()}"。` +
|
|
1236
|
+
`必须使用复选框格式: "- [ ] N.M 描述" 或 "- [x] N.M 描述"`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
// 2. Find all lines under Wave headers that look like tasks but don't use checkbox format
|
|
1240
|
+
const lines = tasksContent.split('\n');
|
|
1241
|
+
let inWaveSection = false;
|
|
1242
|
+
for (const line of lines) {
|
|
1243
|
+
// Detect Wave header
|
|
1244
|
+
if (/^###\s+Wave\s+\d+/i.test(line)) {
|
|
1245
|
+
inWaveSection = true;
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
// Detect next non-Wave section header (## or ### not Wave)
|
|
1249
|
+
if (/^#{2,3}\s+(?!Wave\s+\d+)/i.test(line)) {
|
|
1250
|
+
inWaveSection = false;
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
if (!inWaveSection)
|
|
1254
|
+
continue;
|
|
1255
|
+
// Skip empty lines, comments, and non-list lines
|
|
1256
|
+
const trimmed = line.trim();
|
|
1257
|
+
if (!trimmed || trimmed.startsWith('<!--') || !trimmed.startsWith('-'))
|
|
1258
|
+
continue;
|
|
1259
|
+
// Line starts with - but is NOT a valid checkbox format
|
|
1260
|
+
if (/^-\s+(?!\[[ xX]\])\S/.test(trimmed)) {
|
|
1261
|
+
// Only flag lines that look like task items (have N.M id or substantial content)
|
|
1262
|
+
if (/^-\s+\d+\.\d+\s/.test(trimmed) || /^-\s+[A-Z]/.test(trimmed)) {
|
|
1263
|
+
result.errors.push(`任务行缺少复选框: "${trimmed.substring(0, 80)}"。` +
|
|
1264
|
+
`必须使用复选框格式: "- [ ] N.M 描述" 或 "- [x] N.M 描述"`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
// 3. Verify checkbox tasks exist (at least one task under Wave headers)
|
|
1269
|
+
const checkboxTasks = tasksContent.match(/^-\s+\[[ xX]\]\s+\d+\.\d+\s+.+$/gm) || [];
|
|
1270
|
+
if (checkboxTasks.length === 0 && legacyTaskHeaders.length === 0) {
|
|
1271
|
+
// No checkbox tasks found - this is an ERROR because all tasks MUST use checkbox format
|
|
1272
|
+
result.errors.push('Wave 头部下未找到复选框格式的任务。' +
|
|
1273
|
+
'所有任务必须使用 GitHub Flavored Markdown 复选框格式: "- [ ] N.M 描述"(待办)或 "- [x] N.M 描述"(已完成)。' +
|
|
1274
|
+
'禁止使用 #### 头部、纯文本或 emoji 标记。');
|
|
1275
|
+
}
|
|
1276
|
+
// 4. Check that completed tasks use [x] not ✅ emoji on checkbox lines
|
|
1277
|
+
const checkboxWithEmoji = tasksContent.match(/^-\s+\[[ ]\]\s+.+\u2705\s*$/gm) || [];
|
|
1278
|
+
if (checkboxWithEmoji.length > 0) {
|
|
1279
|
+
for (const line of checkboxWithEmoji) {
|
|
1280
|
+
result.warnings.push(`任务使用了 ✅ emoji 但复选框未勾选: "${line.trim().substring(0, 80)}"。` +
|
|
1281
|
+
`请使用 "- [x]" 标记完成,而非 ✅ emoji`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
result.passed = result.errors.length === 0;
|
|
1285
|
+
return result;
|
|
1286
|
+
}
|
|
1182
1287
|
/**
|
|
1183
1288
|
* Main entry: Run all strict validation rules
|
|
1184
1289
|
*/
|
|
@@ -1194,17 +1299,30 @@ export async function runStrictValidation(tasksContent, parsedTasks, knownSkillN
|
|
|
1194
1299
|
checkFileConflicts(parsedTasks),
|
|
1195
1300
|
checkTestCaseCoverage(tasksContent, hasTestCase),
|
|
1196
1301
|
checkTaskIdFormat(tasksContent),
|
|
1302
|
+
checkTaskCheckboxFormat(tasksContent),
|
|
1197
1303
|
];
|
|
1198
1304
|
// Add new rules if parameters are provided
|
|
1199
1305
|
if (options?.proposalContent) {
|
|
1200
1306
|
checks.push(checkProposalFormatValid(options.proposalContent));
|
|
1201
1307
|
}
|
|
1202
1308
|
// Add tech-spec-coverage rule if any tech spec document exists
|
|
1309
|
+
// Trigger when: techDesign/ directory has any .md files OR external --tech-spec path provided
|
|
1203
1310
|
if (options?.changeDir) {
|
|
1204
|
-
|
|
1205
|
-
|
|
1311
|
+
let hasTechSpec = !!options.techSpecPath;
|
|
1312
|
+
if (!hasTechSpec) {
|
|
1313
|
+
const techDesignDir = join(options.changeDir, 'techDesign');
|
|
1314
|
+
if (existsSync(techDesignDir)) {
|
|
1315
|
+
try {
|
|
1316
|
+
const entries = readdirSync(techDesignDir);
|
|
1317
|
+
hasTechSpec = entries.some((e) => e.endsWith('.md'));
|
|
1318
|
+
}
|
|
1319
|
+
catch {
|
|
1320
|
+
// Ignore
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1206
1324
|
if (hasTechSpec) {
|
|
1207
|
-
checks.push(checkTechSpecCoverage(options.changeDir));
|
|
1325
|
+
checks.push(checkTechSpecCoverage(options.changeDir, options.techSpecPath));
|
|
1208
1326
|
}
|
|
1209
1327
|
}
|
|
1210
1328
|
// Detect current phase for spec-code consistency check
|
|
@@ -324,12 +324,33 @@ export class Validator {
|
|
|
324
324
|
// No metadata file - assume spec-driven (no test cases)
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
|
+
// Detect external tech-spec path from @ref tags in tasks.md
|
|
328
|
+
// Format: @ref:/path/to/tech-spec.md#section or @ref:techDesign/tech-spec.md#section
|
|
329
|
+
// Note: the tech spec filename is NOT limited to 'tech-spec.md' — any .md file
|
|
330
|
+
// referenced via @ref that is NOT under the standard techDesign/ directory
|
|
331
|
+
// is treated as a potential external tech spec document.
|
|
332
|
+
let techSpecPath;
|
|
333
|
+
const refMatches = tasksContent.match(/@ref:([^\s#]+)/g);
|
|
334
|
+
if (refMatches) {
|
|
335
|
+
for (const refTag of refMatches) {
|
|
336
|
+
const refPath = refTag.replace('@ref:', '');
|
|
337
|
+
// Skip standard techDesign/ paths — those are already handled by default lookup
|
|
338
|
+
if (refPath.startsWith('techDesign/'))
|
|
339
|
+
continue;
|
|
340
|
+
// Any external .md file is a potential tech spec (filename is not restricted)
|
|
341
|
+
if (refPath.endsWith('.md')) {
|
|
342
|
+
techSpecPath = refPath;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
327
347
|
const strictResult = await runStrictValidation(tasksContent, parsedTasks, knownSkillNames, {
|
|
328
348
|
proposalContent,
|
|
329
349
|
specsDir,
|
|
330
350
|
repoRoot,
|
|
331
351
|
changeDir,
|
|
332
352
|
hasTestCase,
|
|
353
|
+
techSpecPath,
|
|
333
354
|
});
|
|
334
355
|
strictChecks = strictResult.checks;
|
|
335
356
|
// Merge strict errors into main issues
|
|
@@ -2,8 +2,8 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
|
|
4
4
|
const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
|
|
5
|
-
const TASK_HEADER_PATTERN = /^####\s*(?:Task\s
|
|
6
|
-
const COMPLETED_HEADER_PATTERN = /^####\s*(?:Task\s
|
|
5
|
+
const TASK_HEADER_PATTERN = /^####\s*(?:Task\s+)?\d+\.\d+[\s:]|^####\s*T\d+\s*:/;
|
|
6
|
+
const COMPLETED_HEADER_PATTERN = /^####\s*(?:Task\s+)?\d+\.\d+[\s:].*\u2705|^####\s*T\d+\s*:.*\u2705\s*$/;
|
|
7
7
|
export function countTasksFromContent(content) {
|
|
8
8
|
const lines = content.split('\n');
|
|
9
9
|
let total = 0;
|