@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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. Track tool calls for metrics
|
|
8
8
|
*/
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { promises as fsPromises } from 'fs';
|
|
10
|
+
import { promises as fsPromises, existsSync, readFileSync, unlinkSync } from 'fs';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
12
|
import { PHASE_ORDER } from '../../utils/phase-utils.js';
|
|
13
13
|
const fs = fsPromises;
|
|
@@ -103,6 +103,7 @@ export async function recoverProgressJsonForWrite(filePath) {
|
|
|
103
103
|
linesRemoved: 0,
|
|
104
104
|
deviationCount: 0,
|
|
105
105
|
deviationRecords: [],
|
|
106
|
+
proposalChanges: [],
|
|
106
107
|
reviewStats: { loopCount: 0, criticalFixes: 0, testFixes: 0, consistencyFixes: 0 },
|
|
107
108
|
phaseTransitions: [],
|
|
108
109
|
phaseDurations: [{
|
|
@@ -281,6 +282,34 @@ async function detectActiveChange(cwd, filePath) {
|
|
|
281
282
|
candidates.sort((a, b) => b.sortKey - a.sortKey);
|
|
282
283
|
return { changeId: candidates[0].changeId, phase: candidates[0].phase };
|
|
283
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Check if a file path is a proposal file
|
|
287
|
+
*/
|
|
288
|
+
function isProposalFile(filePath, changePath) {
|
|
289
|
+
const absFilePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
290
|
+
const absChangePath = path.isAbsolute(changePath) ? changePath : path.resolve(process.cwd(), changePath);
|
|
291
|
+
if (!absFilePath.startsWith(absChangePath + path.sep))
|
|
292
|
+
return false;
|
|
293
|
+
const relativePath = path.relative(absChangePath, absFilePath);
|
|
294
|
+
return ['proposal.md', 'tasks.md', 'design.md', 'test-cases.md']
|
|
295
|
+
.some(f => relativePath === f)
|
|
296
|
+
|| relativePath.startsWith('specs' + path.sep);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Generate a brief summary of proposal file changes from tool input
|
|
300
|
+
*/
|
|
301
|
+
function generateProposalChangeSummary(toolInput) {
|
|
302
|
+
const content = typeof toolInput.content === 'string' ? toolInput.content : '';
|
|
303
|
+
if (!content)
|
|
304
|
+
return 'File modified';
|
|
305
|
+
// Extract first meaningful line as summary
|
|
306
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
307
|
+
if (lines.length === 0)
|
|
308
|
+
return 'File modified';
|
|
309
|
+
const firstLine = lines[0].trim();
|
|
310
|
+
// Truncate to 120 chars
|
|
311
|
+
return firstLine.length > 120 ? firstLine.substring(0, 117) + '...' : firstLine;
|
|
312
|
+
}
|
|
284
313
|
async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
285
314
|
const cwd = process.cwd();
|
|
286
315
|
// Priority: filePath-derived > progress.json scan > environment variables
|
|
@@ -327,6 +356,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
327
356
|
};
|
|
328
357
|
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
329
358
|
progress.phaseDurations = progress.phaseDurations || [];
|
|
359
|
+
progress.proposalChanges = progress.proposalChanges || [];
|
|
330
360
|
progress.stats = progress.stats || {
|
|
331
361
|
tokenUsageTotal: 0,
|
|
332
362
|
contextLoad: 0,
|
|
@@ -434,6 +464,43 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
434
464
|
progress.filesModified.push(filePath);
|
|
435
465
|
}
|
|
436
466
|
}
|
|
467
|
+
// Detect proposal file changes and record them
|
|
468
|
+
const changePath = path.join(zhuanspecDir, 'changes', changeId);
|
|
469
|
+
const WRITE_EDIT_TOOLS = new Set(['Write', 'Edit']);
|
|
470
|
+
if (filePath && WRITE_EDIT_TOOLS.has(toolName) && isProposalFile(filePath, changePath)) {
|
|
471
|
+
const relativeProposalPath = path.relative(changePath, path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath));
|
|
472
|
+
const linesContent = typeof toolInput.content === 'string' ? toolInput.content : '';
|
|
473
|
+
const contentLines = linesContent ? linesContent.split('\n').length : 0;
|
|
474
|
+
const record = {
|
|
475
|
+
changeRecordId: `pc-${Date.now()}`,
|
|
476
|
+
timestamp,
|
|
477
|
+
phase,
|
|
478
|
+
triggeredBy: phase === 'apply' ? 'reverse-sync' : phase === 'review' ? 'review-fix' : 'manual-edit',
|
|
479
|
+
modifiedFiles: [{
|
|
480
|
+
filePath: relativeProposalPath,
|
|
481
|
+
changeType: toolName === 'Write' ? 'created' : 'modified',
|
|
482
|
+
summary: generateProposalChangeSummary(toolInput),
|
|
483
|
+
linesAdded: contentLines,
|
|
484
|
+
linesRemoved: 0,
|
|
485
|
+
}],
|
|
486
|
+
};
|
|
487
|
+
// Read related deviation ID from temp file (cross-process communication)
|
|
488
|
+
let relatedDeviationId;
|
|
489
|
+
try {
|
|
490
|
+
const deviationIdPath = path.join(changePath, 'metrics', '.last-deviation-id');
|
|
491
|
+
if (existsSync(deviationIdPath)) {
|
|
492
|
+
relatedDeviationId = readFileSync(deviationIdPath, 'utf-8').trim();
|
|
493
|
+
unlinkSync(deviationIdPath); // consume after reading
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// graceful degradation - no deviation ID available
|
|
498
|
+
}
|
|
499
|
+
if (relatedDeviationId) {
|
|
500
|
+
record.relatedDeviationId = relatedDeviationId;
|
|
501
|
+
}
|
|
502
|
+
progress.proposalChanges.push(record);
|
|
503
|
+
}
|
|
437
504
|
// Try to estimate lines from content
|
|
438
505
|
const content = stdinData.tool_input?.content;
|
|
439
506
|
if (content) {
|
|
@@ -470,6 +537,24 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
470
537
|
progress.stats.durationMs.archive = elapsedMs;
|
|
471
538
|
}
|
|
472
539
|
}
|
|
540
|
+
// === Auto-count tasks from tasks.md ===
|
|
541
|
+
const tasksPath = path.join(changeDir, 'tasks.md');
|
|
542
|
+
if (await FileSystemUtils.fileExists(tasksPath)) {
|
|
543
|
+
try {
|
|
544
|
+
const tasksContent = await FileSystemUtils.readFile(tasksPath);
|
|
545
|
+
const totalTaskMatches = tasksContent.match(/^- \[[ x]\] .+$/gm) || [];
|
|
546
|
+
const completedTaskMatches = tasksContent.match(/^- \[x\] .+$/gm) || [];
|
|
547
|
+
progress.totalTasks = totalTaskMatches.length;
|
|
548
|
+
// Update completedTasks array with completed task descriptions
|
|
549
|
+
const completedDescriptions = completedTaskMatches.map(t => t.replace(/^- \[x\] /, '').trim());
|
|
550
|
+
if (completedDescriptions.length > 0) {
|
|
551
|
+
progress.completedTasks = completedDescriptions;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// Ignore tasks.md read errors
|
|
556
|
+
}
|
|
557
|
+
}
|
|
473
558
|
// Write progress file atomically
|
|
474
559
|
await atomicWriteJson(progressPath, progress);
|
|
475
560
|
// Also update tool_calls.json separately for detailed tracking
|
|
@@ -569,6 +654,7 @@ function createNewProgress(changeId) {
|
|
|
569
654
|
consistencyFixes: 0,
|
|
570
655
|
},
|
|
571
656
|
phaseTransitions: [],
|
|
657
|
+
proposalChanges: [],
|
|
572
658
|
phaseDurations: [{
|
|
573
659
|
phase: initialPhase,
|
|
574
660
|
startedAt: getBeijingTime(),
|
|
@@ -617,6 +703,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
617
703
|
progress.filesModified = progress.filesModified || [];
|
|
618
704
|
progress.completedTasks = progress.completedTasks || [];
|
|
619
705
|
progress.deviationRecords = progress.deviationRecords || [];
|
|
706
|
+
progress.proposalChanges = progress.proposalChanges || [];
|
|
620
707
|
progress.phaseTransitions = progress.phaseTransitions || [];
|
|
621
708
|
progress.phaseDurations = progress.phaseDurations || [];
|
|
622
709
|
progress.reviewStats = progress.reviewStats || {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - Controls loop count (max 3)
|
|
14
14
|
*/
|
|
15
15
|
import path from 'path';
|
|
16
|
+
import fs from 'fs';
|
|
16
17
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
17
18
|
import { getBeijingTime } from './record-progress.js';
|
|
18
19
|
// Output file paths for skill results
|
|
@@ -142,15 +143,19 @@ export async function unitTestResultCheck(changeId, skillOutput) {
|
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
145
|
export async function specConsistencyResultCheck(changeId, skillOutput) {
|
|
145
|
-
|
|
146
|
+
let output = skillOutput || await readSkillOutput(changeId, SPEC_CONSISTENCY_OUTPUT);
|
|
147
|
+
// Fallback: auto-execute basic consistency analysis when no JSON exists
|
|
148
|
+
if (!output) {
|
|
149
|
+
output = await runBasicConsistencyAnalysis(changeId);
|
|
150
|
+
}
|
|
146
151
|
if (!output) {
|
|
147
152
|
return {
|
|
148
153
|
pass: false,
|
|
149
154
|
needFix: false,
|
|
150
155
|
loopCount: 0,
|
|
151
|
-
metrics: { error: 'No skill output found' },
|
|
156
|
+
metrics: { error: 'No skill output found and basic analysis failed' },
|
|
152
157
|
nextAction: 'stop',
|
|
153
|
-
fixPrompt: 'Spec-consistency skill output not found. Please run spec-code-consistency check first.',
|
|
158
|
+
fixPrompt: 'Spec-consistency skill output not found and basic analysis failed. Please run spec-code-consistency check first.',
|
|
154
159
|
};
|
|
155
160
|
}
|
|
156
161
|
const loopCount = (output.loopCount || 0) + 1;
|
|
@@ -200,6 +205,169 @@ export async function specConsistencyResultCheck(changeId, skillOutput) {
|
|
|
200
205
|
};
|
|
201
206
|
}
|
|
202
207
|
// ============================================================
|
|
208
|
+
// Basic Consistency Analysis Fallback
|
|
209
|
+
// ============================================================
|
|
210
|
+
/**
|
|
211
|
+
* Run basic consistency analysis when spec-consistency-result.json is missing.
|
|
212
|
+
* Reads specs/ under the change directory, extracts Requirements/Scenarios,
|
|
213
|
+
* searches touched files for keyword matches, and writes a basic result.
|
|
214
|
+
*/
|
|
215
|
+
async function runBasicConsistencyAnalysis(changeId) {
|
|
216
|
+
const cwd = process.cwd();
|
|
217
|
+
const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeId);
|
|
218
|
+
const specsDir = path.join(changeDir, 'specs');
|
|
219
|
+
// Check if specs directory exists
|
|
220
|
+
if (!await FileSystemUtils.directoryExists(specsDir)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const specEntries = await fs.promises.readdir(specsDir, { withFileTypes: true });
|
|
225
|
+
const allRequirements = [];
|
|
226
|
+
for (const entry of specEntries) {
|
|
227
|
+
if (!entry.isDirectory())
|
|
228
|
+
continue;
|
|
229
|
+
const specFile = path.join(specsDir, entry.name, 'spec.md');
|
|
230
|
+
if (!await FileSystemUtils.fileExists(specFile))
|
|
231
|
+
continue;
|
|
232
|
+
const content = await FileSystemUtils.readFile(specFile);
|
|
233
|
+
const lines = content.split('\n');
|
|
234
|
+
let currentSection = '';
|
|
235
|
+
let currentReq = null;
|
|
236
|
+
let reqCounter = 0;
|
|
237
|
+
let scenarioCounter = 0;
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
if (line.match(/^##\s+(ADDED|Added|\u65b0\u589e)/i))
|
|
240
|
+
currentSection = 'ADDED';
|
|
241
|
+
else if (line.match(/^##\s+(MODIFIED|Modified|\u4fee\u6539)/i))
|
|
242
|
+
currentSection = 'MODIFIED';
|
|
243
|
+
else if (line.match(/^##\s+(REMOVED|Removed|\u5220\u9664)/i))
|
|
244
|
+
currentSection = 'REMOVED';
|
|
245
|
+
else if (line.match(/^###\s+(Requirement|\u9700\u6c42)[\uff1a:]/i)) {
|
|
246
|
+
const reqName = line.replace(/^###\s+(Requirement|\u9700\u6c42)[\uff1a:]\s+/i, '').trim();
|
|
247
|
+
if (currentSection === 'ADDED' || currentSection === 'MODIFIED') {
|
|
248
|
+
reqCounter++;
|
|
249
|
+
currentReq = { id: `REQ-${reqCounter}`, name: reqName, scenarios: [] };
|
|
250
|
+
allRequirements.push(currentReq);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
currentReq = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (line.match(/^####\s+(Scenario|\u573a\u666f)[\uff1a:]/i) && currentReq) {
|
|
257
|
+
scenarioCounter++;
|
|
258
|
+
const scenarioDesc = line.replace(/^####\s+(Scenario|\u573a\u666f)[\uff1a:]\s+/i, '').trim();
|
|
259
|
+
currentReq.scenarios.push({ id: `SC-${scenarioCounter}`, description: scenarioDesc });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (allRequirements.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
// Get touched files from git
|
|
267
|
+
let touchedFiles = [];
|
|
268
|
+
try {
|
|
269
|
+
const { execSync } = await import('child_process');
|
|
270
|
+
const diffOutput = execSync('git diff --name-only HEAD', {
|
|
271
|
+
cwd,
|
|
272
|
+
encoding: 'utf-8',
|
|
273
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
274
|
+
});
|
|
275
|
+
touchedFiles = diffOutput.split('\n').map(f => f.trim()).filter(f => f.length > 0);
|
|
276
|
+
const statusOutput = execSync('git status --porcelain', {
|
|
277
|
+
cwd,
|
|
278
|
+
encoding: 'utf-8',
|
|
279
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
280
|
+
});
|
|
281
|
+
const statusFiles = statusOutput.split('\n')
|
|
282
|
+
.map(l => l.replace(/^\s*[A-Z?]+\s+/, '').trim())
|
|
283
|
+
.filter(f => f.length > 0);
|
|
284
|
+
touchedFiles = [...new Set([...touchedFiles, ...statusFiles])];
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// No git info available
|
|
288
|
+
}
|
|
289
|
+
// Build scenario mapping and check coverage
|
|
290
|
+
const mapping = [];
|
|
291
|
+
const uncoveredScenarios = [];
|
|
292
|
+
let totalScenarios = 0;
|
|
293
|
+
let coveredCount = 0;
|
|
294
|
+
for (const req of allRequirements) {
|
|
295
|
+
const scenarios = req.scenarios.length > 0
|
|
296
|
+
? req.scenarios
|
|
297
|
+
: [{ id: `${req.id}-implicit`, description: req.name }];
|
|
298
|
+
for (const scenario of scenarios) {
|
|
299
|
+
totalScenarios++;
|
|
300
|
+
const keywords = scenario.description
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.split(/[\s\-_/\uff0c\u3001]+/)
|
|
303
|
+
.filter(w => w.length > 3);
|
|
304
|
+
let found = false;
|
|
305
|
+
let matchedFile = '';
|
|
306
|
+
for (const file of touchedFiles) {
|
|
307
|
+
const fileLower = file.toLowerCase();
|
|
308
|
+
if (keywords.some(kw => fileLower.includes(kw))) {
|
|
309
|
+
found = true;
|
|
310
|
+
matchedFile = file;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const fullPath = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
315
|
+
const fileContent = await FileSystemUtils.readFile(fullPath);
|
|
316
|
+
if (keywords.some(kw => fileContent.toLowerCase().includes(kw))) {
|
|
317
|
+
found = true;
|
|
318
|
+
matchedFile = file;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Skip unreadable files
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (found) {
|
|
327
|
+
coveredCount++;
|
|
328
|
+
mapping.push({
|
|
329
|
+
requirementId: req.id,
|
|
330
|
+
scenarioId: scenario.id,
|
|
331
|
+
codeFile: matchedFile,
|
|
332
|
+
status: 'covered',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
mapping.push({
|
|
337
|
+
requirementId: req.id,
|
|
338
|
+
scenarioId: scenario.id,
|
|
339
|
+
codeFile: '',
|
|
340
|
+
status: 'missing',
|
|
341
|
+
});
|
|
342
|
+
uncoveredScenarios.push({
|
|
343
|
+
requirementId: req.id,
|
|
344
|
+
scenarioId: scenario.id,
|
|
345
|
+
description: scenario.description,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const consistencyRate = totalScenarios > 0
|
|
351
|
+
? Math.round((coveredCount / totalScenarios) * 100)
|
|
352
|
+
: 100;
|
|
353
|
+
const result = {
|
|
354
|
+
consistencyRate,
|
|
355
|
+
totalRequirements: allRequirements.length,
|
|
356
|
+
totalScenarios,
|
|
357
|
+
coveredScenarios: coveredCount,
|
|
358
|
+
uncoveredScenarios,
|
|
359
|
+
mapping,
|
|
360
|
+
loopCount: 0,
|
|
361
|
+
};
|
|
362
|
+
// Write the result so subsequent calls can read it directly
|
|
363
|
+
await writeSkillOutput(changeId, SPEC_CONSISTENCY_OUTPUT, result);
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// ============================================================
|
|
203
371
|
// Helper Functions
|
|
204
372
|
// ============================================================
|
|
205
373
|
async function readSkillOutput(changeId, filename) {
|
|
@@ -69,22 +69,69 @@ function generateSpecConsistencyPrompt(changeId) {
|
|
|
69
69
|
执行 Spec-Code 一致性检查,发现未覆盖 Scenario 时启动修复循环:
|
|
70
70
|
|
|
71
71
|
## 任务目标
|
|
72
|
-
验证 changeId=${changeId} 的代码实现与 Spec
|
|
72
|
+
验证 changeId=${changeId} 的代码实现与 Spec Scenario 语义一致。
|
|
73
|
+
⚠️ 禁止用 Requirement 标题做字符串匹配,必须逐条读取 Scenario 内容做语义比对。
|
|
73
74
|
|
|
74
75
|
## 自闭环指令
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
|
|
77
|
+
### Step 1:解析 Spec Scenarios
|
|
78
|
+
读取 zhuanspec/changes/${changeId}/specs/**/*.md,提取所有内容:
|
|
79
|
+
- 每个 \`### Requirement:\` 下的每个 \`#### Scenario:\` 标题
|
|
80
|
+
- Scenario 的 Given/When/Then 描述(业务语义)
|
|
81
|
+
- 标记每个 Scenario 的所属 Requirement
|
|
82
|
+
|
|
83
|
+
输出 Scenario 清单,格式:
|
|
84
|
+
[Requirement名] > [Scenario名]: [Given/When/Then核心语义]
|
|
85
|
+
|
|
86
|
+
### Step 2:逐 Scenario 语义比对
|
|
87
|
+
对每个 Scenario,在代码中找对应实现:
|
|
88
|
+
|
|
89
|
+
**查找顺序**:
|
|
90
|
+
1. 读取 proposal.md 的「后端改动」和「前端改动」表格,定位涉及文件
|
|
91
|
+
2. 在对应文件中找与 Scenario 语义匹配的方法/逻辑:
|
|
92
|
+
- Scenario 描述「新建客户」→ 找 createCustomer 相关方法
|
|
93
|
+
- Scenario 描述「手机号去重」→ 找手机号唯一性校验逻辑
|
|
94
|
+
- Scenario 描述「租户隔离」→ 找 tenantId 过滤条件
|
|
95
|
+
- Scenario 描述「删除企微客户拒绝」→ 找 source_type 判断逻辑
|
|
96
|
+
3. 验证找到的代码是否满足 Scenario 的 Then 条件
|
|
97
|
+
|
|
98
|
+
**判断标准**(语义覆盖,不是字符串匹配):
|
|
99
|
+
- ✅ 覆盖:代码中存在与 Scenario Given→When→Then 对应的业务逻辑
|
|
100
|
+
- ❌ 未覆盖:相关文件不存在、方法不存在、或 Then 条件未实现
|
|
101
|
+
- ⚠️ 部分覆盖:正向路径实现了,异常路径未实现
|
|
102
|
+
|
|
103
|
+
### Step 3:输出比对结果表格
|
|
104
|
+
\`\`\`
|
|
105
|
+
| Requirement | Scenario | 对应文件/方法 | 覆盖状态 | 说明 |
|
|
106
|
+
|---|---|---|---|---|
|
|
107
|
+
| 客户CRUD | 新建客户-成功 | CustomerDomainService#createCustomer | ✅ | - |
|
|
108
|
+
| 客户CRUD | 手机号重复-拒绝 | CustomerDomainService#checkPhoneDuplicate | ✅ | - |
|
|
109
|
+
| 多租户隔离 | 租户隔离查询 | CustomerEsQueryService | ❌ | 未找到tenantId过滤 |
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
### Step 4:自闭环修复
|
|
113
|
+
- 发现 ❌ 未覆盖的 Scenario → 补充对应代码实现
|
|
114
|
+
- 修复后重新执行 Step 2 验证(最多3轮)
|
|
115
|
+
- ⚠️ 仅修复真实遗漏,禁止把"文件存在但逻辑未实现"误报为已覆盖
|
|
116
|
+
|
|
117
|
+
### Step 5:输出 spec-consistency-result.json
|
|
118
|
+
路径:zhuanspec/changes/${changeId}/review/spec-consistency-result.json
|
|
80
119
|
|
|
81
120
|
## 输出格式
|
|
82
121
|
{
|
|
83
|
-
"consistencyRate": number,
|
|
122
|
+
"consistencyRate": number, // 覆盖 Scenario 数 / 总 Scenario 数
|
|
84
123
|
"totalRequirements": number,
|
|
85
124
|
"totalScenarios": number,
|
|
86
125
|
"coveredScenarios": number,
|
|
87
|
-
"uncoveredScenarios": [
|
|
126
|
+
"uncoveredScenarios": [ // 真实未覆盖,非字符串匹配失败
|
|
127
|
+
{
|
|
128
|
+
"requirement": "Requirement名",
|
|
129
|
+
"scenario": "Scenario名",
|
|
130
|
+
"reason": "具体原因:方法不存在/逻辑未实现/文件缺失",
|
|
131
|
+
"suggestedFix": "建议修复方式"
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
"partialCoverage": [...], // 部分覆盖的 Scenario
|
|
88
135
|
"loopCount": number,
|
|
89
136
|
"finalStatus": "PASS" | "FAIL"
|
|
90
137
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostToolUse Hook - TDD Phase Tracking
|
|
3
|
+
*
|
|
4
|
+
* This hook tracks TDD workflow compliance for tasks annotated with @test-case:
|
|
5
|
+
* 1. Tracks file creation order (test files should precede implementation files)
|
|
6
|
+
* 2. Detects test execution results (RED → GREEN → REFACTOR phases)
|
|
7
|
+
* 3. Updates metrics/tdd-phases.json with current TDD status
|
|
8
|
+
*/
|
|
9
|
+
export type TddPhase = 'PENDING' | 'RED' | 'GREEN' | 'REFACTOR' | 'DONE';
|
|
10
|
+
export interface TddTaskStatus {
|
|
11
|
+
taskId: string;
|
|
12
|
+
testCases: string[];
|
|
13
|
+
tddPhase: TddPhase;
|
|
14
|
+
testFileCreated: boolean;
|
|
15
|
+
testInitiallyFailed: boolean;
|
|
16
|
+
testFinallyPassed: boolean;
|
|
17
|
+
implFileCreated: boolean;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface TddHookResult {
|
|
21
|
+
action: 'continue' | 'warn' | 'block';
|
|
22
|
+
message?: string;
|
|
23
|
+
updatedStatus?: TddTaskStatus;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Determine whether a file path looks like a test file.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isTestFile(filePath: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Load the persisted TDD status for a given task.
|
|
31
|
+
* Returns `null` when no status has been recorded yet.
|
|
32
|
+
*/
|
|
33
|
+
export declare function loadTddStatus(changePath: string, taskId: string): Promise<TddTaskStatus | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Persist the TDD status for a task into `metrics/tdd-phases.json`.
|
|
36
|
+
* Creates the file (and parent directories) when they do not exist.
|
|
37
|
+
*/
|
|
38
|
+
export declare function saveTddStatus(changePath: string, taskId: string, status: TddTaskStatus): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Determine TDD phase transition based on the current status and latest event.
|
|
41
|
+
*
|
|
42
|
+
* Transition rules:
|
|
43
|
+
* PENDING → RED when a test file is created
|
|
44
|
+
* RED → GREEN when tests pass after initial failure is recorded
|
|
45
|
+
* GREEN → REFACTOR when an implementation file is modified / created after tests pass
|
|
46
|
+
* REFACTOR → DONE when tests still pass after refactoring
|
|
47
|
+
*/
|
|
48
|
+
export declare function detectTddPhaseTransition(currentStatus: TddTaskStatus, toolName: string, filePath: string, toolOutput?: string): TddTaskStatus;
|
|
49
|
+
/**
|
|
50
|
+
* TDD Phase Hook – called after tool use to enforce TDD discipline.
|
|
51
|
+
*
|
|
52
|
+
* @param changePath Absolute path to the current change directory
|
|
53
|
+
* @param toolName Name of the tool just used (e.g. 'Write', 'Edit', 'Bash')
|
|
54
|
+
* @param filePath File affected by the tool invocation
|
|
55
|
+
* @param taskId Current task identifier (e.g. "2.1")
|
|
56
|
+
* @param toolOutput Optional output captured from Bash executions
|
|
57
|
+
*/
|
|
58
|
+
export declare function tddPhaseHook(changePath: string, toolName: string, filePath: string, taskId: string, toolOutput?: string): Promise<TddHookResult>;
|
|
59
|
+
//# sourceMappingURL=tdd-phase-hook.d.ts.map
|