@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.
@@ -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 weak signal matching: requirement name keywords should appear in touched files.
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 requirements = [];
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 (not REMOVED)
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
- else if (line.match(/^##\s+(MODIFIED|Modified|修改)/i))
632
+ }
633
+ else if (line.match(/^##\s+(MODIFIED|Modified|修改)/i)) {
634
+ flushRequirement();
470
635
  currentSection = 'MODIFIED';
471
- else if (line.match(/^##\s+(REMOVED|Removed|删除)/i))
636
+ }
637
+ else if (line.match(/^##\s+(REMOVED|Removed|删除)/i)) {
638
+ flushRequirement();
472
639
  currentSection = 'REMOVED';
473
- else if (line.match(/^##\s+(RENAMED|Renamed|重命名)/i))
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
- const reqName = line.replace(/^###\s+(Requirement|需求)[::]\s+/i, '').trim();
477
- if (currentSection === 'ADDED' || currentSection === 'MODIFIED') {
478
- requirements.push({ name: reqName, operation: currentSection });
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 (requirements.length === 0) {
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
- // Check each requirement against touched files
502
- for (const req of requirements) {
503
- const normalized = req.name.toLowerCase();
504
- const keywords = normalized.split(/\s+/).filter(w => w.length > 3);
505
- const matched = touchedFiles.some(file => {
506
- const basename = path.basename(file).toLowerCase();
507
- const filePathLower = file.toLowerCase();
508
- const filenameMatched = keywords.some((kw) => basename.includes(kw) || filePathLower.includes(kw));
509
- if (filenameMatched) {
510
- return true;
511
- }
512
- try {
513
- // Support both absolute paths (from GitRepoDetector) and relative paths
514
- const fullPath = path.isAbsolute(file) ? file : path.join(repoRoot, file);
515
- if (!existsSync(fullPath))
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
- const content = readFileSync(fullPath, 'utf-8').toLowerCase();
518
- return keywords.some((kw) => content.includes(kw));
519
- }
520
- catch {
521
- return false;
522
- }
523
- });
524
- if (!matched && touchedFiles.length > 0) {
525
- result.errors.push(`Requirement "${req.name}" (${req.operation}) 未找到对应代码改动`);
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';