@zhuan-ai/zhuanspec 2.10.0 → 2.11.2
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/cli/index.js +1 -0
- package/dist/commands/design.d.ts +12 -2
- package/dist/commands/design.js +64 -12
- package/dist/commands/progress.js +11 -0
- package/dist/commands/review.d.ts +1 -0
- package/dist/commands/review.js +13 -0
- package/dist/core/completions/command-registry.js +4 -0
- package/dist/core/hooks/deviation-check.js +42 -0
- package/dist/core/hooks/init.js +21 -4
- package/dist/core/hooks/post-apply.js +2 -0
- package/dist/core/hooks/pre-review.d.ts +27 -0
- package/dist/core/hooks/pre-review.js +169 -0
- package/dist/core/hooks/record-progress.d.ts +25 -0
- package/dist/core/hooks/record-progress.js +88 -1
- package/dist/core/hooks/review-hooks.js +171 -3
- package/dist/core/hooks/review-orchestrator.js +55 -8
- package/dist/core/hooks/tdd-phase-hook.d.ts +59 -0
- package/dist/core/hooks/tdd-phase-hook.js +313 -0
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +27 -0
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +139 -23
- package/dist/core/templates/skill-templates.js +21 -1
- package/dist/core/templates/slash-command-templates.js +129 -49
- package/dist/core/templates/tasks-template.js +89 -0
- package/dist/core/validation/strict-rules.d.ts +47 -0
- package/dist/core/validation/strict-rules.js +589 -42
- package/dist/utils/git-repo-detector.js +1 -1
- package/dist/utils/phase-utils.js +14 -1
- package/package.json +22 -20
|
@@ -17,6 +17,15 @@ const VALID_COMPLETION_MARKERS = [
|
|
|
17
17
|
/no\s+clarification\s+needed/i,
|
|
18
18
|
/status:\s*completed/i,
|
|
19
19
|
];
|
|
20
|
+
// Test Case Source Log patterns
|
|
21
|
+
const TEST_CASE_SOURCE_LOG_PATTERN = /^##\s+(Test\s+Case\s+Source\s+Log|测试\s*[Cc]ase\s*来源日志?)\s*$/im;
|
|
22
|
+
const TDD_MODE_DECLARED_PATTERNS = [
|
|
23
|
+
/TDD\s*模式[::]\s*(启用|禁用)/i,
|
|
24
|
+
/TDD\s*mode[::]?\s*(enabled|disabled)/i,
|
|
25
|
+
/用户回答[::]\s*\S/i,
|
|
26
|
+
/暂不提供|已跳过|skipped/i,
|
|
27
|
+
/@test-case:/i,
|
|
28
|
+
];
|
|
20
29
|
// Skill mapping section header pattern
|
|
21
30
|
const SKILL_MAPPING_SECTION_PATTERN = /^##\s+Skill\s+Mapping\s*$/im;
|
|
22
31
|
// Table data row pattern (starts with |, not a separator row like |---|---|)
|
|
@@ -24,6 +33,54 @@ const TABLE_DATA_ROW_PATTERN = /^\|(?![-:|\s]+\|$)[^\n]+\|/gm;
|
|
|
24
33
|
const SKILL_TAG_PATTERN = /@skill:([^\s@]+)/g;
|
|
25
34
|
// Wave header pattern
|
|
26
35
|
const WAVE_HEADER_PATTERN = /^###\s+Wave\s+(\d+)/im;
|
|
36
|
+
/**
|
|
37
|
+
* Rule 0: test-case-inquiry-logged
|
|
38
|
+
*
|
|
39
|
+
* Validates that tasks.md has a "## Test Case Source Log" section with non-empty content.
|
|
40
|
+
* This rule fires UNCONDITIONALLY — whether or not TDD mode is used.
|
|
41
|
+
* The AI must always ask the user about test cases and record the outcome before writing
|
|
42
|
+
* any spec/task content. Absence of this section means the inquiry was skipped.
|
|
43
|
+
*/
|
|
44
|
+
export function checkTestCaseInquiryLogged(tasksContent) {
|
|
45
|
+
const result = {
|
|
46
|
+
ruleId: 'test-case-inquiry-logged',
|
|
47
|
+
ruleName: 'Test case inquiry logged',
|
|
48
|
+
passed: false,
|
|
49
|
+
errors: [],
|
|
50
|
+
warnings: [],
|
|
51
|
+
};
|
|
52
|
+
const sectionMatch = tasksContent.match(TEST_CASE_SOURCE_LOG_PATTERN);
|
|
53
|
+
if (!sectionMatch) {
|
|
54
|
+
result.errors.push('Missing "## Test Case Source Log" section — the AI must ask the user about test cases ' +
|
|
55
|
+
'at the start of the proposal phase and record the result here before writing any spec/task content. ' +
|
|
56
|
+
'Add a section with at least: "用户回答: 提供/跳过" and "TDD 模式: 启用/禁用".');
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const sectionStartIndex = sectionMatch.index + sectionMatch[0].length;
|
|
60
|
+
const nextSectionMatch = tasksContent.slice(sectionStartIndex).match(/^##\s+/m);
|
|
61
|
+
const sectionEndIndex = nextSectionMatch
|
|
62
|
+
? sectionStartIndex + nextSectionMatch.index
|
|
63
|
+
: tasksContent.length;
|
|
64
|
+
const sectionContent = tasksContent.slice(sectionStartIndex, sectionEndIndex);
|
|
65
|
+
const contentWithoutComments = sectionContent.replace(HTML_COMMENT_PATTERN, '').trim();
|
|
66
|
+
if (!contentWithoutComments) {
|
|
67
|
+
result.errors.push('"## Test Case Source Log" is empty — record the inquiry outcome (user answer + TDD mode status)');
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
if (UNRESOLVED_MARKERS.test(contentWithoutComments)) {
|
|
71
|
+
result.errors.push('"## Test Case Source Log" contains unresolved markers (TODO / TBD / PENDING)');
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
const hasDeclaredStatus = TDD_MODE_DECLARED_PATTERNS.some(p => p.test(contentWithoutComments));
|
|
75
|
+
const hasSubstantiveContent = contentWithoutComments.length > 10;
|
|
76
|
+
if (hasDeclaredStatus || hasSubstantiveContent) {
|
|
77
|
+
result.passed = true;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
result.errors.push('"## Test Case Source Log" is too brief — include at least the user\'s answer and TDD mode status');
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
27
84
|
/**
|
|
28
85
|
* Rule 1: pre-clarification-completed
|
|
29
86
|
*
|
|
@@ -418,11 +475,96 @@ export function detectPhase(changeDir) {
|
|
|
418
475
|
return 'propose';
|
|
419
476
|
}
|
|
420
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Search for a keyword in project files of a specific type.
|
|
480
|
+
* Uses synchronous file system operations for simplicity.
|
|
481
|
+
*/
|
|
482
|
+
function searchCode(projectRoot, keyword, fileType, touchedFiles) {
|
|
483
|
+
const result = {
|
|
484
|
+
dimension: fileType,
|
|
485
|
+
keyword,
|
|
486
|
+
fileType,
|
|
487
|
+
found: false,
|
|
488
|
+
};
|
|
489
|
+
const extensionMap = {
|
|
490
|
+
java: ['.java'],
|
|
491
|
+
tsx: ['.tsx', '.jsx', '.ts', '.js'],
|
|
492
|
+
xml: ['.xml'],
|
|
493
|
+
sql: ['.sql'],
|
|
494
|
+
};
|
|
495
|
+
const extensions = extensionMap[fileType] || [`.${fileType}`];
|
|
496
|
+
const keywordLower = keyword.toLowerCase();
|
|
497
|
+
for (const file of touchedFiles) {
|
|
498
|
+
const ext = path.extname(file).toLowerCase();
|
|
499
|
+
if (!extensions.includes(ext))
|
|
500
|
+
continue;
|
|
501
|
+
try {
|
|
502
|
+
const fullPath = path.isAbsolute(file) ? file : path.join(projectRoot, file);
|
|
503
|
+
if (!existsSync(fullPath))
|
|
504
|
+
continue;
|
|
505
|
+
const content = readFileSync(fullPath, 'utf-8').toLowerCase();
|
|
506
|
+
if (content.includes(keywordLower)) {
|
|
507
|
+
result.found = true;
|
|
508
|
+
result.matchedFile = file;
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Skip unreadable files
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Calculate coverage rate from scenario results.
|
|
520
|
+
*/
|
|
521
|
+
function calculateCoverageRate(results) {
|
|
522
|
+
if (results.length === 0)
|
|
523
|
+
return 100;
|
|
524
|
+
const covered = results.filter(r => r.covered).length;
|
|
525
|
+
return Math.round((covered / results.length) * 100);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Extract multi-dimensional keywords from a scenario/requirement description.
|
|
529
|
+
* Parses common patterns like class names, API paths, table names, UI actions.
|
|
530
|
+
*/
|
|
531
|
+
function extractScenarioDimensions(name, description) {
|
|
532
|
+
const combined = `${name} ${description}`;
|
|
533
|
+
const scenario = {
|
|
534
|
+
name,
|
|
535
|
+
keywords: [],
|
|
536
|
+
};
|
|
537
|
+
// Dimension 1: Class/method name (PascalCase or camelCase identifiers)
|
|
538
|
+
const classMatch = combined.match(/\b([A-Z][a-zA-Z0-9]{2,}(?:Service|Controller|Mapper|Repository|Handler|Manager|Impl|DTO|VO|Entity))\b/);
|
|
539
|
+
if (classMatch) {
|
|
540
|
+
scenario.mentionsClass = classMatch[1];
|
|
541
|
+
}
|
|
542
|
+
// Dimension 2: API path (e.g., /api/xxx or /v1/xxx)
|
|
543
|
+
const apiMatch = combined.match(/(\/(?:api|v\d+)\/[\w\-/]+)/);
|
|
544
|
+
if (apiMatch) {
|
|
545
|
+
scenario.mentionsApiPath = apiMatch[1];
|
|
546
|
+
}
|
|
547
|
+
// Dimension 3: Database table (e.g., t_xxx or table xxx)
|
|
548
|
+
const tableMatch = combined.match(/\b(t_[a-z][a-z0-9_]+)\b/i) ||
|
|
549
|
+
combined.match(/(?:表|table)[::\s]+([a-z][a-z0-9_]+)/i);
|
|
550
|
+
if (tableMatch) {
|
|
551
|
+
scenario.mentionsTable = tableMatch[1];
|
|
552
|
+
}
|
|
553
|
+
// Dimension 4: UI action (e.g., 点击xxx, onClick, onSubmit)
|
|
554
|
+
const uiMatch = combined.match(/(?:点击|click|onClick|onSubmit|按钮|button)[\s::]*([\u4e00-\u9fa5a-zA-Z]+)/i);
|
|
555
|
+
if (uiMatch) {
|
|
556
|
+
scenario.mentionsUIAction = uiMatch[1];
|
|
557
|
+
}
|
|
558
|
+
// Fallback keywords: extract meaningful words (length > 3)
|
|
559
|
+
const words = name.toLowerCase().split(/[\s\-_/]+/).filter(w => w.length > 3);
|
|
560
|
+
scenario.keywords = words;
|
|
561
|
+
return scenario;
|
|
562
|
+
}
|
|
421
563
|
/**
|
|
422
564
|
* Rule 8: spec-code-consistent
|
|
423
565
|
*
|
|
424
566
|
* Validates that Requirements in delta specs have corresponding code changes.
|
|
425
|
-
* Uses
|
|
567
|
+
* Uses multi-dimensional semantic matching: class names, API paths, table names, UI actions.
|
|
426
568
|
*
|
|
427
569
|
* SKIPPED in Propose phase (no code changes yet).
|
|
428
570
|
*/
|
|
@@ -445,8 +587,8 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
|
445
587
|
result.passed = true;
|
|
446
588
|
return result;
|
|
447
589
|
}
|
|
448
|
-
// Collect requirements from delta specs
|
|
449
|
-
const
|
|
590
|
+
// Collect requirements with scenario-level detail from delta specs
|
|
591
|
+
const parsedScenarios = [];
|
|
450
592
|
try {
|
|
451
593
|
const specDirs = readdirSync(specsDir, { withFileTypes: true });
|
|
452
594
|
for (const entry of specDirs) {
|
|
@@ -456,29 +598,71 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
|
456
598
|
if (!existsSync(specFile))
|
|
457
599
|
continue;
|
|
458
600
|
const content = readFileSync(specFile, 'utf-8');
|
|
459
|
-
// Parse ADDED/MODIFIED Requirements
|
|
460
|
-
const addedMatches = content.matchAll(/^###\s+(Requirement|需求)[::]\s+(.+)$/gm);
|
|
461
|
-
const inAddedSection = content.includes('## ADDED Requirements') || content.includes('## 新增要求');
|
|
462
|
-
const inModifiedSection = content.includes('## MODIFIED Requirements') || content.includes('## 修改要求');
|
|
463
|
-
// Simple parsing: check if requirement is in ADDED or MODIFIED section
|
|
601
|
+
// Parse ADDED/MODIFIED Requirements and their Scenarios
|
|
464
602
|
const lines = content.split('\n');
|
|
465
603
|
let currentSection = '';
|
|
604
|
+
let currentReqName = '';
|
|
605
|
+
let currentReqDescription = '';
|
|
606
|
+
let inScenarioBlock = false;
|
|
607
|
+
let scenarioName = '';
|
|
608
|
+
let scenarioDescription = '';
|
|
609
|
+
const flushScenario = () => {
|
|
610
|
+
if (scenarioName && (currentSection === 'ADDED' || currentSection === 'MODIFIED')) {
|
|
611
|
+
parsedScenarios.push(extractScenarioDimensions(scenarioName, scenarioDescription));
|
|
612
|
+
}
|
|
613
|
+
scenarioName = '';
|
|
614
|
+
scenarioDescription = '';
|
|
615
|
+
};
|
|
616
|
+
const flushRequirement = () => {
|
|
617
|
+
flushScenario();
|
|
618
|
+
// If requirement has no scenarios, treat the requirement itself as a scenario
|
|
619
|
+
if (currentReqName && (currentSection === 'ADDED' || currentSection === 'MODIFIED')) {
|
|
620
|
+
const hasScenarios = parsedScenarios.some(s => s.name.startsWith(currentReqName));
|
|
621
|
+
if (!hasScenarios) {
|
|
622
|
+
parsedScenarios.push(extractScenarioDimensions(currentReqName, currentReqDescription));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
currentReqName = '';
|
|
626
|
+
currentReqDescription = '';
|
|
627
|
+
};
|
|
466
628
|
for (const line of lines) {
|
|
467
|
-
if (line.match(/^##\s+(ADDED|Added|新增)/i))
|
|
629
|
+
if (line.match(/^##\s+(ADDED|Added|新增)/i)) {
|
|
630
|
+
flushRequirement();
|
|
468
631
|
currentSection = 'ADDED';
|
|
469
|
-
|
|
632
|
+
}
|
|
633
|
+
else if (line.match(/^##\s+(MODIFIED|Modified|修改)/i)) {
|
|
634
|
+
flushRequirement();
|
|
470
635
|
currentSection = 'MODIFIED';
|
|
471
|
-
|
|
636
|
+
}
|
|
637
|
+
else if (line.match(/^##\s+(REMOVED|Removed|删除)/i)) {
|
|
638
|
+
flushRequirement();
|
|
472
639
|
currentSection = 'REMOVED';
|
|
473
|
-
|
|
640
|
+
}
|
|
641
|
+
else if (line.match(/^##\s+(RENAMED|Renamed|重命名)/i)) {
|
|
642
|
+
flushRequirement();
|
|
474
643
|
currentSection = 'RENAMED';
|
|
644
|
+
}
|
|
475
645
|
else if (line.match(/^###\s+(Requirement|需求)[::]/i)) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
646
|
+
flushRequirement();
|
|
647
|
+
currentReqName = line.replace(/^###\s+(Requirement|需求)[::]\s+/i, '').trim();
|
|
648
|
+
}
|
|
649
|
+
else if (line.match(/^####\s+(Scenario|场景)[::]/i)) {
|
|
650
|
+
flushScenario();
|
|
651
|
+
scenarioName = line.replace(/^####\s+(Scenario|场景)[::]\s+/i, '').trim();
|
|
652
|
+
inScenarioBlock = true;
|
|
653
|
+
}
|
|
654
|
+
else if (inScenarioBlock && !line.match(/^#{1,4}\s/)) {
|
|
655
|
+
scenarioDescription += ' ' + line;
|
|
656
|
+
}
|
|
657
|
+
else if (line.match(/^#{1,3}\s/)) {
|
|
658
|
+
inScenarioBlock = false;
|
|
659
|
+
}
|
|
660
|
+
// Accumulate requirement description for fallback
|
|
661
|
+
if (currentReqName && !scenarioName && !line.match(/^#{1,4}\s/)) {
|
|
662
|
+
currentReqDescription += ' ' + line;
|
|
480
663
|
}
|
|
481
664
|
}
|
|
665
|
+
flushRequirement();
|
|
482
666
|
}
|
|
483
667
|
}
|
|
484
668
|
catch {
|
|
@@ -486,8 +670,8 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
|
486
670
|
result.passed = true;
|
|
487
671
|
return result;
|
|
488
672
|
}
|
|
489
|
-
// If no requirements found, consider passed
|
|
490
|
-
if (
|
|
673
|
+
// If no scenarios/requirements found, consider passed
|
|
674
|
+
if (parsedScenarios.length === 0) {
|
|
491
675
|
result.passed = true;
|
|
492
676
|
return result;
|
|
493
677
|
}
|
|
@@ -498,33 +682,68 @@ function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
|
498
682
|
result.passed = true;
|
|
499
683
|
return result;
|
|
500
684
|
}
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
685
|
+
// Multi-dimensional matching for each scenario
|
|
686
|
+
const scenarioResults = [];
|
|
687
|
+
for (const scenario of parsedScenarios) {
|
|
688
|
+
const checks = [];
|
|
689
|
+
// Dimension 1: Class/method name matching
|
|
690
|
+
if (scenario.mentionsClass) {
|
|
691
|
+
checks.push(searchCode(repoRoot, scenario.mentionsClass, 'java', touchedFiles));
|
|
692
|
+
}
|
|
693
|
+
// Dimension 2: API path matching
|
|
694
|
+
if (scenario.mentionsApiPath) {
|
|
695
|
+
checks.push(searchCode(repoRoot, scenario.mentionsApiPath, 'java', touchedFiles));
|
|
696
|
+
}
|
|
697
|
+
// Dimension 3: Database table matching
|
|
698
|
+
if (scenario.mentionsTable) {
|
|
699
|
+
checks.push(searchCode(repoRoot, scenario.mentionsTable, 'java', touchedFiles));
|
|
700
|
+
}
|
|
701
|
+
// Dimension 4: UI action matching
|
|
702
|
+
if (scenario.mentionsUIAction) {
|
|
703
|
+
checks.push(searchCode(repoRoot, scenario.mentionsUIAction, 'tsx', touchedFiles));
|
|
704
|
+
}
|
|
705
|
+
// Fallback: keyword-based matching (backward compatible with original logic)
|
|
706
|
+
if (checks.length === 0 && scenario.keywords.length > 0) {
|
|
707
|
+
const keywordMatched = touchedFiles.some(file => {
|
|
708
|
+
const basename = path.basename(file).toLowerCase();
|
|
709
|
+
const filePathLower = file.toLowerCase();
|
|
710
|
+
if (scenario.keywords.some(kw => basename.includes(kw) || filePathLower.includes(kw))) {
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
const fullPath = path.isAbsolute(file) ? file : path.join(repoRoot, file);
|
|
715
|
+
if (!existsSync(fullPath))
|
|
716
|
+
return false;
|
|
717
|
+
const content = readFileSync(fullPath, 'utf-8').toLowerCase();
|
|
718
|
+
return scenario.keywords.some(kw => content.includes(kw));
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
516
721
|
return false;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
checks.push({
|
|
725
|
+
dimension: 'keyword-fallback',
|
|
726
|
+
keyword: scenario.keywords.join(', '),
|
|
727
|
+
fileType: '*',
|
|
728
|
+
found: keywordMatched,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
// Comprehensive judgment: covered if no checks or any check passes
|
|
732
|
+
const covered = checks.length === 0 || checks.some(c => c.found);
|
|
733
|
+
scenarioResults.push({ scenario: scenario.name, covered, checks });
|
|
734
|
+
}
|
|
735
|
+
const coverageRate = calculateCoverageRate(scenarioResults);
|
|
736
|
+
// Report uncovered scenarios as errors
|
|
737
|
+
for (const sr of scenarioResults) {
|
|
738
|
+
if (!sr.covered) {
|
|
739
|
+
const dimensions = sr.checks.map(c => `${c.dimension}:"${c.keyword}"`).join(', ');
|
|
740
|
+
result.errors.push(`Scenario "${sr.scenario}" 未找到对应代码改动 (checked: ${dimensions})`);
|
|
526
741
|
}
|
|
527
742
|
}
|
|
743
|
+
// Add coverage rate to warnings for visibility
|
|
744
|
+
if (scenarioResults.length > 0) {
|
|
745
|
+
result.warnings.push(`Spec-Code coverage: ${coverageRate}% (${scenarioResults.filter(s => s.covered).length}/${scenarioResults.length} scenarios)`);
|
|
746
|
+
}
|
|
528
747
|
result.passed = result.errors.length === 0;
|
|
529
748
|
return result;
|
|
530
749
|
}
|
|
@@ -642,12 +861,331 @@ export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
|
642
861
|
}
|
|
643
862
|
return result;
|
|
644
863
|
}
|
|
864
|
+
/**
|
|
865
|
+
* Rule: tech-spec-coverage
|
|
866
|
+
*
|
|
867
|
+
* When a tech-spec document exists, validates that the technical spec
|
|
868
|
+
* adequately covers the proposal's requirements.
|
|
869
|
+
* This is a basic framework - full logic will be implemented in Task 7's Skill.
|
|
870
|
+
*
|
|
871
|
+
* Tech spec document lookup order:
|
|
872
|
+
* 1. techDesign/tech-spec.md (standard path)
|
|
873
|
+
* 2. tech-spec.md in the proposal root directory
|
|
874
|
+
* 3. 用户提供本地技术方案路径
|
|
875
|
+
*
|
|
876
|
+
* Checks:
|
|
877
|
+
* - tech-spec.md file exists and is non-empty
|
|
878
|
+
* - tech-spec.md contains substantive content (not just placeholder)
|
|
879
|
+
*/
|
|
880
|
+
export function checkTechSpecCoverage(changeDir) {
|
|
881
|
+
const result = {
|
|
882
|
+
ruleId: 'tech-spec-coverage',
|
|
883
|
+
ruleName: 'Tech Spec Coverage',
|
|
884
|
+
passed: false,
|
|
885
|
+
errors: [],
|
|
886
|
+
warnings: [],
|
|
887
|
+
};
|
|
888
|
+
// Look for tech-spec.md from multiple sources
|
|
889
|
+
const candidatePaths = [
|
|
890
|
+
join(changeDir, 'techDesign', 'tech-spec.md'), // Standard path
|
|
891
|
+
join(changeDir, 'tech-spec.md'), // Proposal root directory
|
|
892
|
+
];
|
|
893
|
+
let techSpecPath = null;
|
|
894
|
+
for (const candidate of candidatePaths) {
|
|
895
|
+
if (existsSync(candidate)) {
|
|
896
|
+
techSpecPath = candidate;
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (!techSpecPath) {
|
|
901
|
+
// No tech-spec.md found anywhere — skip check gracefully
|
|
902
|
+
result.passed = true;
|
|
903
|
+
result.warnings.push('No tech-spec.md found (checked techDesign/tech-spec.md and proposal root) — skipping coverage check');
|
|
904
|
+
return result;
|
|
905
|
+
}
|
|
906
|
+
let content;
|
|
907
|
+
try {
|
|
908
|
+
content = readFileSync(techSpecPath, 'utf-8');
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
result.errors.push(`Failed to read ${path.relative(changeDir, techSpecPath)}`);
|
|
912
|
+
return result;
|
|
913
|
+
}
|
|
914
|
+
// Check if content is just the placeholder template
|
|
915
|
+
const stripped = content.replace(/^#.*$/gm, '').replace(/^>.*$/gm, '').replace(/^(created|source):.*$/gm, '').trim();
|
|
916
|
+
if (stripped.length < 50) {
|
|
917
|
+
result.warnings.push('techDesign/tech-spec.md appears to be a placeholder — populate with actual technical spec content');
|
|
918
|
+
result.passed = true;
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
// --- Full coverage validation logic ---
|
|
922
|
+
// 1. Extract key elements from tech-spec.md
|
|
923
|
+
const elements = extractTechSpecElements(content);
|
|
924
|
+
const totalElements = elements.length;
|
|
925
|
+
if (totalElements === 0) {
|
|
926
|
+
result.warnings.push('No structured elements (tables, APIs, classes, configs) detected in tech-spec.md — skipping coverage check');
|
|
927
|
+
result.passed = true;
|
|
928
|
+
return result;
|
|
929
|
+
}
|
|
930
|
+
// 2. Read proposal files for coverage matching
|
|
931
|
+
const proposalContent = readProposalFiles(changeDir);
|
|
932
|
+
// 3. Check coverage of each element
|
|
933
|
+
const uncovered = [];
|
|
934
|
+
for (const el of elements) {
|
|
935
|
+
const covered = isElementCovered(el, proposalContent);
|
|
936
|
+
if (!covered) {
|
|
937
|
+
uncovered.push(el);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// 4. Calculate coverage rate
|
|
941
|
+
const coveredCount = totalElements - uncovered.length;
|
|
942
|
+
const coverageRate = Math.round((coveredCount / totalElements) * 1000) / 10; // one decimal
|
|
943
|
+
// 5. Generate structured auto-fix suggestions report
|
|
944
|
+
if (uncovered.length > 0) {
|
|
945
|
+
// Map element type to suggested target file
|
|
946
|
+
const targetFileMap = {
|
|
947
|
+
database: 'specs/ 对应 spec.md(新增 Requirement)或 tasks.md(新增建表任务)',
|
|
948
|
+
api: 'specs/ 对应 spec.md(新增 API Scenario)',
|
|
949
|
+
class: 'tasks.md(新增实现任务)',
|
|
950
|
+
config: 'design.md 基础设施章节',
|
|
951
|
+
es: 'design.md 基础设施章节或 tasks.md(新增 ES 索引任务)',
|
|
952
|
+
mq: 'design.md 基础设施章节或 tasks.md(新增 MQ 配置任务)',
|
|
953
|
+
frontend: 'tasks.md(新增前端实现任务)',
|
|
954
|
+
};
|
|
955
|
+
const autoFixLines = [
|
|
956
|
+
`Tech spec coverage: ${coverageRate}% (${coveredCount}/${totalElements}).`,
|
|
957
|
+
'以下未覆盖元素将自动补充到提案中:',
|
|
958
|
+
];
|
|
959
|
+
for (const el of uncovered) {
|
|
960
|
+
const target = targetFileMap[el.type] || 'design.md 或 tasks.md';
|
|
961
|
+
autoFixLines.push(`- [${el.type}] ${el.name} → 建议追加到 ${target}`);
|
|
962
|
+
}
|
|
963
|
+
const autoFixMsg = autoFixLines.join('\n');
|
|
964
|
+
// Always treat as warning (auto-fixable), never block
|
|
965
|
+
result.warnings.push(autoFixMsg);
|
|
966
|
+
if (coverageRate < 80) {
|
|
967
|
+
result.warnings.push(`Coverage ${coverageRate}% < 80% — AI 应自动将上述元素补充到对应提案文件,补充后重新检查直到 >= 80%`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// Auto-fix mode: always pass (uncovered elements are auto-fixable, not blocking)
|
|
971
|
+
result.passed = true;
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Extract structured elements from tech-spec.md content.
|
|
976
|
+
* Uses simple regex patterns — not a full parser.
|
|
977
|
+
*/
|
|
978
|
+
function extractTechSpecElements(content) {
|
|
979
|
+
const elements = [];
|
|
980
|
+
const seen = new Set();
|
|
981
|
+
const addElement = (type, name) => {
|
|
982
|
+
const key = `${type}:${name}`;
|
|
983
|
+
if (!seen.has(key) && name.length > 1) {
|
|
984
|
+
seen.add(key);
|
|
985
|
+
elements.push({ type, name });
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
// Database tables: CREATE TABLE / ALTER TABLE / t_xxx / table names in DDL sections
|
|
989
|
+
const tablePatterns = [
|
|
990
|
+
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w.]+)`?/gi,
|
|
991
|
+
/ALTER\s+TABLE\s+`?([\w.]+)`?/gi,
|
|
992
|
+
/\b(t_[a-z][a-z0-9_]{2,})\b/g,
|
|
993
|
+
];
|
|
994
|
+
for (const pat of tablePatterns) {
|
|
995
|
+
let m;
|
|
996
|
+
while ((m = pat.exec(content)) !== null) {
|
|
997
|
+
addElement('database', m[1]);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// API / interface paths
|
|
1001
|
+
const apiPatterns = [
|
|
1002
|
+
/(?:@(?:Request|Get|Post|Put|Delete|Patch)Mapping)\s*\(\s*["']([^"']+)["']/gi,
|
|
1003
|
+
/(?:path|url|endpoint)["'\s:=]+["']?(\/api\/[\w/{}.-]+)/gi,
|
|
1004
|
+
/\b(\/api\/[\w/{}.-]+)\b/g,
|
|
1005
|
+
/(?:SCF|RPC|HTTP|REST)\s+.*?["'`]([\w/.]+)["'`]/gi,
|
|
1006
|
+
];
|
|
1007
|
+
for (const pat of apiPatterns) {
|
|
1008
|
+
let m;
|
|
1009
|
+
while ((m = pat.exec(content)) !== null) {
|
|
1010
|
+
addElement('api', m[1]);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Class / interface / module names
|
|
1014
|
+
const classPatterns = [
|
|
1015
|
+
/\b(?:class|interface|enum)\s+([A-Z][A-Za-z0-9]+)/g,
|
|
1016
|
+
];
|
|
1017
|
+
for (const pat of classPatterns) {
|
|
1018
|
+
let m;
|
|
1019
|
+
while ((m = pat.exec(content)) !== null) {
|
|
1020
|
+
addElement('class', m[1]);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// Config items: apollo.xxx / spring.xxx / application.yml keys
|
|
1024
|
+
const configPatterns = [
|
|
1025
|
+
/\b(apollo\.[a-zA-Z0-9._-]+)/g,
|
|
1026
|
+
/\b(spring\.[a-zA-Z0-9._-]+)/g,
|
|
1027
|
+
/\b(application\.[a-zA-Z0-9._-]+)/g,
|
|
1028
|
+
];
|
|
1029
|
+
for (const pat of configPatterns) {
|
|
1030
|
+
let m;
|
|
1031
|
+
while ((m = pat.exec(content)) !== null) {
|
|
1032
|
+
addElement('config', m[1]);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// ES indexes
|
|
1036
|
+
const esPatterns = [
|
|
1037
|
+
/(?:index|mapping)["'\s:=]+["']?([a-z][a-z0-9_-]*_index[a-z0-9_-]*)/gi,
|
|
1038
|
+
/\b([a-z][a-z0-9_]*_index)\b/g,
|
|
1039
|
+
];
|
|
1040
|
+
for (const pat of esPatterns) {
|
|
1041
|
+
let m;
|
|
1042
|
+
while ((m = pat.exec(content)) !== null) {
|
|
1043
|
+
addElement('es', m[1]);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
// MQ topics / consumers
|
|
1047
|
+
const mqPatterns = [
|
|
1048
|
+
/(?:topic|Topic)["'\s:=]+["']?([A-Z][A-Z0-9_]+)/g,
|
|
1049
|
+
/\b(\w+Consumer)\b/g,
|
|
1050
|
+
/\b(\w+Producer)\b/g,
|
|
1051
|
+
];
|
|
1052
|
+
for (const pat of mqPatterns) {
|
|
1053
|
+
let m;
|
|
1054
|
+
while ((m = pat.exec(content)) !== null) {
|
|
1055
|
+
addElement('mq', m[1]);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// Frontend components / routes
|
|
1059
|
+
const fePatterns = [
|
|
1060
|
+
/\b(\w+(?:Page|View|Component|Modal|Dialog))\b/g,
|
|
1061
|
+
/(?:route|path)["'\s:=]+["'](\/[\w/:.-]+)/gi,
|
|
1062
|
+
];
|
|
1063
|
+
for (const pat of fePatterns) {
|
|
1064
|
+
let m;
|
|
1065
|
+
while ((m = pat.exec(content)) !== null) {
|
|
1066
|
+
addElement('frontend', m[1]);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return elements;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Read all proposal files (proposal.md, design.md, specs/*.md, tasks.md)
|
|
1073
|
+
* and concatenate their content for coverage matching.
|
|
1074
|
+
*/
|
|
1075
|
+
function readProposalFiles(changeDir) {
|
|
1076
|
+
const parts = [];
|
|
1077
|
+
const filesToRead = ['proposal.md', 'design.md', 'tasks.md'];
|
|
1078
|
+
for (const f of filesToRead) {
|
|
1079
|
+
const fp = join(changeDir, f);
|
|
1080
|
+
try {
|
|
1081
|
+
if (existsSync(fp)) {
|
|
1082
|
+
parts.push(readFileSync(fp, 'utf-8'));
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
catch {
|
|
1086
|
+
// graceful skip
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Read specs/*.md
|
|
1090
|
+
const specsDir = join(changeDir, 'specs');
|
|
1091
|
+
try {
|
|
1092
|
+
if (existsSync(specsDir)) {
|
|
1093
|
+
const specDirs = readdirSync(specsDir, { withFileTypes: true });
|
|
1094
|
+
for (const entry of specDirs) {
|
|
1095
|
+
if (entry.isDirectory()) {
|
|
1096
|
+
const specFile = join(specsDir, entry.name, 'spec.md');
|
|
1097
|
+
try {
|
|
1098
|
+
if (existsSync(specFile)) {
|
|
1099
|
+
parts.push(readFileSync(specFile, 'utf-8'));
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
// graceful skip
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
// graceful skip
|
|
1111
|
+
}
|
|
1112
|
+
return parts.join('\n');
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Check if a tech spec element is covered in the proposal content.
|
|
1116
|
+
* Uses case-insensitive matching with the element name.
|
|
1117
|
+
*/
|
|
1118
|
+
function isElementCovered(element, proposalContent) {
|
|
1119
|
+
const name = element.name;
|
|
1120
|
+
// Exact match (case-insensitive)
|
|
1121
|
+
if (proposalContent.toLowerCase().includes(name.toLowerCase())) {
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
// For database tables: also try without the t_ prefix
|
|
1125
|
+
if (element.type === 'database' && name.startsWith('t_')) {
|
|
1126
|
+
const withoutPrefix = name.substring(2);
|
|
1127
|
+
if (proposalContent.toLowerCase().includes(withoutPrefix.toLowerCase())) {
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// For API paths: try matching the last segment
|
|
1132
|
+
if (element.type === 'api' && name.includes('/')) {
|
|
1133
|
+
const segments = name.split('/').filter(Boolean);
|
|
1134
|
+
const lastSegment = segments[segments.length - 1];
|
|
1135
|
+
if (lastSegment && !lastSegment.startsWith('{') && proposalContent.toLowerCase().includes(lastSegment.toLowerCase())) {
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
// For class names: try matching camelCase split words
|
|
1140
|
+
if (element.type === 'class') {
|
|
1141
|
+
// Split PascalCase into words: e.g. OrderRefundService -> [Order, Refund, Service]
|
|
1142
|
+
const words = name.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ').filter(w => w.length > 2);
|
|
1143
|
+
// If most words appear in proposal, consider it covered
|
|
1144
|
+
if (words.length >= 2) {
|
|
1145
|
+
const matchedWords = words.filter(w => proposalContent.toLowerCase().includes(w.toLowerCase()));
|
|
1146
|
+
if (matchedWords.length >= Math.ceil(words.length * 0.6)) {
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Rule: task-id-format
|
|
1155
|
+
*
|
|
1156
|
+
* Validates that all task IDs use the N.M format (e.g., 1.1, 2.3).
|
|
1157
|
+
* Rejects non-standard formats like 3.2a, 1.1.1, abc, etc.
|
|
1158
|
+
*/
|
|
1159
|
+
const TASK_ID_PATTERN = /^\d+\.\d+$/;
|
|
1160
|
+
export function checkTaskIdFormat(tasksContent) {
|
|
1161
|
+
const result = {
|
|
1162
|
+
ruleId: 'task-id-format',
|
|
1163
|
+
ruleName: 'Task ID format',
|
|
1164
|
+
passed: false,
|
|
1165
|
+
errors: [],
|
|
1166
|
+
warnings: [],
|
|
1167
|
+
};
|
|
1168
|
+
const taskLines = tasksContent.match(/^- \[[ xX]\] (\S+) /gm) || [];
|
|
1169
|
+
const invalidIds = [];
|
|
1170
|
+
for (const line of taskLines) {
|
|
1171
|
+
const match = line.match(/^- \[[ xX]\] (\S+)/);
|
|
1172
|
+
if (match && !TASK_ID_PATTERN.test(match[1])) {
|
|
1173
|
+
invalidIds.push(match[1]);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (invalidIds.length > 0) {
|
|
1177
|
+
result.errors = invalidIds.map(id => `Invalid task ID "${id}": must be N.M format (e.g., 1.1, 2.3)`);
|
|
1178
|
+
}
|
|
1179
|
+
result.passed = invalidIds.length === 0;
|
|
1180
|
+
return result;
|
|
1181
|
+
}
|
|
645
1182
|
/**
|
|
646
1183
|
* Main entry: Run all strict validation rules
|
|
647
1184
|
*/
|
|
648
1185
|
export async function runStrictValidation(tasksContent, parsedTasks, knownSkillNames, options) {
|
|
649
1186
|
const hasTestCase = options?.hasTestCase ?? false;
|
|
650
1187
|
const checks = [
|
|
1188
|
+
checkTestCaseInquiryLogged(tasksContent),
|
|
651
1189
|
checkPreClarification(tasksContent),
|
|
652
1190
|
checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames),
|
|
653
1191
|
checkTaskOrderingByWave(tasksContent, parsedTasks),
|
|
@@ -655,11 +1193,20 @@ export async function runStrictValidation(tasksContent, parsedTasks, knownSkillN
|
|
|
655
1193
|
// review-gate-defined rule removed: review gate is enforced by the Review phase skill, not tasks.md
|
|
656
1194
|
checkFileConflicts(parsedTasks),
|
|
657
1195
|
checkTestCaseCoverage(tasksContent, hasTestCase),
|
|
1196
|
+
checkTaskIdFormat(tasksContent),
|
|
658
1197
|
];
|
|
659
1198
|
// Add new rules if parameters are provided
|
|
660
1199
|
if (options?.proposalContent) {
|
|
661
1200
|
checks.push(checkProposalFormatValid(options.proposalContent));
|
|
662
1201
|
}
|
|
1202
|
+
// Add tech-spec-coverage rule if any tech spec document exists
|
|
1203
|
+
if (options?.changeDir) {
|
|
1204
|
+
const hasTechSpec = existsSync(join(options.changeDir, 'techDesign', 'tech-spec.md')) ||
|
|
1205
|
+
existsSync(join(options.changeDir, 'tech-spec.md'));
|
|
1206
|
+
if (hasTechSpec) {
|
|
1207
|
+
checks.push(checkTechSpecCoverage(options.changeDir));
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
663
1210
|
// Detect current phase for spec-code consistency check
|
|
664
1211
|
if (options?.specsDir && options?.repoRoot) {
|
|
665
1212
|
const phase = options?.changeDir ? detectPhase(options.changeDir) : 'apply';
|