@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.
@@ -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
- const output = skillOutput || await readSkillOutput(changeId, SPEC_CONSISTENCY_OUTPUT);
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 Requirement 一致
72
+ 验证 changeId=${changeId} 的代码实现与 Spec Scenario 语义一致。
73
+ ⚠️ 禁止用 Requirement 标题做字符串匹配,必须逐条读取 Scenario 内容做语义比对。
73
74
 
74
75
  ## 自闭环指令
75
- 1. 解析 specs/*.md 中的所有 Requirement 和 Scenario
76
- 2. 检查代码实现是否覆盖每个 Scenario
77
- 3. 如果发现未覆盖,立即补充代码或更新 Spec
78
- 4. 修复后重新检查(最多3轮)
79
- 5. 输出 spec-consistency-result.json 报告到 zhuanspec/changes/${changeId}/review/
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