@zhuan-ai/zhuanspec 2.2.4 → 2.4.8
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/bin/zhuanspec-hook.js +3 -0
- package/dist/cli/hooks.d.ts +14 -0
- package/dist/cli/hooks.js +465 -0
- package/dist/cli/index.js +100 -0
- package/dist/commands/artifact-workflow.js +15 -34
- package/dist/commands/design.d.ts +42 -0
- package/dist/commands/design.js +337 -0
- package/dist/commands/progress.d.ts +32 -0
- package/dist/commands/progress.js +278 -0
- package/dist/commands/review.d.ts +32 -0
- package/dist/commands/review.js +472 -0
- package/dist/commands/validate.d.ts +14 -0
- package/dist/commands/validate.js +161 -10
- package/dist/core/archive.d.ts +1 -0
- package/dist/core/archive.js +45 -3
- package/dist/core/completions/command-registry.js +67 -0
- package/dist/core/configurators/slash/amazon-q.js +32 -2
- package/dist/core/configurators/slash/antigravity.js +8 -2
- package/dist/core/configurators/slash/auggie.js +16 -1
- package/dist/core/configurators/slash/base.js +1 -1
- package/dist/core/configurators/slash/claude.d.ts +4 -0
- package/dist/core/configurators/slash/claude.js +56 -1
- package/dist/core/configurators/slash/cline.js +8 -2
- package/dist/core/configurators/slash/codebuddy.js +22 -1
- package/dist/core/configurators/slash/codex.js +21 -0
- package/dist/core/configurators/slash/costrict.js +15 -0
- package/dist/core/configurators/slash/crush.js +22 -1
- package/dist/core/configurators/slash/cursor.js +22 -1
- package/dist/core/configurators/slash/factory.js +16 -1
- package/dist/core/configurators/slash/gemini.js +8 -2
- package/dist/core/configurators/slash/github-copilot.js +19 -1
- package/dist/core/configurators/slash/iflow.js +22 -1
- package/dist/core/configurators/slash/kilocode.js +4 -1
- package/dist/core/configurators/slash/opencode.js +27 -0
- package/dist/core/configurators/slash/qoder.d.ts +4 -0
- package/dist/core/configurators/slash/qoder.js +59 -1
- package/dist/core/configurators/slash/qwen.js +8 -2
- package/dist/core/configurators/slash/roocode.js +8 -2
- package/dist/core/configurators/slash/windsurf.js +8 -2
- package/dist/core/dashboard/metrics.d.ts +33 -0
- package/dist/core/dashboard/metrics.js +114 -0
- package/dist/core/hooks/collect-knowledge.d.ts +16 -0
- package/dist/core/hooks/collect-knowledge.js +203 -0
- package/dist/core/hooks/context-load-hook.d.ts +25 -0
- package/dist/core/hooks/context-load-hook.js +159 -0
- package/dist/core/hooks/deviation-check.d.ts +27 -0
- package/dist/core/hooks/deviation-check.js +403 -0
- package/dist/core/hooks/deviation-handler.d.ts +43 -0
- package/dist/core/hooks/deviation-handler.js +98 -0
- package/dist/core/hooks/init.d.ts +14 -0
- package/dist/core/hooks/init.js +244 -0
- package/dist/core/hooks/notify-milestone.d.ts +14 -0
- package/dist/core/hooks/notify-milestone.js +170 -0
- package/dist/core/hooks/post-apply.d.ts +29 -0
- package/dist/core/hooks/post-apply.js +173 -0
- package/dist/core/hooks/post-archive.d.ts +7 -0
- package/dist/core/hooks/post-archive.js +208 -0
- package/dist/core/hooks/pre-apply.d.ts +34 -0
- package/dist/core/hooks/pre-apply.js +139 -0
- package/dist/core/hooks/pre-archive.d.ts +7 -0
- package/dist/core/hooks/pre-archive.js +50 -0
- package/dist/core/hooks/record-progress.d.ts +49 -0
- package/dist/core/hooks/record-progress.js +494 -0
- package/dist/core/hooks/review-hooks.d.ts +89 -0
- package/dist/core/hooks/review-hooks.js +345 -0
- package/dist/core/hooks/review-orchestrator.d.ts +40 -0
- package/dist/core/hooks/review-orchestrator.js +146 -0
- package/dist/core/hooks/summarize.d.ts +15 -0
- package/dist/core/hooks/summarize.js +282 -0
- package/dist/core/hooks/user-input-hook.d.ts +25 -0
- package/dist/core/hooks/user-input-hook.js +179 -0
- package/dist/core/init.d.ts +8 -0
- package/dist/core/init.js +251 -23
- package/dist/core/parsers/requirement-blocks.js +13 -10
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +510 -243
- package/dist/core/templates/index.d.ts +1 -0
- package/dist/core/templates/index.js +1 -0
- package/dist/core/templates/skill-templates.js +46 -152
- package/dist/core/templates/slash-command-templates.d.ts +1 -1
- package/dist/core/templates/slash-command-templates.js +352 -20
- package/dist/core/templates/tasks-template.d.ts +7 -0
- package/dist/core/templates/tasks-template.js +130 -24
- package/dist/core/templates/tdd-tasks-template.d.ts +3 -0
- package/dist/core/templates/tdd-tasks-template.js +91 -38
- package/dist/core/templates/test-cases-template.d.ts +41 -0
- package/dist/core/templates/test-cases-template.js +128 -0
- package/dist/core/validation/strict-rules.d.ts +44 -5
- package/dist/core/validation/strict-rules.js +302 -8
- package/dist/core/validation/validator.js +52 -2
- package/dist/core/view.d.ts +1 -0
- package/dist/core/view.js +60 -2
- package/dist/mcp/index.d.ts +28 -0
- package/dist/mcp/index.js +31 -0
- package/dist/utils/file-system.d.ts +1 -0
- package/dist/utils/file-system.js +11 -0
- package/dist/utils/item-discovery.js +24 -2
- package/dist/utils/phase-utils.d.ts +36 -0
- package/dist/utils/phase-utils.js +117 -0
- package/package.json +22 -23
- package/schemas/spec-driven/schema.yaml +45 -31
- package/schemas/spec-driven/templates/spec.md +142 -5
- package/schemas/spec-driven/templates/tasks.md +73 -9
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Strict Validation Rules
|
|
3
3
|
*
|
|
4
|
-
* Implementation of
|
|
5
|
-
* 1. pre-clarification-completed: Check Pre-Clarification Log section
|
|
6
|
-
* 2. skill-tags-valid: Validate @skill tags against known skills
|
|
7
|
-
* 3. task-ordering-by-wave: Verify task ordering follows wave structure
|
|
4
|
+
* Implementation of strict validation rules for `zhuanspec validate --strict`.
|
|
8
5
|
*/
|
|
9
6
|
import { generateExecutionPlan } from '../task-graph/execution-planner.js';
|
|
10
7
|
import { renderExecutionPlanXml } from '../task-graph/xml-renderer.js';
|
|
8
|
+
import { readdirSync, existsSync, readFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import path from 'path';
|
|
11
12
|
// Patterns for detecting unresolved markers
|
|
12
13
|
const UNRESOLVED_MARKERS = /\b(TODO|TBD|PENDING|\?)\b/i;
|
|
13
14
|
const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
|
|
14
|
-
const PRE_CLARIFICATION_SECTION_PATTERN = /^##\s+Pre-Clarification\s+Log\s*$/im;
|
|
15
|
+
const PRE_CLARIFICATION_SECTION_PATTERN = /^##\s+(Pre-Clarification\s+Log|澄清日志)\s*$/im;
|
|
15
16
|
const VALID_COMPLETION_MARKERS = [
|
|
16
17
|
/no\s+clarification\s+needed/i,
|
|
17
18
|
/status:\s*completed/i,
|
|
@@ -102,7 +103,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
102
103
|
// a. Check Skill Mapping section exists with table data
|
|
103
104
|
const sectionMatch = tasksContent.match(SKILL_MAPPING_SECTION_PATTERN);
|
|
104
105
|
if (!sectionMatch) {
|
|
105
|
-
result.
|
|
106
|
+
result.errors.push('Skill Mapping section not found');
|
|
106
107
|
}
|
|
107
108
|
else {
|
|
108
109
|
// Extract section content (from header to next ## or end)
|
|
@@ -125,7 +126,7 @@ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames)
|
|
|
125
126
|
});
|
|
126
127
|
// Need at least 2 rows (header + 1 data row)
|
|
127
128
|
if (!dataRows || dataRows.length < 2) {
|
|
128
|
-
result.
|
|
129
|
+
result.errors.push('Skill Mapping table has no data rows');
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
// If no tasks, consider it valid
|
|
@@ -336,16 +337,309 @@ export function checkSubagentDirectives(parsedTasks) {
|
|
|
336
337
|
result.passed = result.errors.length === 0;
|
|
337
338
|
return result;
|
|
338
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Rule 5: review-gate-defined
|
|
342
|
+
*
|
|
343
|
+
* Tasks must explicitly mention zhuanspec review gate.
|
|
344
|
+
*/
|
|
345
|
+
export function checkReviewGateDefined(tasksContent) {
|
|
346
|
+
const result = {
|
|
347
|
+
ruleId: 'review-gate-defined',
|
|
348
|
+
ruleName: 'Review gate defined',
|
|
349
|
+
passed: false,
|
|
350
|
+
errors: [],
|
|
351
|
+
warnings: [],
|
|
352
|
+
};
|
|
353
|
+
const hasReviewGate = tasksContent.includes('zhuanspec review') ||
|
|
354
|
+
/review\s*gate/i.test(tasksContent) ||
|
|
355
|
+
/双轨审查/.test(tasksContent);
|
|
356
|
+
if (!hasReviewGate) {
|
|
357
|
+
result.errors.push('tasks.md must include a zhuanspec review gate before completion');
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
result.passed = true;
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Rule 7: proposal-format-valid
|
|
365
|
+
*
|
|
366
|
+
* Validates that proposal.md contains required sections:
|
|
367
|
+
* - Why/变更原因
|
|
368
|
+
* - What Changes/变更内容
|
|
369
|
+
* - Impact/影响范围
|
|
370
|
+
*/
|
|
371
|
+
export function checkProposalFormatValid(proposalContent) {
|
|
372
|
+
const result = {
|
|
373
|
+
ruleId: 'proposal-format-valid',
|
|
374
|
+
ruleName: 'Proposal format valid',
|
|
375
|
+
passed: false,
|
|
376
|
+
errors: [],
|
|
377
|
+
warnings: [],
|
|
378
|
+
};
|
|
379
|
+
const requiredSections = [
|
|
380
|
+
{ pattern: /^##\s+(变更原因|Why)/im, name: 'Why/变更原因' },
|
|
381
|
+
{ pattern: /^##\s+(变更内容|What\s+Changes)/im, name: 'What Changes/变更内容' },
|
|
382
|
+
{ pattern: /^##\s+(影响范围|Impact)/im, name: 'Impact/影响范围' },
|
|
383
|
+
];
|
|
384
|
+
for (const section of requiredSections) {
|
|
385
|
+
if (!section.pattern.test(proposalContent)) {
|
|
386
|
+
result.errors.push(`Proposal 缺少必需章节:${section.name}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
result.passed = result.errors.length === 0;
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Detect current workflow phase based on progress.json.
|
|
394
|
+
*
|
|
395
|
+
* Phase detection logic:
|
|
396
|
+
* 1. If progress.json doesn't exist -> Propose phase
|
|
397
|
+
* 2. If progress.json exists with phase field -> use that phase
|
|
398
|
+
* 3. If progress.json exists without phase field -> Apply phase (default)
|
|
399
|
+
*/
|
|
400
|
+
export function detectPhase(changeDir) {
|
|
401
|
+
const progressPath = join(changeDir, 'metrics', 'progress.json');
|
|
402
|
+
// If no progress.json, we're in Propose phase
|
|
403
|
+
if (!existsSync(progressPath)) {
|
|
404
|
+
return 'propose';
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const progressContent = readFileSync(progressPath, 'utf-8');
|
|
408
|
+
const progress = JSON.parse(progressContent);
|
|
409
|
+
// If phase field exists, use it
|
|
410
|
+
if (progress.phase) {
|
|
411
|
+
return progress.phase;
|
|
412
|
+
}
|
|
413
|
+
// Default to Apply phase if progress.json exists but no phase field
|
|
414
|
+
return 'apply';
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// If can't read/parse progress.json, assume Propose phase
|
|
418
|
+
return 'propose';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Rule 8: spec-code-consistent
|
|
423
|
+
*
|
|
424
|
+
* Validates that Requirements in delta specs have corresponding code changes.
|
|
425
|
+
* Uses weak signal matching: requirement name keywords should appear in touched files.
|
|
426
|
+
*
|
|
427
|
+
* SKIPPED in Propose phase (no code changes yet).
|
|
428
|
+
*/
|
|
429
|
+
function checkSpecCodeConsistent(specsDir, repoRoot, phase) {
|
|
430
|
+
const result = {
|
|
431
|
+
ruleId: 'spec-code-consistent',
|
|
432
|
+
ruleName: 'Spec-Code consistency',
|
|
433
|
+
passed: false,
|
|
434
|
+
errors: [],
|
|
435
|
+
warnings: [],
|
|
436
|
+
};
|
|
437
|
+
// Skip this check in Propose phase
|
|
438
|
+
if (phase === 'propose') {
|
|
439
|
+
result.passed = true;
|
|
440
|
+
result.warnings.push('SKIPPED (Propose phase) - no code changes to validate');
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
// If specsDir doesn't exist, skip this check
|
|
444
|
+
if (!specsDir) {
|
|
445
|
+
result.passed = true;
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
// Collect requirements from delta specs
|
|
449
|
+
const requirements = [];
|
|
450
|
+
try {
|
|
451
|
+
const specDirs = readdirSync(specsDir, { withFileTypes: true });
|
|
452
|
+
for (const entry of specDirs) {
|
|
453
|
+
if (!entry.isDirectory())
|
|
454
|
+
continue;
|
|
455
|
+
const specFile = join(specsDir, entry.name, 'spec.md');
|
|
456
|
+
if (!existsSync(specFile))
|
|
457
|
+
continue;
|
|
458
|
+
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
|
|
464
|
+
const lines = content.split('\n');
|
|
465
|
+
let currentSection = '';
|
|
466
|
+
for (const line of lines) {
|
|
467
|
+
if (line.match(/^##\s+(ADDED|Added|新增)/i))
|
|
468
|
+
currentSection = 'ADDED';
|
|
469
|
+
else if (line.match(/^##\s+(MODIFIED|Modified|修改)/i))
|
|
470
|
+
currentSection = 'MODIFIED';
|
|
471
|
+
else if (line.match(/^##\s+(REMOVED|Removed|删除)/i))
|
|
472
|
+
currentSection = 'REMOVED';
|
|
473
|
+
else if (line.match(/^##\s+(RENAMED|Renamed|重命名)/i))
|
|
474
|
+
currentSection = 'RENAMED';
|
|
475
|
+
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
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// Directory read error, skip
|
|
486
|
+
result.passed = true;
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
// If no requirements found, consider passed
|
|
490
|
+
if (requirements.length === 0) {
|
|
491
|
+
result.passed = true;
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
// Get git diff files
|
|
495
|
+
let touchedFiles = [];
|
|
496
|
+
try {
|
|
497
|
+
const gitOutput = execSync('git diff --name-only', {
|
|
498
|
+
cwd: repoRoot,
|
|
499
|
+
encoding: 'utf-8',
|
|
500
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
501
|
+
});
|
|
502
|
+
touchedFiles = gitOutput.trim().split('\n').filter(Boolean);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// Git command failed, try git status for uncommitted changes
|
|
506
|
+
try {
|
|
507
|
+
const statusOutput = execSync('git status --porcelain', {
|
|
508
|
+
cwd: repoRoot,
|
|
509
|
+
encoding: 'utf-8',
|
|
510
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
511
|
+
});
|
|
512
|
+
touchedFiles = statusOutput.trim().split('\n')
|
|
513
|
+
.map(line => line.replace(/^\s*[A-Z]+\s+/, '').trim())
|
|
514
|
+
.filter(Boolean);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// No git info available, pass the check
|
|
518
|
+
result.passed = true;
|
|
519
|
+
return result;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Check each requirement against touched files
|
|
523
|
+
for (const req of requirements) {
|
|
524
|
+
const normalized = req.name.toLowerCase();
|
|
525
|
+
const keywords = normalized.split(/\s+/).filter(w => w.length > 3);
|
|
526
|
+
const matched = touchedFiles.some(file => {
|
|
527
|
+
const basename = path.basename(file).toLowerCase();
|
|
528
|
+
const filePathLower = file.toLowerCase();
|
|
529
|
+
const filenameMatched = keywords.some((kw) => basename.includes(kw) || filePathLower.includes(kw));
|
|
530
|
+
if (filenameMatched) {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const fullPath = path.join(repoRoot, file);
|
|
535
|
+
if (!existsSync(fullPath))
|
|
536
|
+
return false;
|
|
537
|
+
const content = readFileSync(fullPath, 'utf-8').toLowerCase();
|
|
538
|
+
return keywords.some((kw) => content.includes(kw));
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
if (!matched && touchedFiles.length > 0) {
|
|
545
|
+
result.errors.push(`Requirement "${req.name}" (${req.operation}) 未找到对应代码改动`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
result.passed = result.errors.length === 0;
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Rule: test-case-coverage
|
|
553
|
+
* Validates that tasks.md has test case coverage section.
|
|
554
|
+
* Behavior differs based on hasTestCase mode:
|
|
555
|
+
* - hasTestCase=true (TDD or user provided cases): REQUIRED, blocking if missing
|
|
556
|
+
* - hasTestCase=false (normal spec-driven): SKIPPED, warning only
|
|
557
|
+
*
|
|
558
|
+
* Table format validation (when hasTestCase=true):
|
|
559
|
+
* - Must have four columns: Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景
|
|
560
|
+
* - Column headers must match expected format
|
|
561
|
+
*/
|
|
562
|
+
export function checkTestCaseCoverage(tasksContent, hasTestCase = false) {
|
|
563
|
+
const result = {
|
|
564
|
+
ruleId: 'test-case-coverage',
|
|
565
|
+
ruleName: 'Test case coverage',
|
|
566
|
+
passed: false,
|
|
567
|
+
errors: [],
|
|
568
|
+
warnings: [],
|
|
569
|
+
};
|
|
570
|
+
// If no test case mode, skip with warning
|
|
571
|
+
if (!hasTestCase) {
|
|
572
|
+
result.passed = true;
|
|
573
|
+
result.warnings.push('SKIPPED (no test case mode) - test-case-coverage check only applies when test cases are provided');
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
// Check for Test Case Coverage section (required when hasTestCase=true)
|
|
577
|
+
const testCaseSectionPattern = /^##\s+Test\s+Case\s+Coverage/im;
|
|
578
|
+
if (!testCaseSectionPattern.test(tasksContent)) {
|
|
579
|
+
result.errors.push('Missing Test Case Coverage section - REQUIRED when test cases are provided (TDD mode or user-provided cases)');
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
// Check for @test-case tags in tasks
|
|
583
|
+
const testCaseTagPattern = /@test-case:TC-\d+/g;
|
|
584
|
+
const testCaseTags = tasksContent.match(testCaseTagPattern);
|
|
585
|
+
if (testCaseTags && testCaseTags.length > 0) {
|
|
586
|
+
result.passed = true;
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
result.errors.push('No @test-case tags found - REQUIRED when test cases are provided. Link tasks to test cases using @test-case:TC-XXX');
|
|
590
|
+
}
|
|
591
|
+
// Validate Test Case Coverage table format (four columns required)
|
|
592
|
+
// Expected headers: Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景
|
|
593
|
+
const tablePattern = /^##\s+Test\s+Case\s+Coverage\s*\n\n(?:<!--[^>]*-->\s*\n)?\|.*\|.*\|.*\|.*\|/m;
|
|
594
|
+
const tableMatch = tasksContent.match(tablePattern);
|
|
595
|
+
if (tableMatch) {
|
|
596
|
+
// Extract header row and validate columns
|
|
597
|
+
const headerLine = tableMatch[0].split('\n').find(line => line.startsWith('|') && !line.includes('<!--'));
|
|
598
|
+
if (headerLine) {
|
|
599
|
+
const columns = headerLine.split('|').filter(col => col.trim() !== '');
|
|
600
|
+
if (columns.length < 4) {
|
|
601
|
+
result.warnings.push(`Test Case Coverage table should have 4 columns (Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景), found ${columns.length}`);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
// Check column names contain expected keywords
|
|
605
|
+
const headerText = headerLine.toLowerCase();
|
|
606
|
+
const hasTaskCol = headerText.includes('task');
|
|
607
|
+
const hasTestCaseIdCol = headerText.includes('测试 case id') || headerText.includes('test case') || headerText.includes('tc');
|
|
608
|
+
const hasPathCol = headerText.includes('文件路径') || headerText.includes('path') || headerText.includes('路径');
|
|
609
|
+
const hasScenarioCol = headerText.includes('覆盖场景') || headerText.includes('场景') || headerText.includes('scenario');
|
|
610
|
+
if (!hasTaskCol || !hasTestCaseIdCol || !hasPathCol || !hasScenarioCol) {
|
|
611
|
+
result.warnings.push('Test Case Coverage table headers should follow format: Task, 测试 Case ID, 测试 Case 文件路径, 覆盖场景');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
result.warnings.push('Test Case Coverage table format not detected - ensure table has 4 columns with proper headers');
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
}
|
|
339
621
|
/**
|
|
340
622
|
* Main entry: Run all strict validation rules
|
|
341
623
|
*/
|
|
342
|
-
export async function runStrictValidation(tasksContent, parsedTasks, knownSkillNames) {
|
|
624
|
+
export async function runStrictValidation(tasksContent, parsedTasks, knownSkillNames, options) {
|
|
625
|
+
const hasTestCase = options?.hasTestCase ?? false;
|
|
343
626
|
const checks = [
|
|
344
627
|
checkPreClarification(tasksContent),
|
|
345
628
|
checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames),
|
|
346
629
|
checkTaskOrderingByWave(tasksContent, parsedTasks),
|
|
347
630
|
checkSubagentDirectives(parsedTasks),
|
|
631
|
+
checkReviewGateDefined(tasksContent),
|
|
632
|
+
checkTestCaseCoverage(tasksContent, hasTestCase),
|
|
348
633
|
];
|
|
634
|
+
// Add new rules if parameters are provided
|
|
635
|
+
if (options?.proposalContent) {
|
|
636
|
+
checks.push(checkProposalFormatValid(options.proposalContent));
|
|
637
|
+
}
|
|
638
|
+
// Detect current phase for spec-code consistency check
|
|
639
|
+
if (options?.specsDir && options?.repoRoot) {
|
|
640
|
+
const phase = options?.changeDir ? detectPhase(options.changeDir) : 'apply';
|
|
641
|
+
checks.push(checkSpecCodeConsistent(options.specsDir, options.repoRoot, phase));
|
|
642
|
+
}
|
|
349
643
|
const allPassed = checks.every(check => check.passed);
|
|
350
644
|
return {
|
|
351
645
|
checks,
|
|
@@ -288,14 +288,64 @@ export class Validator {
|
|
|
288
288
|
});
|
|
289
289
|
}
|
|
290
290
|
// Run strict validation
|
|
291
|
-
|
|
291
|
+
// Read proposal.md for proposal-format-valid check
|
|
292
|
+
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
293
|
+
let proposalContent;
|
|
294
|
+
try {
|
|
295
|
+
proposalContent = await fs.readFile(proposalPath, 'utf-8');
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// proposal.md not found - skip proposal format validation
|
|
299
|
+
}
|
|
300
|
+
// Get specs directory and repo root for spec-code-consistent check
|
|
301
|
+
const repoRoot = this.findProjectRoot(changeDir);
|
|
302
|
+
// Detect hasTestCase mode:
|
|
303
|
+
// 1. Check for test-cases.md file
|
|
304
|
+
// 2. Check for @test-case tags in tasks.md
|
|
305
|
+
// 3. Check for TDD schema in metadata
|
|
306
|
+
let hasTestCase = false;
|
|
307
|
+
const testCasesPath = path.join(changeDir, 'test-cases.md');
|
|
308
|
+
if (existsSync(testCasesPath)) {
|
|
309
|
+
hasTestCase = true;
|
|
310
|
+
}
|
|
311
|
+
else if (tasksContent.includes('@test-case:')) {
|
|
312
|
+
hasTestCase = true;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// Check metadata for schema type
|
|
316
|
+
const metadataPath = path.join(changeDir, '.zhuanspec.yaml');
|
|
317
|
+
try {
|
|
318
|
+
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
|
|
319
|
+
if (metadataContent.includes('schema: tdd') || metadataContent.includes('schema:\n tdd')) {
|
|
320
|
+
hasTestCase = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// No metadata file - assume spec-driven (no test cases)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const strictResult = await runStrictValidation(tasksContent, parsedTasks, knownSkillNames, {
|
|
328
|
+
proposalContent,
|
|
329
|
+
specsDir,
|
|
330
|
+
repoRoot,
|
|
331
|
+
changeDir,
|
|
332
|
+
hasTestCase,
|
|
333
|
+
});
|
|
292
334
|
strictChecks = strictResult.checks;
|
|
293
335
|
// Merge strict errors into main issues
|
|
294
336
|
for (const check of strictResult.checks) {
|
|
337
|
+
// Determine path based on rule ID
|
|
338
|
+
let issuePath = 'tasks.md';
|
|
339
|
+
if (check.ruleId === 'proposal-format-valid') {
|
|
340
|
+
issuePath = 'proposal.md';
|
|
341
|
+
}
|
|
342
|
+
else if (check.ruleId === 'spec-code-consistent') {
|
|
343
|
+
issuePath = 'specs/';
|
|
344
|
+
}
|
|
295
345
|
for (const error of check.errors) {
|
|
296
346
|
issues.push({
|
|
297
347
|
level: 'ERROR',
|
|
298
|
-
path:
|
|
348
|
+
path: issuePath,
|
|
299
349
|
message: `[${check.ruleName}] ${error}`,
|
|
300
350
|
});
|
|
301
351
|
}
|
package/dist/core/view.d.ts
CHANGED
package/dist/core/view.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from 'path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
|
5
5
|
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
6
|
+
import { loadChangeMetrics } from './dashboard/metrics.js';
|
|
6
7
|
export class ViewCommand {
|
|
7
8
|
async execute(targetPath = '.') {
|
|
8
9
|
const zhuanspecDir = path.join(targetPath, 'zhuanspec');
|
|
@@ -15,8 +16,9 @@ export class ViewCommand {
|
|
|
15
16
|
// Get changes and specs data
|
|
16
17
|
const changesData = await this.getChangesData(zhuanspecDir);
|
|
17
18
|
const specsData = await this.getSpecsData(zhuanspecDir);
|
|
19
|
+
const activeMetrics = await Promise.all(changesData.active.map((change) => loadChangeMetrics(zhuanspecDir, change.name)));
|
|
18
20
|
// Display summary metrics
|
|
19
|
-
this.displaySummary(changesData, specsData);
|
|
21
|
+
this.displaySummary(changesData, specsData, activeMetrics);
|
|
20
22
|
// Display draft changes
|
|
21
23
|
if (changesData.draft.length > 0) {
|
|
22
24
|
console.log(chalk.bold.gray('\nDraft Changes'));
|
|
@@ -36,6 +38,7 @@ export class ViewCommand {
|
|
|
36
38
|
: 0;
|
|
37
39
|
console.log(` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`);
|
|
38
40
|
});
|
|
41
|
+
this.displayRichMetrics(activeMetrics);
|
|
39
42
|
}
|
|
40
43
|
// Display completed changes
|
|
41
44
|
if (changesData.completed.length > 0) {
|
|
@@ -127,7 +130,7 @@ export class ViewCommand {
|
|
|
127
130
|
}
|
|
128
131
|
return specs;
|
|
129
132
|
}
|
|
130
|
-
displaySummary(changesData, specsData) {
|
|
133
|
+
displaySummary(changesData, specsData, activeMetrics) {
|
|
131
134
|
const totalChanges = changesData.draft.length + changesData.active.length + changesData.completed.length;
|
|
132
135
|
const totalSpecs = specsData.length;
|
|
133
136
|
const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);
|
|
@@ -153,6 +156,61 @@ export class ViewCommand {
|
|
|
153
156
|
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
|
|
154
157
|
console.log(` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`);
|
|
155
158
|
}
|
|
159
|
+
const totalTokenUsage = activeMetrics.reduce((sum, item) => sum + item.tokenUsage, 0);
|
|
160
|
+
const totalContextLoad = activeMetrics.reduce((sum, item) => sum + item.contextLoad, 0);
|
|
161
|
+
if (activeMetrics.length > 0) {
|
|
162
|
+
console.log(` ${chalk.white('●')} Runtime Metrics: ${chalk.bold(`token=${totalTokenUsage}`)}, ${chalk.bold(`context=${totalContextLoad}`)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
displayRichMetrics(metricsList) {
|
|
166
|
+
const total = metricsList.length;
|
|
167
|
+
if (total === 0)
|
|
168
|
+
return;
|
|
169
|
+
console.log(chalk.bold.magenta('\nFlow Overview'));
|
|
170
|
+
console.log('─'.repeat(60));
|
|
171
|
+
for (const item of metricsList) {
|
|
172
|
+
const flowText = `${item.phase} -> ${item.currentNode}`;
|
|
173
|
+
const waveText = item.currentWave > 0 ? `Wave ${item.currentWave}` : 'Wave -';
|
|
174
|
+
const taskText = item.currentTask || item.nextTask || '-';
|
|
175
|
+
console.log(` ${chalk.magenta('▸')} ${chalk.bold(item.changeId)} ${chalk.dim(flowText)} ${chalk.dim(waveText)} task:${chalk.white(taskText)}`);
|
|
176
|
+
}
|
|
177
|
+
const totalToolCalls = metricsList.reduce((sum, m) => sum + m.toolCalls, 0);
|
|
178
|
+
const reviewCritical = metricsList.reduce((sum, m) => sum + m.review.critical, 0);
|
|
179
|
+
const reviewImportant = metricsList.reduce((sum, m) => sum + m.review.important, 0);
|
|
180
|
+
const reviewMinor = metricsList.reduce((sum, m) => sum + m.review.minor, 0);
|
|
181
|
+
const testPassed = metricsList.reduce((sum, m) => sum + m.tests.passed, 0);
|
|
182
|
+
const testFailed = metricsList.reduce((sum, m) => sum + m.tests.failed, 0);
|
|
183
|
+
const coverageValues = metricsList
|
|
184
|
+
.map((m) => m.tests.coverage)
|
|
185
|
+
.filter((v) => typeof v === 'number');
|
|
186
|
+
const avgCoverage = coverageValues.length > 0
|
|
187
|
+
? Math.round(coverageValues.reduce((sum, v) => sum + v, 0) / coverageValues.length)
|
|
188
|
+
: null;
|
|
189
|
+
console.log(chalk.bold.magenta('\nEfficiency Metrics'));
|
|
190
|
+
console.log('─'.repeat(60));
|
|
191
|
+
console.log(` ${chalk.magenta('▸')} Tool Calls: ${chalk.bold(totalToolCalls)}`);
|
|
192
|
+
const skillMap = {};
|
|
193
|
+
for (const metric of metricsList) {
|
|
194
|
+
for (const [name, count] of Object.entries(metric.skillCalls)) {
|
|
195
|
+
skillMap[name] = (skillMap[name] ?? 0) + count;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const topSkills = Object.entries(skillMap)
|
|
199
|
+
.sort((a, b) => b[1] - a[1])
|
|
200
|
+
.slice(0, 5)
|
|
201
|
+
.map(([name, count]) => `${name}:${count}`)
|
|
202
|
+
.join(', ');
|
|
203
|
+
console.log(` ${chalk.magenta('▸')} Skill Calls: ${topSkills || 'N/A'}`);
|
|
204
|
+
console.log(chalk.bold.magenta('\nQuality Metrics'));
|
|
205
|
+
console.log('─'.repeat(60));
|
|
206
|
+
console.log(` ${chalk.magenta('▸')} Review: C=${reviewCritical} I=${reviewImportant} M=${reviewMinor}`);
|
|
207
|
+
console.log(` ${chalk.magenta('▸')} Tests: passed=${testPassed} failed=${testFailed} coverage=${avgCoverage !== null ? `${avgCoverage}%` : 'N/A'}`);
|
|
208
|
+
const totalDuration = metricsList.reduce((sum, m) => {
|
|
209
|
+
return sum + m.durations.proposeMs + m.durations.applyMs + m.durations.reviewMs + m.durations.archiveMs;
|
|
210
|
+
}, 0);
|
|
211
|
+
console.log(chalk.bold.magenta('\nTimeline'));
|
|
212
|
+
console.log('─'.repeat(60));
|
|
213
|
+
console.log(` ${chalk.magenta('▸')} Total Duration: ${Math.round(totalDuration / 1000)}s`);
|
|
156
214
|
}
|
|
157
215
|
createProgressBar(completed, total, width = 20) {
|
|
158
216
|
if (total === 0)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client Wrapper
|
|
3
|
+
*
|
|
4
|
+
* This module provides a wrapper for MCP tools that may be available
|
|
5
|
+
* in the runtime environment. If the MCP server is not configured,
|
|
6
|
+
* these functions will gracefully return empty results.
|
|
7
|
+
*/
|
|
8
|
+
interface SonarQueryParams {
|
|
9
|
+
branchName?: string;
|
|
10
|
+
staticType?: string;
|
|
11
|
+
}
|
|
12
|
+
interface SonarIssue {
|
|
13
|
+
type: string;
|
|
14
|
+
severity: string;
|
|
15
|
+
message: string;
|
|
16
|
+
file: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Query Sonar issues for a branch
|
|
21
|
+
*
|
|
22
|
+
* Note: This function uses the MCP tool mcp__sonar__get_Sonar_Query
|
|
23
|
+
* which must be configured in the environment. If not available,
|
|
24
|
+
* it returns an empty array.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mcp__sonar__get_Sonar_Query(params: SonarQueryParams): Promise<SonarIssue[]>;
|
|
27
|
+
export type { SonarQueryParams, SonarIssue };
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client Wrapper
|
|
3
|
+
*
|
|
4
|
+
* This module provides a wrapper for MCP tools that may be available
|
|
5
|
+
* in the runtime environment. If the MCP server is not configured,
|
|
6
|
+
* these functions will gracefully return empty results.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Query Sonar issues for a branch
|
|
10
|
+
*
|
|
11
|
+
* Note: This function uses the MCP tool mcp__sonar__get_Sonar_Query
|
|
12
|
+
* which must be configured in the environment. If not available,
|
|
13
|
+
* it returns an empty array.
|
|
14
|
+
*/
|
|
15
|
+
export async function mcp__sonar__get_Sonar_Query(params) {
|
|
16
|
+
// In a real implementation, this would call the MCP tool
|
|
17
|
+
// For now, we provide a placeholder that returns empty results
|
|
18
|
+
// when the MCP is not configured
|
|
19
|
+
try {
|
|
20
|
+
// Dynamic import to handle cases where MCP tools are not available
|
|
21
|
+
// The actual MCP tool mcp__sonar__get_Sonar_Query is provided by the runtime
|
|
22
|
+
// This wrapper serves as a TypeScript-compatible interface
|
|
23
|
+
// Return empty array as fallback
|
|
24
|
+
// The actual implementation would be handled by the MCP runtime
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -13,6 +13,7 @@ export declare class FileSystemUtils {
|
|
|
13
13
|
static directoryExists(dirPath: string): Promise<boolean>;
|
|
14
14
|
static writeFile(filePath: string, content: string): Promise<void>;
|
|
15
15
|
static readFile(filePath: string): Promise<string>;
|
|
16
|
+
static deleteFile(filePath: string): Promise<void>;
|
|
16
17
|
static updateFileWithMarkers(filePath: string, content: string, startMarker: string, endMarker: string): Promise<void>;
|
|
17
18
|
static ensureWritePermissions(dirPath: string): Promise<boolean>;
|
|
18
19
|
}
|
|
@@ -125,6 +125,17 @@ export class FileSystemUtils {
|
|
|
125
125
|
static async readFile(filePath) {
|
|
126
126
|
return await fs.readFile(filePath, 'utf-8');
|
|
127
127
|
}
|
|
128
|
+
static async deleteFile(filePath) {
|
|
129
|
+
try {
|
|
130
|
+
await fs.unlink(filePath);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error.code !== 'ENOENT') {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
// File doesn't exist, nothing to delete
|
|
137
|
+
}
|
|
138
|
+
}
|
|
128
139
|
static async updateFileWithMarkers(filePath, content, startMarker, endMarker) {
|
|
129
140
|
let existingContent = '';
|
|
130
141
|
if (await this.fileExists(filePath)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { FileSystemUtils } from './file-system.js';
|
|
3
4
|
export async function getActiveChangeIds(root = process.cwd()) {
|
|
4
5
|
const changesPath = path.join(root, 'zhuanspec', 'changes');
|
|
5
6
|
try {
|
|
@@ -8,13 +9,34 @@ export async function getActiveChangeIds(root = process.cwd()) {
|
|
|
8
9
|
for (const entry of entries) {
|
|
9
10
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive')
|
|
10
11
|
continue;
|
|
11
|
-
const
|
|
12
|
+
const changeDir = path.join(changesPath, entry.name);
|
|
13
|
+
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
12
14
|
try {
|
|
15
|
+
// Check for proposal.md (normal propose/apply/review change)
|
|
13
16
|
await fs.access(proposalPath);
|
|
14
17
|
result.push(entry.name);
|
|
15
18
|
}
|
|
16
19
|
catch {
|
|
17
|
-
//
|
|
20
|
+
// No proposal.md - check if it's a techDesign change
|
|
21
|
+
const techDesignMarker = path.join(changeDir, '.tech-design');
|
|
22
|
+
const techDesignMarkerExists = await FileSystemUtils.fileExists(techDesignMarker);
|
|
23
|
+
// Also check progress.json phase
|
|
24
|
+
const progressPath = path.join(changeDir, 'metrics', 'progress.json');
|
|
25
|
+
let progressPhaseIsTechDesign = false;
|
|
26
|
+
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
27
|
+
try {
|
|
28
|
+
const progressContent = await FileSystemUtils.readFile(progressPath);
|
|
29
|
+
const progress = JSON.parse(progressContent);
|
|
30
|
+
progressPhaseIsTechDesign = progress.phase === 'techDesign';
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Ignore parse errors
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// If techDesign markers exist, add to active changes
|
|
37
|
+
if (techDesignMarkerExists || progressPhaseIsTechDesign) {
|
|
38
|
+
result.push(entry.name);
|
|
39
|
+
}
|
|
18
40
|
}
|
|
19
41
|
}
|
|
20
42
|
return result.sort();
|