@zhuan-ai/zhuanspec 2.9.5 → 2.10.0
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/hooks.js +8 -8
- package/dist/commands/design.js +21 -0
- package/dist/commands/review.d.ts +0 -25
- package/dist/commands/review.js +35 -405
- package/dist/core/hooks/collect-knowledge.d.ts +13 -5
- package/dist/core/hooks/collect-knowledge.js +56 -164
- package/dist/core/hooks/deviation-check.js +117 -55
- package/dist/core/hooks/init.js +30 -1
- package/dist/core/hooks/pre-archive.js +13 -14
- package/dist/core/hooks/review-hooks.js +5 -4
- package/dist/core/hooks/review-orchestrator.js +4 -4
- package/dist/core/init.js +18 -2
- package/dist/core/templates/slash-command-templates.js +41 -21
- package/dist/core/update.d.ts +3 -0
- package/dist/core/update.js +85 -2
- package/package.json +1 -1
package/dist/cli/hooks.js
CHANGED
|
@@ -240,7 +240,7 @@ program
|
|
|
240
240
|
codeReview: codeReviewResult,
|
|
241
241
|
unitTest: unitTestResult,
|
|
242
242
|
specConsistency: specConsistencyResult,
|
|
243
|
-
reportPath: `zhuanspec/changes/${changeId}/review-report.md`,
|
|
243
|
+
reportPath: `zhuanspec/changes/${changeId}/review/review-report.md`,
|
|
244
244
|
}));
|
|
245
245
|
}
|
|
246
246
|
else {
|
|
@@ -355,10 +355,10 @@ async function runPhaseTransitionCheck(options) {
|
|
|
355
355
|
};
|
|
356
356
|
case 'archive':
|
|
357
357
|
// Review → Archive: Check for review completion
|
|
358
|
-
const
|
|
359
|
-
const reviewPath = path.join(
|
|
360
|
-
const testsPath = path.join(
|
|
361
|
-
const reportPath = path.join(
|
|
358
|
+
const reviewDir = path.join(changeDir, 'review');
|
|
359
|
+
const reviewPath = path.join(reviewDir, 'code-review-result.json');
|
|
360
|
+
const testsPath = path.join(reviewDir, 'unit-test-result.json');
|
|
361
|
+
const reportPath = path.join(reviewDir, 'review-report.md');
|
|
362
362
|
const checks = {
|
|
363
363
|
reviewCompleted: false,
|
|
364
364
|
testsCompleted: false,
|
|
@@ -370,11 +370,11 @@ async function runPhaseTransitionCheck(options) {
|
|
|
370
370
|
if (!checks.reviewCompleted || !checks.testsCompleted || !checks.reportExists) {
|
|
371
371
|
const missing = [];
|
|
372
372
|
if (!checks.reviewCompleted)
|
|
373
|
-
missing.push('review.json');
|
|
373
|
+
missing.push('review/code-review-result.json');
|
|
374
374
|
if (!checks.testsCompleted)
|
|
375
|
-
missing.push('
|
|
375
|
+
missing.push('review/unit-test-result.json');
|
|
376
376
|
if (!checks.reportExists)
|
|
377
|
-
missing.push('review-report.md');
|
|
377
|
+
missing.push('review/review-report.md');
|
|
378
378
|
return {
|
|
379
379
|
continue: false,
|
|
380
380
|
systemMessage: `✗ Phase transition blocked: ${fromPhase} → ${targetPhase}\n\nMissing review artifacts:\n${missing.map(m => `- ${m}`).join('\n')}\n\nPlease complete Review phase before archiving.`,
|
package/dist/commands/design.js
CHANGED
|
@@ -14,6 +14,25 @@ const DEFAULT_DESIGN_TEMPLATE = `## 背景
|
|
|
14
14
|
- Decision: [内容和原因]
|
|
15
15
|
- Alternatives considered: [选项 + 理由]
|
|
16
16
|
|
|
17
|
+
## 接口设计
|
|
18
|
+
<!-- 接口签名、入参出参字段类型、枚举值、SCF 方法名 -->
|
|
19
|
+
|
|
20
|
+
## 数据模型
|
|
21
|
+
<!-- DB schema 变更、实体类字段、索引设计 -->
|
|
22
|
+
|
|
23
|
+
## 业务流程
|
|
24
|
+
<!-- Mermaid 流程图或时序图 -->
|
|
25
|
+
\`\`\`mermaid
|
|
26
|
+
flowchart TD
|
|
27
|
+
A[开始] --> B[...]
|
|
28
|
+
\`\`\`
|
|
29
|
+
|
|
30
|
+
## 实现细节
|
|
31
|
+
<!-- 分层实现说明(Component→Application→Domain→Assemble→DAO)、关键逻辑、复用点 -->
|
|
32
|
+
|
|
33
|
+
## 边界条件 / 异常处理
|
|
34
|
+
<!-- 参数校验规则、错误码、降级策略 -->
|
|
35
|
+
|
|
17
36
|
## 风险 / 权衡
|
|
18
37
|
- [风险] -> 缓解措施
|
|
19
38
|
|
|
@@ -205,6 +224,8 @@ export class DesignCommand {
|
|
|
205
224
|
{ pattern: /^##\s+(背景|Background)/im, name: '背景/Background', skeleton: '## 背景\n\n[请补充背景、约束、利益相关者]\n' },
|
|
206
225
|
{ pattern: /^##\s+(目标|Goals|非目标|Non-Goals)/im, name: '目标/Goals', skeleton: '## 目标 / 非目标\n\n- Goals: [...]\n- Non-Goals: [...]\n' },
|
|
207
226
|
{ pattern: /^##\s+(决策|Decision)/im, name: '决策/Decision', skeleton: '## 决策\n\n- Decision: [内容和原因]\n- Alternatives considered: [选项 + 理由]\n' },
|
|
227
|
+
{ pattern: /^##\s+(接口设计|Interface|API)/im, name: '接口设计/Interface', skeleton: '## 接口设计\n\n<!-- 接口签名、入参出参字段类型、枚举值、SCF 方法名 -->\n' },
|
|
228
|
+
{ pattern: /^##\s+(实现细节|Implementation)/im, name: '实现细节/Implementation', skeleton: '## 实现细节\n\n<!-- 分层实现说明(Component→Application→Domain→Assemble→DAO)、关键逻辑、复用点 -->\n' },
|
|
208
229
|
{ pattern: /^##\s+(风险|权衡|Risks)/im, name: '风险/Risks', skeleton: '## 风险 / 权衡\n\n- [风险] -> 缓解措施\n' },
|
|
209
230
|
];
|
|
210
231
|
for (const section of requiredSections) {
|
|
@@ -1,32 +1,7 @@
|
|
|
1
1
|
export declare class ReviewCommand {
|
|
2
2
|
execute(changeName?: string, options?: {
|
|
3
3
|
json?: boolean;
|
|
4
|
-
testCommand?: string;
|
|
5
|
-
coverageThreshold?: string;
|
|
6
4
|
}): Promise<void>;
|
|
7
5
|
private selectChangeInteractively;
|
|
8
|
-
private collectRequirementNames;
|
|
9
|
-
private collectTouchedFileContent;
|
|
10
|
-
private runTests;
|
|
11
|
-
private buildSummary;
|
|
12
|
-
private buildSpecConsistency;
|
|
13
|
-
private printSummary;
|
|
14
|
-
/**
|
|
15
|
-
* Fetch Sonar issues using MCP
|
|
16
|
-
* Uses the first detected repository's branch for SonarQube queries
|
|
17
|
-
*/
|
|
18
|
-
private fetchSonarIssues;
|
|
19
|
-
/**
|
|
20
|
-
* Collect all Scenarios from delta specs
|
|
21
|
-
*/
|
|
22
|
-
private collectScenarios;
|
|
23
|
-
/**
|
|
24
|
-
* Find test files in the project
|
|
25
|
-
*/
|
|
26
|
-
private findTestFiles;
|
|
27
|
-
/**
|
|
28
|
-
* Convert Scenario name to possible test method names
|
|
29
|
-
*/
|
|
30
|
-
private scenarioToTestMethodNames;
|
|
31
6
|
}
|
|
32
7
|
//# sourceMappingURL=review.d.ts.map
|
package/dist/commands/review.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promises as fs, readdirSync, existsSync } from 'fs';
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
5
|
-
import {
|
|
6
|
-
import { mcp__sonar__get_Sonar_Query } from '../mcp/index.js';
|
|
7
|
-
import { writeCodeReviewOutput, writeUnitTestOutput, writeSpecConsistencyOutput, codeReviewResultCheck, unitTestResultCheck, specConsistencyResultCheck, generateReviewReport, } from '../core/hooks/review-hooks.js';
|
|
4
|
+
import { codeReviewResultCheck, unitTestResultCheck, specConsistencyResultCheck, generateReviewReport, } from '../core/hooks/review-hooks.js';
|
|
8
5
|
export class ReviewCommand {
|
|
9
6
|
async execute(changeName, options) {
|
|
10
7
|
if (!changeName) {
|
|
@@ -16,136 +13,33 @@ export class ReviewCommand {
|
|
|
16
13
|
}
|
|
17
14
|
const cwd = process.cwd();
|
|
18
15
|
const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeName);
|
|
19
|
-
const
|
|
16
|
+
const reviewDir = path.join(changeDir, 'review');
|
|
20
17
|
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
21
18
|
const tasksPath = path.join(changeDir, 'tasks.md');
|
|
22
|
-
|
|
19
|
+
// Check proposal and tasks exist
|
|
23
20
|
const proposalExists = await FileSystemUtils.fileExists(proposalPath);
|
|
24
21
|
const tasksExists = await FileSystemUtils.fileExists(tasksPath);
|
|
25
22
|
if (!proposalExists || !tasksExists) {
|
|
26
23
|
throw new Error(`Change '${changeName}' is not ready for review. Missing proposal.md or tasks.md`);
|
|
27
24
|
}
|
|
28
|
-
//
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const touchedContent = await this.collectTouchedFileContent(cwd, touchedFiles);
|
|
35
|
-
// Track whether every requirement has at least one weak signal in changed code.
|
|
36
|
-
for (const req of requirementNames) {
|
|
37
|
-
const normalized = req.toLowerCase();
|
|
38
|
-
const matched = touchedContent.some((content) => content.includes(normalized));
|
|
39
|
-
if (!matched) {
|
|
40
|
-
issues.push({
|
|
41
|
-
severity: 'important',
|
|
42
|
-
message: `Requirement may not be mapped to implementation: ${req}`,
|
|
43
|
-
source: 'specCompliance',
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
|
|
48
|
-
const incompleteTasks = (tasksContent.match(/^-\s*\[\s\]\s+/gm) ?? []).length;
|
|
49
|
-
if (incompleteTasks > 0) {
|
|
50
|
-
issues.push({
|
|
51
|
-
severity: 'critical',
|
|
52
|
-
message: `Found ${incompleteTasks} incomplete tasks in tasks.md`,
|
|
53
|
-
source: 'specCompliance',
|
|
54
|
-
});
|
|
25
|
+
// Check skill output JSON files exist
|
|
26
|
+
const codeReviewJsonPath = path.join(reviewDir, 'code-review-result.json');
|
|
27
|
+
const unitTestJsonPath = path.join(reviewDir, 'unit-test-result.json');
|
|
28
|
+
const missingFiles = [];
|
|
29
|
+
if (!(await FileSystemUtils.fileExists(codeReviewJsonPath))) {
|
|
30
|
+
missingFiles.push('code-review-result.json(需先运行 code-review-expert skill)');
|
|
55
31
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const file of touchedFiles) {
|
|
59
|
-
if (file.endsWith('.ts') || (file.endsWith('.js') && !file.includes('node_modules'))) {
|
|
60
|
-
const content = await fs.readFile(file, 'utf-8');
|
|
61
|
-
if (content.includes('TODO') || content.includes('FIXME')) {
|
|
62
|
-
issues.push({
|
|
63
|
-
severity: 'minor',
|
|
64
|
-
message: `Found TODO/FIXME in ${file}`,
|
|
65
|
-
source: 'codeQuality',
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
}
|
|
32
|
+
if (!(await FileSystemUtils.fileExists(unitTestJsonPath))) {
|
|
33
|
+
missingFiles.push('unit-test-result.json(需先运行 generate-mockito-unit-test skill)');
|
|
69
34
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
message: `Sonar ${issue.type}: ${issue.message} (${issue.file}${issue.line ? ':' + issue.line : ''})`,
|
|
77
|
-
source: 'codeQuality',
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
// Scenario→Test mapping validation
|
|
81
|
-
const scenarios = await this.collectScenarios(changeSpecsDir);
|
|
82
|
-
const testFiles = await this.findTestFiles(cwd);
|
|
83
|
-
for (const scenario of scenarios) {
|
|
84
|
-
const testPatterns = this.scenarioToTestMethodNames(scenario.name);
|
|
85
|
-
const matched = testFiles.some(tf => testPatterns.some(p => tf.toLowerCase().includes(p.toLowerCase())));
|
|
86
|
-
if (!matched) {
|
|
87
|
-
issues.push({
|
|
88
|
-
severity: 'important',
|
|
89
|
-
message: `Scenario "${scenario.name}" 未找到对应测试文件`,
|
|
90
|
-
source: 'specCompliance',
|
|
91
|
-
});
|
|
92
|
-
}
|
|
35
|
+
if (missingFiles.length > 0) {
|
|
36
|
+
console.error(`❌ skill 输出缺失,请先执行以下步骤:`);
|
|
37
|
+
missingFiles.forEach((f, i) => console.error(` ${i + 1}. ${f}`));
|
|
38
|
+
console.error(` ${missingFiles.length + 1}. spec-consistency-result.json(运行 zhuanspec validate <id> --strict 后由 AI 写入)`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
93
41
|
}
|
|
94
|
-
|
|
95
|
-
const testResult = this.runTests(cwd, testCommand);
|
|
96
|
-
const coverageThreshold = Number(options?.coverageThreshold ?? 80);
|
|
97
|
-
if (testResult.coverage !== null && testResult.coverage < coverageThreshold) {
|
|
98
|
-
issues.push({
|
|
99
|
-
severity: 'important',
|
|
100
|
-
message: `Coverage ${testResult.coverage}% is below threshold ${coverageThreshold}%`,
|
|
101
|
-
source: 'unitTests',
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
if (testResult.failed > 0) {
|
|
105
|
-
issues.push({
|
|
106
|
-
severity: 'critical',
|
|
107
|
-
message: `Unit tests failed: ${testResult.failed}`,
|
|
108
|
-
source: 'unitTests',
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
const summary = this.buildSummary(issues, testResult, testCommand);
|
|
112
|
-
const consistency = this.buildSpecConsistency(requirementNames, scenarios, touchedFiles, testFiles);
|
|
113
|
-
// Persist skill-compatible outputs so hooks and command share one result chain.
|
|
114
|
-
await writeCodeReviewOutput(changeName, {
|
|
115
|
-
criticalCount: summary.summary.critical,
|
|
116
|
-
importantCount: summary.summary.important,
|
|
117
|
-
infoCount: summary.summary.minor,
|
|
118
|
-
issues: summary.issues.map((issue) => ({
|
|
119
|
-
level: issue.severity === 'minor' ? 'info' : issue.severity,
|
|
120
|
-
file: issue.source,
|
|
121
|
-
message: issue.message,
|
|
122
|
-
})),
|
|
123
|
-
sonarStatus: sonarIssues.length > 0 ? 'pass' : 'skip',
|
|
124
|
-
loopCount: 0,
|
|
125
|
-
});
|
|
126
|
-
await writeUnitTestOutput(changeName, {
|
|
127
|
-
testPassed: testResult.failed === 0,
|
|
128
|
-
passRate: testResult.failed === 0 ? 100 : 0,
|
|
129
|
-
coverage: testResult.coverage ?? 0,
|
|
130
|
-
coverageThreshold,
|
|
131
|
-
newTestsGenerated: [],
|
|
132
|
-
failedTests: testResult.failed > 0 ? ['test-command-failed'] : [],
|
|
133
|
-
loopCount: 0,
|
|
134
|
-
});
|
|
135
|
-
await writeSpecConsistencyOutput(changeName, {
|
|
136
|
-
consistencyRate: consistency.consistencyRate,
|
|
137
|
-
totalRequirements: consistency.totalRequirements,
|
|
138
|
-
totalScenarios: consistency.totalScenarios,
|
|
139
|
-
coveredScenarios: consistency.coveredScenarios,
|
|
140
|
-
uncoveredScenarios: consistency.uncoveredScenarios.map((scenario, idx) => ({
|
|
141
|
-
requirementId: `REQ-${String(idx + 1).padStart(3, '0')}`,
|
|
142
|
-
scenarioId: `SC-${String(idx + 1).padStart(3, '0')}`,
|
|
143
|
-
description: scenario.name,
|
|
144
|
-
})),
|
|
145
|
-
mapping: consistency.mapping,
|
|
146
|
-
loopCount: 0,
|
|
147
|
-
});
|
|
148
|
-
// Build unified review-report.md from hook checkers.
|
|
42
|
+
// Read hook results and generate report
|
|
149
43
|
const codeReviewResult = await codeReviewResultCheck(changeName);
|
|
150
44
|
const unitTestResult = await unitTestResultCheck(changeName);
|
|
151
45
|
const specConsistencyResult = await specConsistencyResultCheck(changeName);
|
|
@@ -154,16 +48,25 @@ export class ReviewCommand {
|
|
|
154
48
|
unitTest: unitTestResult,
|
|
155
49
|
specConsistency: specConsistencyResult,
|
|
156
50
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
await FileSystemUtils.writeFile(path.join(metricsDir, 'tests.json'), JSON.stringify(testResult, null, 2));
|
|
51
|
+
// Output results
|
|
52
|
+
const overallPass = codeReviewResult.pass && unitTestResult.pass && specConsistencyResult.pass;
|
|
160
53
|
if (options?.json) {
|
|
161
|
-
console.log(JSON.stringify(
|
|
54
|
+
console.log(JSON.stringify({
|
|
55
|
+
passed: overallPass,
|
|
56
|
+
codeReview: codeReviewResult,
|
|
57
|
+
unitTest: unitTestResult,
|
|
58
|
+
specConsistency: specConsistencyResult,
|
|
59
|
+
}, null, 2));
|
|
162
60
|
}
|
|
163
61
|
else {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
62
|
+
console.log(`Review report for ${changeName}`);
|
|
63
|
+
console.log(`Overall: ${overallPass ? 'PASS ✅' : 'FAIL ❌'}`);
|
|
64
|
+
console.log(`Code Review: ${codeReviewResult.pass ? 'PASS' : 'FAIL'} (critical=${codeReviewResult.metrics.criticalCount ?? 0})`);
|
|
65
|
+
console.log(`Unit Test: ${unitTestResult.pass ? 'PASS' : 'FAIL'} (coverage=${unitTestResult.metrics.coverage ?? 0}%)`);
|
|
66
|
+
console.log(`Spec-Code: ${specConsistencyResult.pass ? 'PASS' : 'FAIL'} (consistency=${specConsistencyResult.metrics.consistencyRate ?? 0}%)`);
|
|
67
|
+
console.log(`Report: ${path.join('zhuanspec/changes', changeName, 'review/review-report.md')}`);
|
|
68
|
+
}
|
|
69
|
+
process.exitCode = overallPass ? 0 : 1;
|
|
167
70
|
}
|
|
168
71
|
async selectChangeInteractively() {
|
|
169
72
|
const changesDir = path.join(process.cwd(), 'zhuanspec', 'changes');
|
|
@@ -195,278 +98,5 @@ export class ReviewCommand {
|
|
|
195
98
|
return null;
|
|
196
99
|
}
|
|
197
100
|
}
|
|
198
|
-
async collectRequirementNames(changeSpecsDir) {
|
|
199
|
-
const requirements = [];
|
|
200
|
-
const walk = async (dir) => {
|
|
201
|
-
let entries = [];
|
|
202
|
-
try {
|
|
203
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
for (const entry of entries) {
|
|
209
|
-
const full = path.join(dir, entry.name);
|
|
210
|
-
if (entry.isDirectory()) {
|
|
211
|
-
await walk(full);
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
if (entry.isFile() && entry.name === 'spec.md') {
|
|
215
|
-
const content = await fs.readFile(full, 'utf-8');
|
|
216
|
-
const matches = content.match(/^###\s+(Requirement|需求):\s+(.+)$/gim) ?? [];
|
|
217
|
-
for (const line of matches) {
|
|
218
|
-
const parts = line.split(':');
|
|
219
|
-
const name = parts.slice(1).join(':').trim();
|
|
220
|
-
if (name)
|
|
221
|
-
requirements.push(name);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
await walk(changeSpecsDir);
|
|
227
|
-
return Array.from(new Set(requirements));
|
|
228
|
-
}
|
|
229
|
-
async collectTouchedFileContent(repoRoot, files) {
|
|
230
|
-
const result = [];
|
|
231
|
-
for (const file of files) {
|
|
232
|
-
try {
|
|
233
|
-
const full = path.isAbsolute(file) ? file : path.join(repoRoot, file);
|
|
234
|
-
const stat = await fs.stat(full);
|
|
235
|
-
if (!stat.isFile())
|
|
236
|
-
continue;
|
|
237
|
-
const content = await fs.readFile(full, 'utf-8');
|
|
238
|
-
result.push(content.toLowerCase());
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
// ignore unreadable files
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return result;
|
|
245
|
-
}
|
|
246
|
-
runTests(repoRoot, command) {
|
|
247
|
-
try {
|
|
248
|
-
const output = execSync(command, { cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
249
|
-
const coverageMatch = output.match(/(\d+(?:\.\d+)?)%\s*(?:\|)?\s*Statements/i);
|
|
250
|
-
return {
|
|
251
|
-
passed: 1,
|
|
252
|
-
failed: 0,
|
|
253
|
-
coverage: coverageMatch ? Number(coverageMatch[1]) : null,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
const stdout = error?.stdout?.toString?.() ?? '';
|
|
258
|
-
const stderr = error?.stderr?.toString?.() ?? '';
|
|
259
|
-
const combined = `${stdout}\n${stderr}`;
|
|
260
|
-
const coverageMatch = combined.match(/(\d+(?:\.\d+)?)%\s*(?:\|)?\s*Statements/i);
|
|
261
|
-
return {
|
|
262
|
-
passed: 0,
|
|
263
|
-
failed: 1,
|
|
264
|
-
coverage: coverageMatch ? Number(coverageMatch[1]) : null,
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
buildSummary(issues, testResult, command) {
|
|
269
|
-
const critical = issues.filter((i) => i.severity === 'critical').length;
|
|
270
|
-
const important = issues.filter((i) => i.severity === 'important').length;
|
|
271
|
-
const minor = issues.filter((i) => i.severity === 'minor').length;
|
|
272
|
-
return {
|
|
273
|
-
passed: critical === 0 && testResult.failed === 0,
|
|
274
|
-
summary: { critical, important, minor },
|
|
275
|
-
checks: {
|
|
276
|
-
specCompliance: !issues.some((i) => i.source === 'specCompliance' && i.severity === 'critical'),
|
|
277
|
-
codeQuality: !issues.some((i) => i.source === 'codeQuality' && i.severity === 'critical'),
|
|
278
|
-
unitTests: testResult.failed === 0,
|
|
279
|
-
},
|
|
280
|
-
test: {
|
|
281
|
-
passed: testResult.passed,
|
|
282
|
-
failed: testResult.failed,
|
|
283
|
-
coverage: testResult.coverage,
|
|
284
|
-
command,
|
|
285
|
-
},
|
|
286
|
-
issues,
|
|
287
|
-
generatedAt: new Date().toISOString(),
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
buildSpecConsistency(requirementNames, scenarios, touchedFiles, testFiles) {
|
|
291
|
-
const normalizedTouched = touchedFiles.map((file) => file.toLowerCase());
|
|
292
|
-
const normalizedTests = testFiles.map((file) => file.toLowerCase());
|
|
293
|
-
const mapping = [];
|
|
294
|
-
const uncoveredScenarios = [];
|
|
295
|
-
scenarios.forEach((scenario, idx) => {
|
|
296
|
-
const patterns = this.scenarioToTestMethodNames(scenario.name).map((p) => p.toLowerCase());
|
|
297
|
-
const testCovered = normalizedTests.some((testFile) => patterns.some((p) => testFile.includes(p)));
|
|
298
|
-
const codeCovered = normalizedTouched.some((codeFile) => patterns.some((p) => codeFile.includes(p)));
|
|
299
|
-
const status = testCovered && codeCovered
|
|
300
|
-
? 'covered'
|
|
301
|
-
: (testCovered || codeCovered ? 'partial' : 'missing');
|
|
302
|
-
mapping.push({
|
|
303
|
-
requirementId: `REQ-${String((idx % Math.max(requirementNames.length, 1)) + 1).padStart(3, '0')}`,
|
|
304
|
-
scenarioId: `SC-${String(idx + 1).padStart(3, '0')}`,
|
|
305
|
-
codeFile: touchedFiles[0] || '',
|
|
306
|
-
status,
|
|
307
|
-
});
|
|
308
|
-
if (status !== 'covered') {
|
|
309
|
-
uncoveredScenarios.push(scenario);
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
const totalScenarios = scenarios.length;
|
|
313
|
-
const coveredScenarios = totalScenarios - uncoveredScenarios.length;
|
|
314
|
-
const consistencyRate = totalScenarios === 0 ? 100 : Math.round((coveredScenarios / totalScenarios) * 100);
|
|
315
|
-
return {
|
|
316
|
-
consistencyRate,
|
|
317
|
-
totalRequirements: requirementNames.length,
|
|
318
|
-
totalScenarios,
|
|
319
|
-
coveredScenarios,
|
|
320
|
-
uncoveredScenarios,
|
|
321
|
-
mapping,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
printSummary(changeName, summary) {
|
|
325
|
-
console.log(`Review report for ${changeName}`);
|
|
326
|
-
console.log(`Passed: ${summary.passed ? 'YES' : 'NO'}`);
|
|
327
|
-
console.log(`Issues: critical=${summary.summary.critical}, important=${summary.summary.important}, minor=${summary.summary.minor}`);
|
|
328
|
-
console.log(`Tests: passed=${summary.test.passed}, failed=${summary.test.failed}, coverage=${summary.test.coverage ?? 'N/A'}`);
|
|
329
|
-
if (summary.issues.length > 0) {
|
|
330
|
-
console.log('Findings:');
|
|
331
|
-
for (const item of summary.issues) {
|
|
332
|
-
console.log(`- [${item.severity}] (${item.source}) ${item.message}`);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Fetch Sonar issues using MCP
|
|
338
|
-
* Uses the first detected repository's branch for SonarQube queries
|
|
339
|
-
*/
|
|
340
|
-
async fetchSonarIssues(allRepos) {
|
|
341
|
-
// If no repos detected, skip Sonar queries
|
|
342
|
-
if (allRepos.length === 0) {
|
|
343
|
-
return [];
|
|
344
|
-
}
|
|
345
|
-
try {
|
|
346
|
-
// Use the first detected repository's branch
|
|
347
|
-
const primaryRepo = allRepos[0];
|
|
348
|
-
const branch = execSync('git branch --show-current', {
|
|
349
|
-
cwd: primaryRepo,
|
|
350
|
-
encoding: 'utf-8',
|
|
351
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
352
|
-
}).trim();
|
|
353
|
-
// Query different types
|
|
354
|
-
const types = ['BUG', 'VULNERABILITY', 'CODE_SMELL'];
|
|
355
|
-
const allIssues = [];
|
|
356
|
-
for (const type of types) {
|
|
357
|
-
try {
|
|
358
|
-
const result = await mcp__sonar__get_Sonar_Query({
|
|
359
|
-
branchName: branch,
|
|
360
|
-
staticType: type,
|
|
361
|
-
});
|
|
362
|
-
if (result && Array.isArray(result)) {
|
|
363
|
-
for (const issue of result) {
|
|
364
|
-
allIssues.push({
|
|
365
|
-
type: type,
|
|
366
|
-
severity: issue.severity || 'MAJOR',
|
|
367
|
-
message: issue.message || '',
|
|
368
|
-
file: issue.file || '',
|
|
369
|
-
line: issue.line,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
// Sonar MCP might not be configured, skip silently
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return allIssues;
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
// Git command failed or Sonar not available
|
|
382
|
-
return [];
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Collect all Scenarios from delta specs
|
|
387
|
-
*/
|
|
388
|
-
async collectScenarios(changeSpecsDir) {
|
|
389
|
-
const scenarios = [];
|
|
390
|
-
const walk = async (dir) => {
|
|
391
|
-
let entries = [];
|
|
392
|
-
try {
|
|
393
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
for (const entry of entries) {
|
|
399
|
-
const full = path.join(dir, entry.name);
|
|
400
|
-
if (entry.isDirectory()) {
|
|
401
|
-
await walk(full);
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
if (entry.isFile() && entry.name === 'spec.md') {
|
|
405
|
-
const content = await fs.readFile(full, 'utf-8');
|
|
406
|
-
// Match Scenario headers
|
|
407
|
-
const matches = content.matchAll(/^####\s+(Scenario|场景)[::]\s+(.+)$/gim);
|
|
408
|
-
for (const match of matches) {
|
|
409
|
-
const name = match[2].trim();
|
|
410
|
-
scenarios.push({ name, specFile: full });
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
|
-
await walk(changeSpecsDir);
|
|
416
|
-
return scenarios;
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Find test files in the project
|
|
420
|
-
*/
|
|
421
|
-
async findTestFiles(repoRoot) {
|
|
422
|
-
const testFiles = [];
|
|
423
|
-
const testDirs = ['test', 'tests', 'src/test', 'spec', '__tests__'];
|
|
424
|
-
for (const testDir of testDirs) {
|
|
425
|
-
const fullDir = path.join(repoRoot, testDir);
|
|
426
|
-
try {
|
|
427
|
-
if (!existsSync(fullDir))
|
|
428
|
-
continue;
|
|
429
|
-
const walk = (dir) => {
|
|
430
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
431
|
-
for (const entry of entries) {
|
|
432
|
-
const full = path.join(dir, entry.name);
|
|
433
|
-
if (entry.isDirectory()) {
|
|
434
|
-
walk(full);
|
|
435
|
-
}
|
|
436
|
-
else if (entry.isFile() && (entry.name.endsWith('.test.ts') ||
|
|
437
|
-
entry.name.endsWith('.test.js') ||
|
|
438
|
-
entry.name.endsWith('.spec.ts') ||
|
|
439
|
-
entry.name.endsWith('.spec.js') ||
|
|
440
|
-
entry.name.includes('Test'))) {
|
|
441
|
-
testFiles.push(path.relative(repoRoot, full));
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
};
|
|
445
|
-
walk(fullDir);
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
// Directory doesn't exist or can't be read
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return testFiles;
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Convert Scenario name to possible test method names
|
|
455
|
-
*/
|
|
456
|
-
scenarioToTestMethodNames(scenarioName) {
|
|
457
|
-
const name = scenarioName.replace(/[::]/g, '').trim();
|
|
458
|
-
return [
|
|
459
|
-
// snake_case
|
|
460
|
-
name.replace(/\s+/g, '_').toLowerCase(),
|
|
461
|
-
// camelCase
|
|
462
|
-
name.replace(/\s+/g, '').toLowerCase(),
|
|
463
|
-
// PascalCase
|
|
464
|
-
name.split(/\s+/).map(w => w[0]?.toUpperCase() + w.slice(1).toLowerCase()).join(''),
|
|
465
|
-
// Remove Chinese characters for keyword matching
|
|
466
|
-
name.replace(/[^\w\s]/g, '').trim().toLowerCase(),
|
|
467
|
-
// Key words from scenario name
|
|
468
|
-
...name.split(/\s+/).filter(w => w.length > 3),
|
|
469
|
-
].filter(n => n.length > 0);
|
|
470
|
-
}
|
|
471
101
|
}
|
|
472
102
|
//# sourceMappingURL=review.js.map
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PostToolUse Hook - Collect Knowledge
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Fires after Write/Edit in Apply phase. Injects a lightweight prompt
|
|
5
|
+
* into Claude's context so the LLM can decide (with user confirmation)
|
|
6
|
+
* whether the change is worth capturing as project knowledge.
|
|
7
|
+
*
|
|
8
|
+
* Design principle: no keyword matching, no auto-save — the LLM decides.
|
|
8
9
|
*/
|
|
9
10
|
interface CollectKnowledgeOptions {
|
|
10
11
|
json?: boolean;
|
|
11
12
|
file?: string;
|
|
12
|
-
|
|
13
|
+
}
|
|
14
|
+
interface HookOutput {
|
|
15
|
+
continue: boolean;
|
|
16
|
+
systemMessage?: string;
|
|
17
|
+
hookSpecificOutput?: {
|
|
18
|
+
additionalContext?: string;
|
|
19
|
+
};
|
|
13
20
|
}
|
|
14
21
|
export declare function collectKnowledgeHook(options: CollectKnowledgeOptions): Promise<void>;
|
|
22
|
+
export declare function runCollectKnowledge(filePath: string): Promise<HookOutput>;
|
|
15
23
|
export {};
|
|
16
24
|
//# sourceMappingURL=collect-knowledge.d.ts.map
|