ai-git-tools 2.0.68 → 2.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +14 -90
- package/package.json +1 -1
- package/src/commands/dev-from-issue.js +0 -4
- package/src/core/ai-client.js +1 -1
- package/src/pr-modules/ai/code-analyzer.js +42 -27
- package/src/pr-modules/core/workflow.js +54 -49
- package/src/commands/auto-dev.js +0 -256
- package/src/commands/generate-code.js +0 -115
- package/src/commands/plan-issue.js +0 -91
- package/src/commands/write-and-test.js +0 -469
- package/src/core/test-generator.js +0 -165
- package/src/core/test-runner.js +0 -132
|
@@ -1,469 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* write-and-test 命令
|
|
3
|
-
* 為指定檔案生成 Jest 測試,執行測試,失敗時自動修復(最多 2 次)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { resolve, basename, dirname, join } from 'path';
|
|
7
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
8
|
-
import { execSync } from 'child_process';
|
|
9
|
-
import { tmpdir } from 'os';
|
|
10
|
-
import { TestGenerator } from '../core/test-generator.js';
|
|
11
|
-
import { TestRunner } from '../core/test-runner.js';
|
|
12
|
-
import { AIClient } from '../core/ai-client.js';
|
|
13
|
-
import { Logger } from '../utils/logger.js';
|
|
14
|
-
|
|
15
|
-
const STATE_FILE = '.ai-git-dev-state.json';
|
|
16
|
-
|
|
17
|
-
function loadDevState() {
|
|
18
|
-
const statePath = join(process.cwd(), STATE_FILE);
|
|
19
|
-
if (!existsSync(statePath)) return null;
|
|
20
|
-
try {
|
|
21
|
-
return JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
22
|
-
} catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const TEST_TYPE_CHOICES = [
|
|
28
|
-
{
|
|
29
|
-
name: '寫測試(自動判斷 Jest 單元測試 / 元件測試)',
|
|
30
|
-
value: 'auto',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: '寫 Jest 單元測試',
|
|
34
|
-
value: 'unit',
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
name: '寫元件測試',
|
|
38
|
-
value: 'component',
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: '同時寫 Jest 單元測試 + 元件測試',
|
|
42
|
-
value: 'both',
|
|
43
|
-
},
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
export function normalizeTestTypeSelection(testType = 'auto') {
|
|
47
|
-
const rawValues = Array.isArray(testType)
|
|
48
|
-
? testType
|
|
49
|
-
: String(testType)
|
|
50
|
-
.split(',')
|
|
51
|
-
.map((value) => value.trim())
|
|
52
|
-
.filter(Boolean);
|
|
53
|
-
|
|
54
|
-
const normalizedValues = rawValues.length > 0 ? rawValues : ['auto'];
|
|
55
|
-
const resolvedValues = normalizedValues.flatMap((value) => {
|
|
56
|
-
const normalized = value.toLowerCase();
|
|
57
|
-
|
|
58
|
-
if (normalized === 'auto' || normalized === 'unit' || normalized === 'component') {
|
|
59
|
-
return normalized;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (normalized === 'both' || normalized === 'all') {
|
|
63
|
-
return ['unit', 'component'];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (normalized === 'jest' || normalized === 'jest-unit' || normalized === 'unit-test') {
|
|
67
|
-
return 'unit';
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (normalized === 'component-test' || normalized === 'react-component') {
|
|
71
|
-
return 'component';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
throw new Error(`不支援的測試類型:${value}`);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
return [...new Set(resolvedValues)];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function resolveRequestedTestTypes(sourceFilePath, requestedTestType = 'auto') {
|
|
81
|
-
const normalizedTestTypes = normalizeTestTypeSelection(requestedTestType);
|
|
82
|
-
|
|
83
|
-
if (!normalizedTestTypes.includes('auto')) {
|
|
84
|
-
return normalizedTestTypes;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const sourceCode = readFileSync(sourceFilePath, 'utf-8');
|
|
88
|
-
const inferredTestType = TestGenerator.resolveTestType(sourceFilePath, sourceCode, 'auto');
|
|
89
|
-
|
|
90
|
-
return [...new Set(
|
|
91
|
-
normalizedTestTypes.flatMap((testType) => (testType === 'auto' ? inferredTestType : testType))
|
|
92
|
-
)];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function formatTestTypes(testTypes) {
|
|
96
|
-
const labels = {
|
|
97
|
-
auto: '自動判斷',
|
|
98
|
-
unit: 'Jest 單元測試',
|
|
99
|
-
component: '元件測試',
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
return testTypes.map((testType) => labels[testType] || testType).join('、');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export async function writeAndTestCommand(options = {}) {
|
|
106
|
-
const logger = new Logger();
|
|
107
|
-
|
|
108
|
-
// 自動從 state 檔讀取 file / issue(指令列將覆蓋)
|
|
109
|
-
const state = loadDevState();
|
|
110
|
-
const issueNumber = options.issue || state?.issueNumber;
|
|
111
|
-
const maxFixes = parseInt(options.maxFixes || '3', 10);
|
|
112
|
-
|
|
113
|
-
// 決定要處理的檔案清單
|
|
114
|
-
let sourceFiles = [];
|
|
115
|
-
let fromState = false;
|
|
116
|
-
let fromGit = false;
|
|
117
|
-
|
|
118
|
-
if (options.file) {
|
|
119
|
-
// 明確指定單一檔案
|
|
120
|
-
sourceFiles = [options.file];
|
|
121
|
-
} else if (state?.filePath) {
|
|
122
|
-
// 從上一步 dev-from-issue 讀取
|
|
123
|
-
sourceFiles = [state.filePath];
|
|
124
|
-
fromState = true;
|
|
125
|
-
} else {
|
|
126
|
-
// 從 git 偵測所有異動的原始碼檔案
|
|
127
|
-
sourceFiles = await detectChangedSourceFiles(logger);
|
|
128
|
-
fromGit = sourceFiles.length > 0;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (sourceFiles.length === 0) {
|
|
132
|
-
logger.error('偵測不到異動的原始碼,請用 --file 指定目標檔案。');
|
|
133
|
-
throw new Error('缺少原始碼路徑');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
logger.header('write-and-test');
|
|
137
|
-
if (fromState) {
|
|
138
|
-
logger.info('從上一步 dev-from-issue 自動讀取');
|
|
139
|
-
} else if (fromGit) {
|
|
140
|
-
logger.info(`從 git 異動記錄偵測到 ${sourceFiles.length} 個檔案`);
|
|
141
|
-
}
|
|
142
|
-
console.log('');
|
|
143
|
-
|
|
144
|
-
const allResults = [];
|
|
145
|
-
for (const filePath of sourceFiles) {
|
|
146
|
-
const result = await processOneFile({ filePath, maxFixes, fromState, fromGit, options, logger });
|
|
147
|
-
allResults.push({ filePath, ...result });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// 所有檔案處理完成後,才發射一則聚合報告
|
|
151
|
-
if (issueNumber) {
|
|
152
|
-
await postAggregatedReport(issueNumber, allResults, options.model, logger);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return allResults.length === 1 ? allResults[0] : allResults;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function processOneFile({ filePath, maxFixes, fromState, fromGit, options, logger }) {
|
|
159
|
-
const testDir = options.testDir || '__tests__';
|
|
160
|
-
const absoluteSourcePath = resolve(process.cwd(), filePath);
|
|
161
|
-
if (!existsSync(absoluteSourcePath)) {
|
|
162
|
-
logger.error(`原始碼不存在:${absoluteSourcePath}`);
|
|
163
|
-
throw new Error(`找不到原始碼:${absoluteSourcePath}`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// 自動偵測時預設 both,手動指定時依 --test-type
|
|
167
|
-
const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || (fromState || fromGit ? 'both' : 'auto'));
|
|
168
|
-
const testTargets = resolvedTestTypes.map((testType) => ({
|
|
169
|
-
testType,
|
|
170
|
-
testFilePath: deriveTestFilePath(absoluteSourcePath, testType, testDir),
|
|
171
|
-
}));
|
|
172
|
-
|
|
173
|
-
console.log(`── ${filePath}`);
|
|
174
|
-
console.log(` 測試類型:${formatTestTypes(resolvedTestTypes)}`);
|
|
175
|
-
testTargets.forEach(({ testType, testFilePath }) => {
|
|
176
|
-
console.log(` ${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
|
|
177
|
-
});
|
|
178
|
-
console.log('');
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// 1. 生成測試
|
|
182
|
-
logger.step('正在生成測試代碼...');
|
|
183
|
-
const generatedTests = [];
|
|
184
|
-
for (const { testType, testFilePath } of testTargets) {
|
|
185
|
-
const generatedTest = await TestGenerator.generateTests(
|
|
186
|
-
absoluteSourcePath,
|
|
187
|
-
testFilePath,
|
|
188
|
-
testType,
|
|
189
|
-
options.model
|
|
190
|
-
);
|
|
191
|
-
generatedTests.push(generatedTest);
|
|
192
|
-
logger.success(`測試已寫入:${testFilePath}`);
|
|
193
|
-
}
|
|
194
|
-
logger.info(`實際測試類型:${formatTestTypes(generatedTests.map(({ testType }) => testType))}`);
|
|
195
|
-
|
|
196
|
-
// 2. 執行測試 + 自動修復循環
|
|
197
|
-
let lastErrors = [];
|
|
198
|
-
for (let attempt = 0; attempt <= maxFixes; attempt++) {
|
|
199
|
-
if (attempt > 0) {
|
|
200
|
-
logger.step(`正在進行第 ${attempt}/${maxFixes} 次自動修復...`);
|
|
201
|
-
await TestGenerator.generateFix(absoluteSourcePath, lastErrors, attempt, options.model);
|
|
202
|
-
logger.success('修復代碼已更新。');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
logger.step(`正在執行測試${attempt > 0 ? `(第 ${attempt} 次修復後)` : ''}...`);
|
|
206
|
-
const result = await TestRunner.runTests(generatedTests.map(({ testFilePath }) => testFilePath));
|
|
207
|
-
|
|
208
|
-
if (result.success) {
|
|
209
|
-
logger.success('所有測試通過! 🎉');
|
|
210
|
-
return {
|
|
211
|
-
success: true,
|
|
212
|
-
testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
|
|
213
|
-
testTypes: generatedTests.map(({ testType }) => testType),
|
|
214
|
-
attempts: attempt,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
lastErrors = result.errors;
|
|
219
|
-
logger.warning(`測試失敗(${result.errors.length} 個錯誤):`);
|
|
220
|
-
result.errors.forEach((e) => {
|
|
221
|
-
console.log('');
|
|
222
|
-
console.log(e.split('\n').slice(0, 10).join('\n'));
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
if (attempt >= maxFixes) {
|
|
226
|
-
// 已達最大嘗試次數,升級給使用者
|
|
227
|
-
logger.section('⚠️ 自動修復失敗,需要人工介入');
|
|
228
|
-
console.log(`已嘗試自動修復 ${maxFixes} 次,測試仍未通過。`);
|
|
229
|
-
console.log('');
|
|
230
|
-
console.log('請手動查看以下錯誤並修正:');
|
|
231
|
-
lastErrors.forEach((e, i) => {
|
|
232
|
-
console.log(`\n[錯誤 ${i + 1}]`);
|
|
233
|
-
console.log(e);
|
|
234
|
-
});
|
|
235
|
-
console.log('');
|
|
236
|
-
console.log(`原始碼路徑:${absoluteSourcePath}`);
|
|
237
|
-
generatedTests.forEach(({ testFilePath }) => {
|
|
238
|
-
console.log(`測試路徑 :${testFilePath}`);
|
|
239
|
-
});
|
|
240
|
-
const failedResult = {
|
|
241
|
-
success: false,
|
|
242
|
-
testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
|
|
243
|
-
errors: lastErrors,
|
|
244
|
-
testTypes: generatedTests.map(({ testType }) => testType),
|
|
245
|
-
attempts: maxFixes,
|
|
246
|
-
};
|
|
247
|
-
return failedResult;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ============================================================
|
|
253
|
-
// GitHub Issue 測試報告留言
|
|
254
|
-
// ============================================================
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* 生成 mermaid 流程圖(基於原始碼邏輯)
|
|
258
|
-
*/
|
|
259
|
-
async function generateMermaidDiagram(sourceFilePath, model) {
|
|
260
|
-
const sourceFileName = basename(sourceFilePath);
|
|
261
|
-
let sourceCode = '';
|
|
262
|
-
try {
|
|
263
|
-
sourceCode = readFileSync(sourceFilePath, 'utf-8').split('\n').slice(0, 150).join('\n');
|
|
264
|
-
} catch {
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const prompt = `你是一位軟體工程師。請閱讀以下原始碼,生成一個 Mermaid flowchart 圖,描述這個模組的主要邏輯流程。
|
|
269
|
-
|
|
270
|
-
## 規則
|
|
271
|
-
- 只輸出 mermaid 程式碼區塊(含 \`\`\`mermaid 與結尾 \`\`\`),不要有任何說明
|
|
272
|
-
- 使用 flowchart TD 格式
|
|
273
|
-
- 節點文字使用繁體中文
|
|
274
|
-
- 保持簡潔,最多 20 個節點
|
|
275
|
-
|
|
276
|
-
## 檔案:${sourceFileName}
|
|
277
|
-
|
|
278
|
-
\`\`\`
|
|
279
|
-
${sourceCode}
|
|
280
|
-
\`\`\``.trim();
|
|
281
|
-
|
|
282
|
-
try {
|
|
283
|
-
const raw = await AIClient.sendAndWait(prompt, model);
|
|
284
|
-
const match = raw.match(/```mermaid[\s\S]*?```/);
|
|
285
|
-
return match ? match[0] : null;
|
|
286
|
-
} catch {
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* 將所有檔案的測試結果彙整為一則 Issue 留言,並為通過的檔案附上 mermaid 流程圖
|
|
293
|
-
* @param {Array<{filePath,success,testFilePaths,testTypes,attempts,errors?}>} allResults
|
|
294
|
-
*/
|
|
295
|
-
async function postAggregatedReport(issueNumber, allResults, model, logger) {
|
|
296
|
-
if (!allResults || allResults.length === 0) return;
|
|
297
|
-
|
|
298
|
-
logger.step(`正在將測試報告發佈到 Issue #${issueNumber}...`);
|
|
299
|
-
|
|
300
|
-
const cwd = process.cwd();
|
|
301
|
-
const total = allResults.length;
|
|
302
|
-
const passed = allResults.filter((r) => r.success).length;
|
|
303
|
-
const failed = total - passed;
|
|
304
|
-
const typeLabels = { unit: 'Jest 單元測試', component: '元件測試', auto: '自動判斷' };
|
|
305
|
-
|
|
306
|
-
// 摘要表格
|
|
307
|
-
const summaryEmoji = failed === 0 ? '✅' : '⚠️';
|
|
308
|
-
const header =
|
|
309
|
-
`## ${summaryEmoji} 測試報告總覽\n\n` +
|
|
310
|
-
`**異動檔案**:${total} 個|**通過**:${passed}|**失敗**:${failed}\n\n` +
|
|
311
|
-
`| 狀態 | 檔案 | 測試類型 | 結果 |\n` +
|
|
312
|
-
`| :---: | --- | --- | --- |\n`;
|
|
313
|
-
|
|
314
|
-
const tableRows = allResults.map((r) => {
|
|
315
|
-
const relPath = r.filePath.replace(cwd + '/', '');
|
|
316
|
-
const testTypesStr = (r.testTypes || []).map((t) => typeLabels[t] || t).join('、');
|
|
317
|
-
const statusEmoji = r.success ? '✅' : '❌';
|
|
318
|
-
const resultStr = r.success
|
|
319
|
-
? r.attempts > 0 ? `通過(修復 ${r.attempts} 次)` : '一次通過'
|
|
320
|
-
: `失敗(已嘗試修復 ${r.attempts} 次)`;
|
|
321
|
-
return `| ${statusEmoji} | \`${relPath}\` | ${testTypesStr} | ${resultStr} |`;
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
// 個別檔案詳情
|
|
325
|
-
const fileDetails = allResults.map((r) => {
|
|
326
|
-
const relPath = r.filePath.replace(cwd + '/', '');
|
|
327
|
-
const statusEmoji = r.success ? '✅' : '❌';
|
|
328
|
-
const relTestPaths = (r.testFilePaths || [])
|
|
329
|
-
.map((p) => `- \`${p.replace(cwd + '/', '')}\``)
|
|
330
|
-
.join('\n');
|
|
331
|
-
|
|
332
|
-
let section = `\n---\n\n### ${statusEmoji} \`${relPath}\`\n\n**測試檔案**:\n${relTestPaths}\n`;
|
|
333
|
-
|
|
334
|
-
if (!r.success && r.errors?.length) {
|
|
335
|
-
const errorSummary = r.errors
|
|
336
|
-
.slice(0, 2)
|
|
337
|
-
.map((e, i) => `**錯誤 ${i + 1}**\n\`\`\`\n${e.split('\n').slice(0, 12).join('\n')}\n\`\`\``)
|
|
338
|
-
.join('\n\n');
|
|
339
|
-
section += `\n**錯誤摘要**:\n\n${errorSummary}\n`;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return section;
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
// 為通過的檔案生成 mermaid 流程圖
|
|
346
|
-
const mermaidSections = [];
|
|
347
|
-
for (const r of allResults.filter((r) => r.success)) {
|
|
348
|
-
const absPath = resolve(cwd, r.filePath);
|
|
349
|
-
const diagram = await generateMermaidDiagram(absPath, model);
|
|
350
|
-
if (diagram) {
|
|
351
|
-
const relPath = r.filePath.replace(cwd + '/', '');
|
|
352
|
-
mermaidSections.push(`\n### \`${relPath}\`\n\n${diagram}`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const mermaidBlock =
|
|
357
|
-
mermaidSections.length > 0
|
|
358
|
-
? `\n---\n\n## 📊 邏輯流程圖\n${mermaidSections.join('\n')}\n`
|
|
359
|
-
: '';
|
|
360
|
-
|
|
361
|
-
const body = header + tableRows.join('\n') + fileDetails.join('') + mermaidBlock;
|
|
362
|
-
|
|
363
|
-
const tmpFile = join(tmpdir(), `ai-git-tools-report-${Date.now()}.md`);
|
|
364
|
-
try {
|
|
365
|
-
writeFileSync(tmpFile, body, 'utf-8');
|
|
366
|
-
execSync(`gh issue comment ${issueNumber} --body-file "${tmpFile}"`, {
|
|
367
|
-
encoding: 'utf-8',
|
|
368
|
-
stdio: 'pipe',
|
|
369
|
-
cwd,
|
|
370
|
-
});
|
|
371
|
-
logger.success(`測試報告已發佈到 Issue #${issueNumber}`);
|
|
372
|
-
} catch (error) {
|
|
373
|
-
logger.warning(`無法發佈到 Issue(${error.message}),請確認 gh CLI 已登入且有 Issue 存取權限。`);
|
|
374
|
-
} finally {
|
|
375
|
-
try { unlinkSync(tmpFile); } catch { /* 忽略清理失敗 */ }
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* 依據原始檔案路徑推導測試檔案路徑
|
|
381
|
-
* @param {string} testDir - 測試輸出目錄:'__tests__'(預設) 或 相對專案根的路徑(如 'tests')
|
|
382
|
-
*/
|
|
383
|
-
export function deriveTestFilePath(sourceFilePath, testType = 'unit', testDir = '__tests__') {
|
|
384
|
-
const cwd = process.cwd();
|
|
385
|
-
const name = basename(sourceFilePath).replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
|
|
386
|
-
const ext = /\.(ts|tsx)$/.test(sourceFilePath) ? '.ts' : '.js';
|
|
387
|
-
const suffix = testType === 'component' ? 'component' : 'unit';
|
|
388
|
-
|
|
389
|
-
if (testDir === '__tests__') {
|
|
390
|
-
// 預設:放在原始檔旁的 __tests__ 子目錄
|
|
391
|
-
return join(dirname(sourceFilePath), '__tests__', `${name}.${suffix}.test${ext}`);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// 自訂目錄:放在專案根 testDir 底下,保留原始檔的相對路徑結構
|
|
395
|
-
const relDir = dirname(sourceFilePath).replace(cwd, '').replace(/^\//, '');
|
|
396
|
-
return join(cwd, testDir, relDir, `${name}.${suffix}.test${ext}`);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* 透過 git 偵測所有異動的原始碼檔案(回傳陣列)
|
|
401
|
-
* 優先順序:staged → 工作區(unstaged+untracked) → 整個 branch(vs main/master) → 最近 10 commits
|
|
402
|
-
* 排除測試檔、設定檔、lock 檔
|
|
403
|
-
*/
|
|
404
|
-
async function detectChangedSourceFiles(logger) {
|
|
405
|
-
const SOURCE_EXTENSIONS = /\.(js|jsx|ts|tsx|mjs|cjs)$/;
|
|
406
|
-
const EXCLUDED = /(__tests__|\.test\.|\.spec\.|\.config\.|node_modules|dist\/|\.lock$|package\.json)/;
|
|
407
|
-
const cwd = process.cwd();
|
|
408
|
-
|
|
409
|
-
function filterSourceFiles(raw) {
|
|
410
|
-
return raw
|
|
411
|
-
.split('\n')
|
|
412
|
-
.map((f) => f.trim().replace(/^[MADRCU?!\s]+/, '').replace(/^"(.*)"$/, '$1'))
|
|
413
|
-
.filter((f) => f && SOURCE_EXTENSIONS.test(f) && !EXCLUDED.test(f) && existsSync(resolve(cwd, f)));
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function run(cmd) {
|
|
417
|
-
return execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', cwd });
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
try {
|
|
421
|
-
// 1. staged 檔案
|
|
422
|
-
const staged = filterSourceFiles(run('git diff --cached --name-only'));
|
|
423
|
-
|
|
424
|
-
// 2. unstaged + untracked
|
|
425
|
-
const unstaged = filterSourceFiles(run('git diff --name-only'));
|
|
426
|
-
const untracked = filterSourceFiles(run('git ls-files --others --exclude-standard'));
|
|
427
|
-
const working = [...new Set([...unstaged, ...untracked])];
|
|
428
|
-
|
|
429
|
-
// 3. 整個 branch 相對於 upstream / main / master 的變更
|
|
430
|
-
const branchFiles = getBranchChangedFiles(filterSourceFiles, run);
|
|
431
|
-
|
|
432
|
-
// 合併全部(去重);staged > working > branch
|
|
433
|
-
const all = [...new Set([...staged, ...working, ...branchFiles])];
|
|
434
|
-
|
|
435
|
-
if (all.length > 0) {
|
|
436
|
-
const breakdown = [
|
|
437
|
-
staged.length && `staged ${staged.length}`,
|
|
438
|
-
working.length && `工作區 ${working.length}`,
|
|
439
|
-
branchFiles.length && `branch ${branchFiles.length}`,
|
|
440
|
-
].filter(Boolean).join('、');
|
|
441
|
-
logger.info(`偵測到 ${all.length} 個異動檔案(${breakdown})`);
|
|
442
|
-
return all;
|
|
443
|
-
}
|
|
444
|
-
} catch {
|
|
445
|
-
// git 取得失敗,直接回傳空陣列
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return [];
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function getBranchChangedFiles(filterSourceFiles, run) {
|
|
452
|
-
// 依序嘗試找到 merge-base,再比較 HEAD
|
|
453
|
-
const baseCandidates = ['@{u}', 'origin/main', 'origin/master', 'main', 'master'];
|
|
454
|
-
for (const base of baseCandidates) {
|
|
455
|
-
try {
|
|
456
|
-
const mergeBase = run(`git merge-base HEAD ${base}`).trim();
|
|
457
|
-
const files = filterSourceFiles(run(`git diff --name-only ${mergeBase}...HEAD`));
|
|
458
|
-
if (files.length > 0) return files;
|
|
459
|
-
} catch {
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
// 最後 fallback:最近 10 commits
|
|
464
|
-
try {
|
|
465
|
-
return filterSourceFiles(run('git diff --name-only HEAD~10...HEAD'));
|
|
466
|
-
} catch {
|
|
467
|
-
return [];
|
|
468
|
-
}
|
|
469
|
-
}
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TestGenerator 服務
|
|
3
|
-
* 為指定的原始碼檔案使用 AIClient 生成 Jest 測試
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
|
-
import { dirname } from 'path';
|
|
8
|
-
import { AIClient } from './ai-client.js';
|
|
9
|
-
|
|
10
|
-
export class TestGenerator {
|
|
11
|
-
/**
|
|
12
|
-
* 為原始檔案生成測試並寫入測試檔案
|
|
13
|
-
* @param {string} sourceFilePath - 原始碼路徑(絕對路徑)
|
|
14
|
-
* @param {string} testFilePath - 測試檔案輸出路徑(絕對路徑)
|
|
15
|
-
* @param {'auto'|'unit'|'component'} testType - 測試類型(預設 'auto')
|
|
16
|
-
* @param {string} [model] - AI 模型(可選)
|
|
17
|
-
* @returns {Promise<{ testFilePath, linesCount, testType: 'unit'|'component' }>}
|
|
18
|
-
*/
|
|
19
|
-
static async generateTests(sourceFilePath, testFilePath, testType = 'auto', model) {
|
|
20
|
-
let sourceCode;
|
|
21
|
-
try {
|
|
22
|
-
sourceCode = readFileSync(sourceFilePath, 'utf-8');
|
|
23
|
-
} catch {
|
|
24
|
-
throw new Error(`無法讀取原始碼:${sourceFilePath}`);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const resolvedTestType = TestGenerator.resolveTestType(sourceFilePath, sourceCode, testType);
|
|
28
|
-
const prompt = TestGenerator._buildPrompt(sourceCode, sourceFilePath, resolvedTestType);
|
|
29
|
-
const rawTests = await AIClient.sendAndWait(prompt, model);
|
|
30
|
-
const testCode = TestGenerator._finalizeTestCode(
|
|
31
|
-
TestGenerator._cleanCode(rawTests),
|
|
32
|
-
resolvedTestType
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
// 確保目錄存在
|
|
36
|
-
mkdirSync(dirname(testFilePath), { recursive: true });
|
|
37
|
-
writeFileSync(testFilePath, testCode, 'utf-8');
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
testFilePath,
|
|
41
|
-
linesCount: testCode.split('\n').length,
|
|
42
|
-
testType: resolvedTestType,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 解析最終測試類型
|
|
48
|
-
* @param {string} sourceFilePath
|
|
49
|
-
* @param {string} sourceCode
|
|
50
|
-
* @param {'auto'|'unit'|'component'} requestedTestType
|
|
51
|
-
* @returns {'unit'|'component'}
|
|
52
|
-
*/
|
|
53
|
-
static resolveTestType(sourceFilePath, sourceCode, requestedTestType = 'auto') {
|
|
54
|
-
if (requestedTestType === 'unit' || requestedTestType === 'component') {
|
|
55
|
-
return requestedTestType;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const normalizedPath = sourceFilePath.toLowerCase();
|
|
59
|
-
const looksLikeComponentPath = /\.(jsx|tsx)$/.test(normalizedPath)
|
|
60
|
-
|| /component|page|view|screen/.test(normalizedPath);
|
|
61
|
-
const hasReactImport = /from\s+['"]react['"]|from\s+['"]react-dom['"]/.test(sourceCode);
|
|
62
|
-
const hasTestingLibraryHint = /use(State|Effect|Memo|Callback|Reducer|Ref|Context)|React\./.test(sourceCode);
|
|
63
|
-
const hasJsx = /<([A-Z][\w]*|[a-z][\w-]*)(\s[^>]*)?>/.test(sourceCode);
|
|
64
|
-
|
|
65
|
-
return looksLikeComponentPath || hasReactImport || hasTestingLibraryHint || hasJsx
|
|
66
|
-
? 'component'
|
|
67
|
-
: 'unit';
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* 使用錯誤訊息重新生成修復後的原始碼
|
|
72
|
-
* @param {string} sourceFilePath - 原始碼路徑
|
|
73
|
-
* @param {string[]} testErrors - Jest 錯誤訊息陣列
|
|
74
|
-
* @param {number} attempt - 第幾次修復嘗試(1 或 2)
|
|
75
|
-
* @param {string} [model] - AI 模型(可選)
|
|
76
|
-
* @returns {Promise<void>}
|
|
77
|
-
*/
|
|
78
|
-
static async generateFix(sourceFilePath, testErrors, attempt, model) {
|
|
79
|
-
let sourceCode;
|
|
80
|
-
try {
|
|
81
|
-
sourceCode = readFileSync(sourceFilePath, 'utf-8');
|
|
82
|
-
} catch {
|
|
83
|
-
throw new Error(`無法讀取原始碼:${sourceFilePath}`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const prompt = TestGenerator._buildFixPrompt(sourceCode, testErrors, attempt);
|
|
87
|
-
const rawFixed = await AIClient.sendAndWait(prompt, model);
|
|
88
|
-
const fixedCode = TestGenerator._cleanCode(rawFixed);
|
|
89
|
-
|
|
90
|
-
writeFileSync(sourceFilePath, fixedCode, 'utf-8');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 建立測試生成 prompt
|
|
95
|
-
*/
|
|
96
|
-
static _buildPrompt(sourceCode, sourceFilePath, testType) {
|
|
97
|
-
return `你是一位 Jest 測試專家。請為以下 ${testType === 'component' ? 'React 元件' : '模組'} 撰寫全面的 ${testType} 測試。
|
|
98
|
-
|
|
99
|
-
## 原始碼(${sourceFilePath})
|
|
100
|
-
\`\`\`
|
|
101
|
-
${sourceCode}
|
|
102
|
-
\`\`\`
|
|
103
|
-
|
|
104
|
-
## 測試要求
|
|
105
|
-
1. 使用現代 Jest 語法(describe / it / expect)
|
|
106
|
-
2. 涵蓋所有主要函數及邊界情況
|
|
107
|
-
3. Mock 所有外部依賴(檔案系統、網路、子程序等)
|
|
108
|
-
4. 使用 beforeEach / afterEach 管理測試狀態
|
|
109
|
-
5. 測試名稱使用繁體中文描述
|
|
110
|
-
${testType === 'component'
|
|
111
|
-
? '6. 使用 @testing-library/react 撰寫互動與渲染測試\n7. 檔案最前面必須加上 /** @jest-environment jsdom */ 註解'
|
|
112
|
-
: '6. 以純邏輯與副作用隔離為主,避免使用 DOM API'}
|
|
113
|
-
|
|
114
|
-
## 輸出規則
|
|
115
|
-
- 只輸出可直接執行的測試程式碼
|
|
116
|
-
- 不要 markdown 區塊(\`\`\`)、不要任何說明文字
|
|
117
|
-
- 第一行必須是 /** @jest-environment jsdom */ 或 import / require 陳述式`.trim();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* 建立修復 prompt
|
|
122
|
-
*/
|
|
123
|
-
static _buildFixPrompt(sourceCode, testErrors, attempt) {
|
|
124
|
-
return `這是第 ${attempt} 次修復嘗試。以下是目前的原始碼和失敗的測試錯誤,請修正原始碼使測試通過。
|
|
125
|
-
|
|
126
|
-
## 當前原始碼
|
|
127
|
-
\`\`\`
|
|
128
|
-
${sourceCode}
|
|
129
|
-
\`\`\`
|
|
130
|
-
|
|
131
|
-
## 測試錯誤
|
|
132
|
-
${testErrors.join('\n')}
|
|
133
|
-
|
|
134
|
-
## 修復規則
|
|
135
|
-
- 只修改原始碼,不修改測試
|
|
136
|
-
- 保留原有功能,只修正造成測試失敗的部分
|
|
137
|
-
- 只輸出修正後的完整原始碼,不要任何說明文字
|
|
138
|
-
- 不要 markdown 區塊(\`\`\`)`.trim();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* 移除 AI 回傳中的 markdown code fence
|
|
143
|
-
*/
|
|
144
|
-
static _cleanCode(raw) {
|
|
145
|
-
return raw
|
|
146
|
-
.replace(/^```[\w]*\n?/gm, '')
|
|
147
|
-
.replace(/\n?```$/gm, '')
|
|
148
|
-
.trim();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* 針對元件測試補上必要的 jsdom 設定
|
|
153
|
-
*/
|
|
154
|
-
static _finalizeTestCode(testCode, testType) {
|
|
155
|
-
if (testType !== 'component') {
|
|
156
|
-
return testCode;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (testCode.startsWith('/** @jest-environment jsdom */')) {
|
|
160
|
-
return testCode;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return `/** @jest-environment jsdom */\n${testCode}`;
|
|
164
|
-
}
|
|
165
|
-
}
|