@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 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 metricsDir = path.join(changeDir, 'metrics');
359
- const reviewPath = path.join(metricsDir, 'review.json');
360
- const testsPath = path.join(metricsDir, 'tests.json');
361
- const reportPath = path.join(changeDir, 'review-report.md');
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('tests.json');
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.`,
@@ -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
@@ -1,10 +1,7 @@
1
- import { execSync } from 'child_process';
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 { GitRepoDetector } from '../utils/git-repo-detector.js';
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 metricsDir = path.join(changeDir, 'metrics');
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
- const changeSpecsDir = path.join(changeDir, 'specs');
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
- // Detect all git repositories (parent + children)
29
- const allRepos = GitRepoDetector.detectAllGitRepos(cwd);
30
- const issues = [];
31
- const requirementNames = await this.collectRequirementNames(changeSpecsDir);
32
- // Use GitRepoDetector to get all changed files across all detected repos
33
- const touchedFiles = GitRepoDetector.getAllChanges(cwd).filter((file) => !file.startsWith('zhuanspec/changes/archive/'));
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
- // Very lightweight code-quality checks.
57
- // touchedFiles now contains absolute paths from GitRepoDetector
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
- // Sonar MCP integration - fetch issues from SonarQube (use first detected repo if available)
71
- const sonarIssues = await this.fetchSonarIssues(allRepos);
72
- for (const issue of sonarIssues) {
73
- const severity = issue.type === 'BUG' || issue.type === 'VULNERABILITY' ? 'critical' : 'important';
74
- issues.push({
75
- severity,
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
- const testCommand = options?.testCommand?.trim() || 'npm test';
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
- await FileSystemUtils.createDirectory(metricsDir);
158
- await FileSystemUtils.writeFile(path.join(metricsDir, 'review.json'), JSON.stringify(summary, null, 2));
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(summary, null, 2));
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
- this.printSummary(changeName, summary);
165
- }
166
- process.exitCode = summary.passed ? 0 : 1;
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
- * This hook runs after Write/Edit operations to:
5
- * 1. Detect pitfall keywords in content or conversation
6
- * 2. Extract and save troubleshooting knowledge
7
- * 3. Record implicit conventions discovered
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
- content?: string;
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