ai-git-tools 2.0.68 → 2.0.69

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.
@@ -1,115 +0,0 @@
1
- /**
2
- * generate-code 命令
3
- * 讀取 GitHub Issue,依照指定路徑生成符合專案風格的代碼
4
- */
5
-
6
- import { readFileSync } from 'fs';
7
- import { resolve } from 'path';
8
- import inquirer from 'inquirer';
9
- import { IssueReader } from '../core/issue-reader.js';
10
- import { CodeGenerator } from '../core/code-generator.js';
11
- import { writeAndTestCommand, TEST_TYPE_CHOICES, normalizeTestTypeSelection } from './write-and-test.js';
12
- import { Logger } from '../utils/logger.js';
13
-
14
- export async function generateCodeCommand(options = {}) {
15
- const logger = new Logger();
16
-
17
- // 驗證必要參數
18
- const issueNumber = options.issue;
19
- const filePath = options.file;
20
- const extraContext = options.context || '';
21
- const maxLines = parseInt(options.maxLines || '500', 10);
22
-
23
- if (!issueNumber) {
24
- logger.error('請提供 Issue 編號(--issue <number>)');
25
- throw new Error('缺少 Issue 編號');
26
- }
27
- if (!filePath) {
28
- logger.error('請提供目標檔案路徑(--file <path>)');
29
- throw new Error('缺少目標檔案路徑');
30
- }
31
-
32
- const absolutePath = resolve(process.cwd(), filePath);
33
-
34
- logger.header(`generate-code`);
35
- console.log(`Issue:#${issueNumber}`);
36
- console.log(`目標檔案:${absolutePath}`);
37
- console.log(`最大行數:${maxLines}`);
38
- console.log('');
39
-
40
- // 1. 讀取 Issue
41
- logger.step(`正在讀取 Issue #${issueNumber}...`);
42
- const issue = await IssueReader.readIssue(issueNumber);
43
- logger.info(`Issue:${issue.title}`);
44
-
45
- // 2. 詢問確認後再生成
46
- if (!options.noConfirm) {
47
- const { confirmed } = await inquirer.prompt([{
48
- type: 'confirm',
49
- name: 'confirmed',
50
- message: `確認為檔案 ${filePath} 生成代碼?`,
51
- default: true,
52
- }]);
53
-
54
- if (!confirmed) {
55
- logger.info('已取消。');
56
- return;
57
- }
58
- }
59
-
60
- // 3. 生成代碼
61
- logger.step('正在生成代碼中,請稍候...\n');
62
-
63
- const result = await CodeGenerator.generateFile(issue, absolutePath, {
64
- maxLines,
65
- language: /\.(ts|tsx)$/.test(absolutePath) ? 'ts' : 'js',
66
- extraContext,
67
- model: options.model,
68
- });
69
-
70
- // 4. 顯示結果
71
- logger.section(`生成完成`);
72
- logger.success(`檔案已寫入:${result.filePath}`);
73
- logger.info(`共 ${result.linesCount} 行`);
74
-
75
- // 5. 預覽前 20 行
76
- try {
77
- const preview = readFileSync(result.filePath, 'utf-8').split('\n').slice(0, 20).join('\n');
78
- console.log('\n--- 預覽(前 20 行)---');
79
- console.log(preview);
80
- console.log('---');
81
- } catch {
82
- // 忽略預覽讀取失敗
83
- }
84
-
85
- console.log('\n💡 下一步:執行 write-and-test 為此檔案生成並執行測試');
86
- console.log(` ai-git-tools write-and-test --file ${filePath}`);
87
-
88
- if (!options.noConfirm) {
89
- const { nextAction } = await inquirer.prompt([{
90
- type: 'list',
91
- name: 'nextAction',
92
- message: '代碼已生成,接下來要如何處理測試?',
93
- choices: [
94
- ...TEST_TYPE_CHOICES,
95
- {
96
- name: '先不執行測試',
97
- value: 'skip',
98
- },
99
- ],
100
- default: 'both',
101
- }]);
102
-
103
- if (nextAction !== 'skip') {
104
- await writeAndTestCommand({
105
- file: filePath,
106
- testType: normalizeTestTypeSelection(nextAction).join(','),
107
- maxFixes: options.maxFixes || '2',
108
- model: options.model,
109
- noConfirm: true,
110
- });
111
- }
112
- }
113
-
114
- return result;
115
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * plan-issue 命令
3
- * 讀取 GitHub Issue,使用 AI 生成結構化的實現計畫
4
- */
5
-
6
- import inquirer from 'inquirer';
7
- import { IssueReader } from '../core/issue-reader.js';
8
- import { AIClient } from '../core/ai-client.js';
9
- import { Logger } from '../utils/logger.js';
10
-
11
- export async function planIssueCommand(options = {}) {
12
- const logger = new Logger();
13
-
14
- const issueNumber = options.number || options.issue;
15
- if (!issueNumber) {
16
- logger.error('請提供 Issue 編號(--number <number>)');
17
- throw new Error('缺少 Issue 編號');
18
- }
19
-
20
- logger.header(`plan-issue #${issueNumber}`);
21
-
22
- // 1. 讀取 Issue
23
- logger.step(`正在讀取 Issue #${issueNumber}...`);
24
- const issue = await IssueReader.readIssue(issueNumber);
25
-
26
- logger.section(`Issue #${issue.number}:${issue.title}`);
27
- if (options.verbose) {
28
- console.log(`作者:${issue.author}`);
29
- console.log(`標籤:${issue.labels.join(', ') || '(無)'}`);
30
- console.log(`URL:${issue.url}`);
31
- console.log(`\n描述:\n${issue.body || '(無描述)'}`);
32
- }
33
-
34
- // 2. 呼叫 AI 生成計畫
35
- logger.step('正在分析 Issue 並生成實現計畫...\n');
36
- const prompt = buildPlanPrompt(issue);
37
- const plan = await AIClient.sendAndWait(prompt, options.model);
38
-
39
- // 3. 顯示計畫
40
- logger.section('AI 生成的實現計畫');
41
- console.log(plan);
42
- console.log('');
43
-
44
- // 4. 詢問確認(除非 --no-confirm)
45
- if (options.noConfirm) {
46
- logger.success('自動模式:跳過確認,繼續進行代碼生成。');
47
- return { issue, plan };
48
- }
49
-
50
- const { proceed } = await inquirer.prompt([{
51
- type: 'confirm',
52
- name: 'proceed',
53
- message: '是否確認此計畫並繼續進行代碼生成?',
54
- default: false,
55
- }]);
56
-
57
- if (proceed) {
58
- logger.success('計畫已確認!可以使用 generate-code 命令開始生成代碼。');
59
- console.log('');
60
- console.log(` ai-git-tools generate-code --issue ${issue.number} --file <目標檔案路徑> --context <描述>`);
61
- } else {
62
- logger.info('已取消。請根據上方計畫手動進行後續操作。');
63
- }
64
-
65
- return { issue, plan };
66
- }
67
-
68
- /**
69
- * 建立計畫生成 prompt
70
- */
71
- function buildPlanPrompt(issue) {
72
- return `你是一位軟體架構師。請閱讀以下 GitHub Issue 並建立詳細的實現計畫。
73
-
74
- ## Issue #${issue.number}
75
- 標題:${issue.title}
76
-
77
- 描述:
78
- ${issue.body || '(無描述)'}
79
-
80
- ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
81
-
82
- ## 輸出要求
83
- 請輸出包含以下內容的實現計畫:
84
-
85
- 1. **摘要** - 一句話說明要做什麼
86
- 2. **需要新增/修改的檔案** - 列出所有相關檔案路徑和各自的職責
87
- 3. **實現步驟** - 有序的任務清單,說明每個步驟的目的
88
- 4. **注意事項** - 潛在風險或需要留意的事項(若有)
89
-
90
- 請使用繁體中文,格式清晰,簡潔有力。`.trim();
91
- }
@@ -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
- }