@kb-labs/commit-core 0.6.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/README.md +23 -0
- package/dist/analyzer/index.d.ts +90 -0
- package/dist/analyzer/index.js +588 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/applier/index.d.ts +37 -0
- package/dist/applier/index.js +265 -0
- package/dist/applier/index.js.map +1 -0
- package/dist/generator/index.d.ts +26 -0
- package/dist/generator/index.js +2694 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/heuristics-B6EsN9yD.d.ts +80 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3093 -0
- package/dist/index.js.map +1 -0
- package/dist/recent-commits-DD6LX2OI.d.ts +85 -0
- package/dist/storage/index.d.ts +59 -0
- package/dist/storage/index.js +280 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/types-CEjOl41u.d.ts +67 -0
- package/package.json +69 -0
|
@@ -0,0 +1,2694 @@
|
|
|
1
|
+
import { useLogger, useAnalytics, useLLM } from '@kb-labs/sdk';
|
|
2
|
+
import { simpleGit } from 'simple-git';
|
|
3
|
+
import { dirname, basename, extname } from 'path';
|
|
4
|
+
import { minimatch } from 'minimatch';
|
|
5
|
+
import * as readline from 'readline';
|
|
6
|
+
|
|
7
|
+
// src/generator/commit-plan.ts
|
|
8
|
+
|
|
9
|
+
// src/generator/commit-tools.ts
|
|
10
|
+
var COMMIT_PLAN_TOOL_PHASE3 = {
|
|
11
|
+
name: "generate_commit_plan",
|
|
12
|
+
description: "Generate commits for missing files - either extend existing commits or create new ones",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
commits: {
|
|
17
|
+
type: "array",
|
|
18
|
+
description: "List of commit actions. Can mix extend_existing and create_new actions.",
|
|
19
|
+
items: {
|
|
20
|
+
type: "object",
|
|
21
|
+
required: ["action", "files"],
|
|
22
|
+
properties: {
|
|
23
|
+
action: {
|
|
24
|
+
type: "string",
|
|
25
|
+
enum: ["create_new", "extend_existing"],
|
|
26
|
+
description: 'Whether to create a new commit or add files to an existing commit. Use "extend_existing" to avoid creating unnecessary commits.'
|
|
27
|
+
},
|
|
28
|
+
existingCommitId: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: 'ID of existing commit to extend (REQUIRED when action is "extend_existing"). Example: "c1", "c2".',
|
|
31
|
+
pattern: "^c[0-9]+$"
|
|
32
|
+
},
|
|
33
|
+
id: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Unique commit identifier (REQUIRED for create_new action, e.g., c1, c2, c3).",
|
|
36
|
+
pattern: "^c[0-9]+$"
|
|
37
|
+
},
|
|
38
|
+
type: {
|
|
39
|
+
type: "string",
|
|
40
|
+
enum: ["feat", "fix", "refactor", "chore", "docs", "test", "build", "ci", "perf"],
|
|
41
|
+
description: "Conventional commit type (REQUIRED for create_new action)."
|
|
42
|
+
},
|
|
43
|
+
scope: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Scope of the commit (optional)."
|
|
46
|
+
},
|
|
47
|
+
message: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Commit message in imperative mood (REQUIRED for create_new action).",
|
|
50
|
+
minLength: 5,
|
|
51
|
+
maxLength: 100
|
|
52
|
+
},
|
|
53
|
+
body: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Detailed description (optional)."
|
|
56
|
+
},
|
|
57
|
+
files: {
|
|
58
|
+
type: "array",
|
|
59
|
+
items: { type: "string" },
|
|
60
|
+
description: "List of file paths to add to this commit.",
|
|
61
|
+
minItems: 1
|
|
62
|
+
},
|
|
63
|
+
releaseHint: {
|
|
64
|
+
type: "string",
|
|
65
|
+
enum: ["none", "patch", "minor", "major"],
|
|
66
|
+
description: "Semantic versioning impact (REQUIRED for create_new action)."
|
|
67
|
+
},
|
|
68
|
+
breaking: {
|
|
69
|
+
type: "boolean",
|
|
70
|
+
description: "Whether this is a breaking change.",
|
|
71
|
+
default: false
|
|
72
|
+
},
|
|
73
|
+
reasoning: {
|
|
74
|
+
type: "object",
|
|
75
|
+
description: "Reasoning for classification (REQUIRED for create_new action).",
|
|
76
|
+
properties: {
|
|
77
|
+
newBehavior: { type: "boolean" },
|
|
78
|
+
fixesBug: { type: "boolean" },
|
|
79
|
+
internalOnly: { type: "boolean" },
|
|
80
|
+
explanation: { type: "string", minLength: 10, maxLength: 300 },
|
|
81
|
+
confidence: { type: "number", minimum: 0, maximum: 1 }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
required: ["commits"]
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var COMMIT_PLAN_TOOL = {
|
|
92
|
+
name: "generate_commit_plan",
|
|
93
|
+
description: "Generate a structured commit plan with conventional commits following best practices",
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
needsMoreContext: {
|
|
98
|
+
type: "boolean",
|
|
99
|
+
description: "Whether you need to see diff content to make accurate commit type decisions. Set to true if file paths and stats alone are insufficient.",
|
|
100
|
+
default: false
|
|
101
|
+
},
|
|
102
|
+
requestedFiles: {
|
|
103
|
+
type: "array",
|
|
104
|
+
items: { type: "string" },
|
|
105
|
+
description: "List of SPECIFIC files you need diffs for (MAXIMUM 15 files). Only request files where file path and stats are insufficient. DO NOT request all files - be selective and choose only the most critical/ambiguous ones. Prioritize files with unclear intent or complex changes.",
|
|
106
|
+
default: []
|
|
107
|
+
},
|
|
108
|
+
commits: {
|
|
109
|
+
type: "array",
|
|
110
|
+
description: "List of commit groups. Each commit groups related files by logical change (not by file type or directory).",
|
|
111
|
+
items: {
|
|
112
|
+
type: "object",
|
|
113
|
+
required: ["id", "type", "message", "files", "releaseHint", "breaking", "reasoning"],
|
|
114
|
+
properties: {
|
|
115
|
+
id: {
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "Unique commit identifier (e.g., c1, c2, c3).",
|
|
118
|
+
pattern: "^c[0-9]+$"
|
|
119
|
+
},
|
|
120
|
+
type: {
|
|
121
|
+
type: "string",
|
|
122
|
+
enum: ["feat", "fix", "refactor", "chore", "docs", "test", "build", "ci", "perf"],
|
|
123
|
+
description: "Conventional commit type. Use refactor for internal changes, feat only for new user-facing features."
|
|
124
|
+
},
|
|
125
|
+
scope: {
|
|
126
|
+
type: "string",
|
|
127
|
+
description: 'Scope of the commit (e.g., "cli", "api", "core"). Should reflect affected area, not individual files.'
|
|
128
|
+
},
|
|
129
|
+
message: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description: 'Commit message in imperative mood, lowercase, no period at end (e.g., "add authentication middleware")',
|
|
132
|
+
minLength: 5,
|
|
133
|
+
maxLength: 100
|
|
134
|
+
},
|
|
135
|
+
body: {
|
|
136
|
+
type: "string",
|
|
137
|
+
description: "Detailed description with bullet points listing affected files/changes. Use for commits with 2+ files."
|
|
138
|
+
},
|
|
139
|
+
files: {
|
|
140
|
+
type: "array",
|
|
141
|
+
items: { type: "string" },
|
|
142
|
+
description: "List of file paths in this commit. Each file must appear in exactly ONE commit (no duplicates across commits).",
|
|
143
|
+
minItems: 1
|
|
144
|
+
},
|
|
145
|
+
releaseHint: {
|
|
146
|
+
type: "string",
|
|
147
|
+
enum: ["none", "patch", "minor", "major"],
|
|
148
|
+
description: 'Semantic versioning impact. Use "minor" for feat, "patch" for fix/refactor, "none" for chore/docs/test.'
|
|
149
|
+
},
|
|
150
|
+
breaking: {
|
|
151
|
+
type: "boolean",
|
|
152
|
+
description: "Whether this is a breaking change (breaks public API compatibility).",
|
|
153
|
+
default: false
|
|
154
|
+
},
|
|
155
|
+
reasoning: {
|
|
156
|
+
type: "object",
|
|
157
|
+
required: ["newBehavior", "fixesBug", "internalOnly", "explanation", "confidence"],
|
|
158
|
+
description: "Reasoning for commit type classification. Used for validation and debugging.",
|
|
159
|
+
properties: {
|
|
160
|
+
newBehavior: {
|
|
161
|
+
type: "boolean",
|
|
162
|
+
description: "Does this change add NEW USER-VISIBLE BEHAVIOR? (new API, feature, capability that users can access)"
|
|
163
|
+
},
|
|
164
|
+
fixesBug: {
|
|
165
|
+
type: "boolean",
|
|
166
|
+
description: "Does this change fix BROKEN functionality? (corrects bug or error)"
|
|
167
|
+
},
|
|
168
|
+
internalOnly: {
|
|
169
|
+
type: "boolean",
|
|
170
|
+
description: "Is this ONLY INTERNAL restructuring? (code reorganization, renaming, extracting functions without changing behavior)"
|
|
171
|
+
},
|
|
172
|
+
explanation: {
|
|
173
|
+
type: "string",
|
|
174
|
+
description: "Explain your classification decision. Why did you choose this commit type?",
|
|
175
|
+
minLength: 10,
|
|
176
|
+
maxLength: 300
|
|
177
|
+
},
|
|
178
|
+
confidence: {
|
|
179
|
+
type: "number",
|
|
180
|
+
minimum: 0,
|
|
181
|
+
maximum: 1,
|
|
182
|
+
description: "Confidence in this classification (0.0 to 1.0). Use <0.7 if you need more context (will trigger Phase 2)."
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
required: ["commits"]
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
async function getGitStatus(cwd) {
|
|
194
|
+
const git = simpleGit(cwd);
|
|
195
|
+
const status = await git.status(["--ignore-submodules=all"]);
|
|
196
|
+
return {
|
|
197
|
+
staged: status.staged.filter((f) => !shouldIgnoreFile(f)),
|
|
198
|
+
unstaged: [...status.modified, ...status.deleted].filter((f) => !status.staged.includes(f)).filter((f) => !shouldIgnoreFile(f)),
|
|
199
|
+
untracked: status.not_added.filter((f) => !shouldIgnoreFile(f))
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function shouldIgnoreFile(file) {
|
|
203
|
+
const ignoredPaths = [
|
|
204
|
+
"node_modules/",
|
|
205
|
+
".git/",
|
|
206
|
+
"dist/",
|
|
207
|
+
"build/",
|
|
208
|
+
".next/",
|
|
209
|
+
".turbo/",
|
|
210
|
+
"coverage/",
|
|
211
|
+
".cache/",
|
|
212
|
+
".temp/",
|
|
213
|
+
"tmp/"
|
|
214
|
+
];
|
|
215
|
+
return ignoredPaths.some((path) => file.includes(path));
|
|
216
|
+
}
|
|
217
|
+
function getAllChangedFiles(status) {
|
|
218
|
+
const allFiles = [
|
|
219
|
+
.../* @__PURE__ */ new Set([...status.staged, ...status.unstaged, ...status.untracked])
|
|
220
|
+
];
|
|
221
|
+
return allFiles.filter((file) => !shouldIgnoreFile(file));
|
|
222
|
+
}
|
|
223
|
+
function isTextFile(file) {
|
|
224
|
+
return !file.binary;
|
|
225
|
+
}
|
|
226
|
+
async function isNewFile(cwd, filePath) {
|
|
227
|
+
try {
|
|
228
|
+
const repo = await findGitRepo(cwd, filePath);
|
|
229
|
+
if (!repo) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
const git = simpleGit(repo.repoPath);
|
|
233
|
+
const result = await git.raw(["ls-tree", "HEAD", "--", repo.relativePath]);
|
|
234
|
+
return result.trim().length === 0;
|
|
235
|
+
} catch {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function getFileSummaries(cwd, files) {
|
|
240
|
+
if (files.length === 0) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const summaries = [];
|
|
244
|
+
const filesByRepo = /* @__PURE__ */ new Map();
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
const repo = await findGitRepo(cwd, file);
|
|
247
|
+
if (!repo) {
|
|
248
|
+
const group2 = filesByRepo.get(cwd) ?? [];
|
|
249
|
+
group2.push({ repoPath: cwd, relativePath: file, originalPath: file });
|
|
250
|
+
filesByRepo.set(cwd, group2);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const group = filesByRepo.get(repo.repoPath) ?? [];
|
|
254
|
+
group.push({
|
|
255
|
+
repoPath: repo.repoPath,
|
|
256
|
+
relativePath: repo.relativePath,
|
|
257
|
+
originalPath: file
|
|
258
|
+
});
|
|
259
|
+
filesByRepo.set(repo.repoPath, group);
|
|
260
|
+
}
|
|
261
|
+
for (const [repoPath, fileInfos] of filesByRepo) {
|
|
262
|
+
const git = simpleGit(repoPath);
|
|
263
|
+
const relativePaths = fileInfos.map((f) => f.relativePath);
|
|
264
|
+
try {
|
|
265
|
+
const stagedDiff = await git.diffSummary([
|
|
266
|
+
"--cached",
|
|
267
|
+
"--",
|
|
268
|
+
...relativePaths
|
|
269
|
+
]);
|
|
270
|
+
const unstagedDiff = await git.diffSummary(["--", ...relativePaths]);
|
|
271
|
+
const allDiffFiles = /* @__PURE__ */ new Map();
|
|
272
|
+
for (const file of unstagedDiff.files) {
|
|
273
|
+
allDiffFiles.set(file.file, file);
|
|
274
|
+
}
|
|
275
|
+
for (const file of stagedDiff.files) {
|
|
276
|
+
allDiffFiles.set(file.file, file);
|
|
277
|
+
}
|
|
278
|
+
for (const file of allDiffFiles.values()) {
|
|
279
|
+
const fileInfo = fileInfos.find((f) => f.relativePath === file.file);
|
|
280
|
+
if (!fileInfo) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const isNew = await isNewFile(repoPath, file.file);
|
|
284
|
+
if (isTextFile(file)) {
|
|
285
|
+
summaries.push({
|
|
286
|
+
path: fileInfo.originalPath,
|
|
287
|
+
status: mapDiffStatus(file.insertions, file.deletions),
|
|
288
|
+
additions: file.insertions,
|
|
289
|
+
deletions: file.deletions,
|
|
290
|
+
binary: false,
|
|
291
|
+
isNewFile: isNew
|
|
292
|
+
});
|
|
293
|
+
} else {
|
|
294
|
+
summaries.push({
|
|
295
|
+
path: fileInfo.originalPath,
|
|
296
|
+
status: "modified",
|
|
297
|
+
additions: 0,
|
|
298
|
+
deletions: 0,
|
|
299
|
+
binary: true,
|
|
300
|
+
isNewFile: isNew
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const processedPaths = new Set(
|
|
305
|
+
Array.from(allDiffFiles.values()).map((f) => f.file)
|
|
306
|
+
);
|
|
307
|
+
const missingInRepo = fileInfos.filter(
|
|
308
|
+
(f) => !processedPaths.has(f.relativePath)
|
|
309
|
+
);
|
|
310
|
+
for (const fileInfo of missingInRepo) {
|
|
311
|
+
summaries.push({
|
|
312
|
+
path: fileInfo.originalPath,
|
|
313
|
+
status: "added",
|
|
314
|
+
additions: 0,
|
|
315
|
+
deletions: 0,
|
|
316
|
+
binary: false,
|
|
317
|
+
isNewFile: true
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
for (const fileInfo of fileInfos) {
|
|
322
|
+
summaries.push({
|
|
323
|
+
path: fileInfo.originalPath,
|
|
324
|
+
status: "modified",
|
|
325
|
+
additions: 0,
|
|
326
|
+
deletions: 0,
|
|
327
|
+
binary: false,
|
|
328
|
+
isNewFile: false
|
|
329
|
+
// Conservative assumption
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return summaries;
|
|
335
|
+
}
|
|
336
|
+
function mapDiffStatus(insertions, deletions) {
|
|
337
|
+
if (deletions === 0 && insertions > 0) {
|
|
338
|
+
return "added";
|
|
339
|
+
}
|
|
340
|
+
if (insertions === 0 && deletions > 0) {
|
|
341
|
+
return "deleted";
|
|
342
|
+
}
|
|
343
|
+
return "modified";
|
|
344
|
+
}
|
|
345
|
+
async function getFileDiffs(cwd, files) {
|
|
346
|
+
if (files.length === 0) {
|
|
347
|
+
return /* @__PURE__ */ new Map();
|
|
348
|
+
}
|
|
349
|
+
const diffs = /* @__PURE__ */ new Map();
|
|
350
|
+
const filesByRepo = /* @__PURE__ */ new Map();
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
const repo = await findGitRepo(cwd, file);
|
|
353
|
+
if (!repo) {
|
|
354
|
+
const group2 = filesByRepo.get(cwd) ?? [];
|
|
355
|
+
group2.push({ repoPath: cwd, relativePath: file, originalPath: file });
|
|
356
|
+
filesByRepo.set(cwd, group2);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const group = filesByRepo.get(repo.repoPath) ?? [];
|
|
360
|
+
group.push({
|
|
361
|
+
repoPath: repo.repoPath,
|
|
362
|
+
relativePath: repo.relativePath,
|
|
363
|
+
originalPath: file
|
|
364
|
+
});
|
|
365
|
+
filesByRepo.set(repo.repoPath, group);
|
|
366
|
+
}
|
|
367
|
+
for (const [repoPath, fileInfos] of filesByRepo) {
|
|
368
|
+
const git = simpleGit(repoPath);
|
|
369
|
+
for (const { relativePath, originalPath } of fileInfos) {
|
|
370
|
+
try {
|
|
371
|
+
let diff = await git.diff(["--cached", "--", relativePath]);
|
|
372
|
+
if (!diff) {
|
|
373
|
+
diff = await git.diff(["--", relativePath]);
|
|
374
|
+
}
|
|
375
|
+
if (!diff) {
|
|
376
|
+
diff = await git.show([`:${relativePath}`]).catch(() => "");
|
|
377
|
+
}
|
|
378
|
+
if (diff) {
|
|
379
|
+
diffs.set(originalPath, diff);
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return diffs;
|
|
386
|
+
}
|
|
387
|
+
async function existsAsync(path) {
|
|
388
|
+
try {
|
|
389
|
+
const { existsSync } = await import('fs');
|
|
390
|
+
return existsSync(path);
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function findGitRepo(basePath, filePath) {
|
|
396
|
+
const segments = filePath.split("/");
|
|
397
|
+
for (let i = segments.length - 1; i > 0; i--) {
|
|
398
|
+
const potentialRepoSegments = segments.slice(0, i);
|
|
399
|
+
const potentialRepoPath = `${basePath}/${potentialRepoSegments.join("/")}`;
|
|
400
|
+
const gitDir = `${potentialRepoPath}/.git`;
|
|
401
|
+
if (await existsAsync(gitDir)) {
|
|
402
|
+
const relativePath = segments.slice(i).join("/");
|
|
403
|
+
return { repoPath: potentialRepoPath, relativePath };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (await existsAsync(`${basePath}/.git`)) {
|
|
407
|
+
return { repoPath: basePath, relativePath: filePath };
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
async function getRecentCommits(cwd, count = 10) {
|
|
412
|
+
const git = simpleGit(cwd);
|
|
413
|
+
try {
|
|
414
|
+
const log = await git.log({
|
|
415
|
+
maxCount: count,
|
|
416
|
+
format: {
|
|
417
|
+
message: "%s"
|
|
418
|
+
// Subject line only
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return log.all.map((commit) => commit.message);
|
|
422
|
+
} catch {
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/generator/llm-prompt.ts
|
|
428
|
+
var SYSTEM_PROMPT = `You are a git commit message generator. Analyze the changed files and generate a commit plan.
|
|
429
|
+
|
|
430
|
+
CRITICAL OUTPUT FORMAT:
|
|
431
|
+
- Return ONLY a valid JSON object
|
|
432
|
+
- Do NOT wrap in markdown code blocks (no \`\`\`json, no \`\`\`)
|
|
433
|
+
- Do NOT add any text before or after the JSON
|
|
434
|
+
- Ensure all strings are properly escaped (use double quotes, escape backslashes and quotes)
|
|
435
|
+
|
|
436
|
+
IMPORTANT: You must assess your confidence level. If file paths and stats alone are not enough to determine the correct commit type and message, set needsMoreContext to true and list the files you need to see the diff for.
|
|
437
|
+
|
|
438
|
+
CRITICAL GROUPING RULES:
|
|
439
|
+
- Group files by LOGICAL CHANGE, not by file type or directory
|
|
440
|
+
- If multiple files implement the same feature/fix/refactor, they belong in ONE commit
|
|
441
|
+
- For initial project setup (many new files): group by package or functional area (contracts, core, cli, docs, tests)
|
|
442
|
+
- Target: 3-8 commits for <50 files, 5-12 commits for 50-150 files, 10-20 commits for 150+ files
|
|
443
|
+
- Ask yourself: "Would a developer make these changes in separate commits?" If no, group them!
|
|
444
|
+
- CRITICAL: Each file must appear in EXACTLY ONE commit - no duplicates across commits!
|
|
445
|
+
|
|
446
|
+
CRITICAL: FILE TYPE SHORTCUTS (check FIRST, before other rules):
|
|
447
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
448
|
+
|
|
449
|
+
\u{1F6AB} STOP! Check file extensions BEFORE asking questions below:
|
|
450
|
+
|
|
451
|
+
\u{1F4DD} ALL files are *.md or *.mdx? \u2192 Type: docs (SKIP all questions below!)
|
|
452
|
+
\u{1F9EA} ALL files in test/, __tests__/, *.test.ts, *.spec.ts? \u2192 Type: test
|
|
453
|
+
\u274C If ANY file has code (.ts, .js, .tsx, .jsx, .py, etc.) \u2192 Continue to questions below
|
|
454
|
+
|
|
455
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
456
|
+
|
|
457
|
+
CRITICAL COMMIT TYPE CLASSIFICATION:
|
|
458
|
+
For EACH commit, you MUST answer these questions to determine the correct type:
|
|
459
|
+
|
|
460
|
+
1. Does this change add NEW USER-VISIBLE BEHAVIOR? (yes/no)
|
|
461
|
+
- Can users/developers do something they couldn't before?
|
|
462
|
+
- Is there a new API, feature, or capability?
|
|
463
|
+
\u2192 YES = likely feat
|
|
464
|
+
|
|
465
|
+
2. Does this change fix BROKEN functionality? (yes/no)
|
|
466
|
+
- Was something not working correctly?
|
|
467
|
+
- Is this correcting a bug or error?
|
|
468
|
+
\u2192 YES = fix
|
|
469
|
+
|
|
470
|
+
3. Is this ONLY INTERNAL restructuring? (yes/no)
|
|
471
|
+
- Code reorganization, renaming, extracting functions?
|
|
472
|
+
- Improving structure WITHOUT changing behavior?
|
|
473
|
+
- Modified files with balanced additions/deletions?
|
|
474
|
+
\u2192 YES = refactor
|
|
475
|
+
|
|
476
|
+
4. Is this configuration, build, or maintenance work? (yes/no)
|
|
477
|
+
- Dependencies, build configs, tooling?
|
|
478
|
+
- No code logic changes?
|
|
479
|
+
\u2192 YES = chore
|
|
480
|
+
|
|
481
|
+
DEFAULT BIAS: When uncertain between feat and refactor, choose refactor!
|
|
482
|
+
|
|
483
|
+
Rules:
|
|
484
|
+
1. Use conventional commits: feat, fix, refactor, chore, docs, test, build, ci, perf
|
|
485
|
+
2. Group related files - number of commits should scale with file count (see grouping rules above)
|
|
486
|
+
3. Each commit MUST include "reasoning" field explaining your classification
|
|
487
|
+
4. Message should be lowercase, imperative mood, no period at end
|
|
488
|
+
5. breaking: true only for breaking API changes
|
|
489
|
+
6. For commits with 2+ files, add "body" with bullet points listing affected files/changes
|
|
490
|
+
7. Scope should reflect the affected area (e.g., "cli", "api"), not individual files
|
|
491
|
+
8. CRITICAL: If ALL files in a commit have status "deleted", use type "chore" or "refactor", NOT "feat"
|
|
492
|
+
9. CRITICAL: If a commit is mostly deletions (>80% deletions), use "refactor" or "chore", NOT "feat"
|
|
493
|
+
|
|
494
|
+
10. CRITICAL: WRITE INFORMATIVE COMMIT MESSAGES (not generic):
|
|
495
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
496
|
+
|
|
497
|
+
\u274C BAD (too generic):
|
|
498
|
+
- "add feature"
|
|
499
|
+
- "update files"
|
|
500
|
+
- "refactor code"
|
|
501
|
+
- "fix bug"
|
|
502
|
+
- "improve types"
|
|
503
|
+
|
|
504
|
+
\u2705 GOOD (specific and descriptive):
|
|
505
|
+
- "add JWT authentication with refresh token support"
|
|
506
|
+
- "update TypeScript configuration for strict mode"
|
|
507
|
+
- "refactor plugin execution to use factory pattern"
|
|
508
|
+
- "fix null pointer exception in authentication middleware"
|
|
509
|
+
- "improve type safety in workflow execution context"
|
|
510
|
+
|
|
511
|
+
Guidelines:
|
|
512
|
+
- Include WHAT was changed (specific feature/component)
|
|
513
|
+
- Include HOW if relevant (method, pattern, technology)
|
|
514
|
+
- Use concrete nouns (not "files", "code", "feature")
|
|
515
|
+
- Add context that helps reviewers understand the change
|
|
516
|
+
- For body: list specific changes, not just file names
|
|
517
|
+
|
|
518
|
+
11. CRITICAL: SPECIFIC TYPE DETECTION (check BEFORE defaulting to refactor):
|
|
519
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
520
|
+
|
|
521
|
+
\u{1F4DD} docs: Documentation ONLY (no code changes)
|
|
522
|
+
\u2705 ALL files are markdown (*.md, *.mdx) \u2192 docs (even if IsNewFile: true)
|
|
523
|
+
\u2705 README.md, CONTRIBUTING.md, API docs, ADRs
|
|
524
|
+
\u2705 Files in docs/ or doc/ directory with only markdown
|
|
525
|
+
\u2705 JSDoc comments only (no logic changes)
|
|
526
|
+
\u274C NOT docs if ANY file has code logic changes
|
|
527
|
+
|
|
528
|
+
**IMPORTANT**: If ALL files end with .md or .mdx \u2192 ALWAYS use docs, NEVER feat!
|
|
529
|
+
|
|
530
|
+
\u{1F41B} fix: Corrects BROKEN functionality
|
|
531
|
+
\u2705 Bug fixes, error handling corrections
|
|
532
|
+
\u2705 Fixes crashes, incorrect behavior
|
|
533
|
+
\u2705 Corrects typos in USER-FACING text (not code comments)
|
|
534
|
+
\u274C NOT fix if adding new behavior (that's feat)
|
|
535
|
+
|
|
536
|
+
\u{1F9EA} test: Test files ONLY
|
|
537
|
+
\u2705 Files in test/, tests/, __tests__/, *.test.ts, *.spec.ts
|
|
538
|
+
\u2705 Adding/updating test cases
|
|
539
|
+
\u274C NOT test if also changing source code
|
|
540
|
+
|
|
541
|
+
12. CRITICAL: IsNewFile flag determines STRONG BIAS against feat:
|
|
542
|
+
|
|
543
|
+
IsNewFile: FALSE (modified existing file):
|
|
544
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
545
|
+
\u2192 DEFAULT to refactor/fix/chore (NOT feat!)
|
|
546
|
+
\u2192 Only use feat if adds MAJOR new user-facing capability
|
|
547
|
+
|
|
548
|
+
Common cases where IsNewFile: false = NOT feat:
|
|
549
|
+
\u2705 Changed dependencies/imports \u2192 chore
|
|
550
|
+
\u2705 Added method to existing class \u2192 refactor
|
|
551
|
+
\u2705 Updated implementation logic \u2192 refactor
|
|
552
|
+
\u2705 Fixed bug in existing code \u2192 fix
|
|
553
|
+
\u2705 Renamed/moved code \u2192 refactor
|
|
554
|
+
\u2705 Modified config files \u2192 chore
|
|
555
|
+
|
|
556
|
+
\u274C WRONG: IsNewFile: false, minor additions \u2192 feat
|
|
557
|
+
\u2705 RIGHT: IsNewFile: false, minor additions \u2192 refactor
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
IsNewFile: TRUE (brand new file):
|
|
561
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
562
|
+
\u2192 Might be feat IF adds new user capability
|
|
563
|
+
\u2192 If only config/tooling/internal \u2192 chore
|
|
564
|
+
|
|
565
|
+
Examples:
|
|
566
|
+
\u2705 New API endpoint file \u2192 feat
|
|
567
|
+
\u2705 New CLI command file \u2192 feat
|
|
568
|
+
\u2705 New config file \u2192 chore
|
|
569
|
+
\u2705 New test file \u2192 test
|
|
570
|
+
|
|
571
|
+
EXACT JSON SCHEMA (copy this structure):
|
|
572
|
+
{
|
|
573
|
+
"needsMoreContext": false,
|
|
574
|
+
"requestedFiles": ["file1.ts"],
|
|
575
|
+
"commits": [
|
|
576
|
+
{
|
|
577
|
+
"id": "c1",
|
|
578
|
+
"type": "feat",
|
|
579
|
+
"scope": "auth",
|
|
580
|
+
"message": "add JWT authentication with refresh token support",
|
|
581
|
+
"body": "- implement JWT token generation and validation\\n- add refresh token rotation mechanism\\n- integrate with existing user authentication flow",
|
|
582
|
+
"files": ["src/auth/jwt-strategy.ts", "src/auth/refresh-token.ts", "src/middleware/auth.ts"],
|
|
583
|
+
"releaseHint": "minor",
|
|
584
|
+
"breaking": false,
|
|
585
|
+
"reasoning": {
|
|
586
|
+
"newBehavior": true,
|
|
587
|
+
"fixesBug": false,
|
|
588
|
+
"internalOnly": false,
|
|
589
|
+
"explanation": "Adds new JWT authentication capability with refresh tokens - enables secure stateless authentication for API users",
|
|
590
|
+
"confidence": 0.85
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
]
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
VALID TYPE VALUES: feat, fix, refactor, chore, docs, test, build, ci, perf
|
|
597
|
+
VALID RELEASEHINT VALUES: none, patch, minor, major
|
|
598
|
+
|
|
599
|
+
EXAMPLE OUTPUT (use this as template):
|
|
600
|
+
{
|
|
601
|
+
"needsMoreContext": false,
|
|
602
|
+
"requestedFiles": [],
|
|
603
|
+
"commits": [
|
|
604
|
+
{
|
|
605
|
+
"id": "c1",
|
|
606
|
+
"type": "feat",
|
|
607
|
+
"scope": "cli",
|
|
608
|
+
"message": "add commit generation command",
|
|
609
|
+
"body": "- implement generate command\\n- add LLM integration",
|
|
610
|
+
"files": ["src/commands/generate.ts", "src/llm.ts"],
|
|
611
|
+
"releaseHint": "minor",
|
|
612
|
+
"breaking": false,
|
|
613
|
+
"reasoning": {
|
|
614
|
+
"newBehavior": true,
|
|
615
|
+
"fixesBug": false,
|
|
616
|
+
"internalOnly": false,
|
|
617
|
+
"explanation": "New command allows users to generate commits with LLM - new capability",
|
|
618
|
+
"confidence": 0.9
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
"id": "c2",
|
|
623
|
+
"type": "test",
|
|
624
|
+
"message": "add tests for commit generator",
|
|
625
|
+
"files": ["tests/generate.test.ts"],
|
|
626
|
+
"releaseHint": "none",
|
|
627
|
+
"breaking": false,
|
|
628
|
+
"reasoning": {
|
|
629
|
+
"newBehavior": false,
|
|
630
|
+
"fixesBug": false,
|
|
631
|
+
"internalOnly": true,
|
|
632
|
+
"explanation": "Test coverage for new feature - internal quality improvement",
|
|
633
|
+
"confidence": 0.85
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
]
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
REAL-WORLD EXAMPLES (learn from these patterns):
|
|
640
|
+
|
|
641
|
+
Example 1: Modified files with low addition ratio \u2192 refactor, NOT feat
|
|
642
|
+
Files:
|
|
643
|
+
- commit-plan.ts (modified, +150/-120)
|
|
644
|
+
- llm-prompt.ts (modified, +80/-60)
|
|
645
|
+
Addition ratio: 230/350 = 65% (low, mostly structural changes)
|
|
646
|
+
\u274C WRONG: feat(core): add commit plan and llm prompt generators
|
|
647
|
+
\u2705 CORRECT: refactor(core): update commit plan and llm prompt logic
|
|
648
|
+
Reason: Modified files + low addition ratio = refactoring existing code
|
|
649
|
+
|
|
650
|
+
Example 2: New package with many files \u2192 feat, NOT chore
|
|
651
|
+
Files: 21 new files in packages/core-resource-broker/
|
|
652
|
+
- package.json (new)
|
|
653
|
+
- src/broker/resource-broker.ts (new)
|
|
654
|
+
- src/queue/priority-queue.ts (new)
|
|
655
|
+
- ... (18 more new files)
|
|
656
|
+
\u274C WRONG: chore(core-resource-broker): initialize core resource broker package
|
|
657
|
+
\u2705 CORRECT: feat(core-resource-broker): add resource broker for rate limiting and queueing
|
|
658
|
+
Reason: New package = new functionality = feat (even if many files)
|
|
659
|
+
|
|
660
|
+
Example 3: Bulk move (many added files but not new) \u2192 refactor, NOT feat
|
|
661
|
+
Files: 100 files with status "added" but isNewFile: false
|
|
662
|
+
- packages/analytics/core/file1.ts (added, isNewFile: false)
|
|
663
|
+
- packages/analytics/core/file2.ts (added, isNewFile: false)
|
|
664
|
+
- ... (98 more files, all moved from elsewhere)
|
|
665
|
+
\u274C WRONG: feat(analytics): add analytics packages
|
|
666
|
+
\u2705 CORRECT: refactor(analytics): reorganize analytics package structure
|
|
667
|
+
Reason: isNewFile: false means files existed before, just moved/reorganized
|
|
668
|
+
|
|
669
|
+
Example 4: All deleted files \u2192 chore, NOT feat
|
|
670
|
+
Files: 22 files, all with status "deleted"
|
|
671
|
+
- packages/analytics/test1.spec.ts (deleted, +0/-1579)
|
|
672
|
+
- packages/analytics/test2.spec.ts (deleted, +0/-856)
|
|
673
|
+
- ... (20 more deleted files)
|
|
674
|
+
\u274C WRONG: feat(analytics): add analytics functionality
|
|
675
|
+
\u2705 CORRECT: chore(analytics): remove unused test files
|
|
676
|
+
Reason: Deleting files is cleanup (chore), not new feature
|
|
677
|
+
|
|
678
|
+
Example 5: True new feature (genuinely new files) \u2192 feat
|
|
679
|
+
Files: 5 new files with isNewFile: true
|
|
680
|
+
- src/auth/jwt-strategy.ts (added, +200/-0, isNewFile: true)
|
|
681
|
+
- src/auth/auth-middleware.ts (added, +150/-0, isNewFile: true)
|
|
682
|
+
- ... (3 more new files)
|
|
683
|
+
\u2705 CORRECT: feat(auth): add JWT authentication
|
|
684
|
+
Reason: New functionality, truly new files, implements new capability
|
|
685
|
+
|
|
686
|
+
Example 6: Documentation ONLY \u2192 docs, NOT chore or feat
|
|
687
|
+
Files:
|
|
688
|
+
- README.md (modified, +50/-20, isNewFile: false)
|
|
689
|
+
- CONTRIBUTING.md (modified, +30/-10, isNewFile: false)
|
|
690
|
+
- docs/api.md (modified, +100/-50, isNewFile: false)
|
|
691
|
+
\u274C WRONG: chore(docs): update documentation files
|
|
692
|
+
\u274C WRONG: feat(docs): add documentation
|
|
693
|
+
\u2705 CORRECT: docs: improve README and API documentation
|
|
694
|
+
Reason: ALL files are markdown = docs (regardless of IsNewFile)
|
|
695
|
+
|
|
696
|
+
Example 6b: NEW documentation files \u2192 docs, NOT feat
|
|
697
|
+
Files:
|
|
698
|
+
- docs/benchmarks/README.md (added, +200/-0, isNewFile: true)
|
|
699
|
+
- docs/benchmarks/RESULTS.md (added, +100/-0, isNewFile: true)
|
|
700
|
+
\u274C WRONG: feat(docs): add benchmarks documentation
|
|
701
|
+
\u2705 CORRECT: docs(benchmarks): add benchmarks documentation
|
|
702
|
+
Reason: ALL files are .md = docs type (even if IsNewFile: true)
|
|
703
|
+
|
|
704
|
+
Example 7: Bug fix with error handling \u2192 fix, NOT refactor
|
|
705
|
+
Files:
|
|
706
|
+
- src/api/auth.ts (modified, +15/-5, isNewFile: false)
|
|
707
|
+
Diff shows: Added try-catch, null check for token validation
|
|
708
|
+
\u274C WRONG: refactor(api): update auth token validation
|
|
709
|
+
\u2705 CORRECT: fix(api): handle null token in authentication
|
|
710
|
+
Reason: Adds error handling to prevent crash = bug fix
|
|
711
|
+
|
|
712
|
+
Example 8: Test files ONLY \u2192 test, NOT chore
|
|
713
|
+
Files:
|
|
714
|
+
- tests/auth.test.ts (added, +200/-0, isNewFile: true)
|
|
715
|
+
- tests/fixtures/users.json (added, +50/-0, isNewFile: true)
|
|
716
|
+
\u274C WRONG: chore(tests): add test files
|
|
717
|
+
\u2705 CORRECT: test(auth): add authentication test suite
|
|
718
|
+
Reason: Test files only = test type
|
|
719
|
+
`;
|
|
720
|
+
var SYSTEM_PROMPT_WITH_DIFF = `You are a git commit message generator. You now have the actual diff content for better context.
|
|
721
|
+
|
|
722
|
+
CRITICAL OUTPUT FORMAT:
|
|
723
|
+
- Return ONLY a valid JSON object
|
|
724
|
+
- Do NOT wrap in markdown code blocks (no \`\`\`json, no \`\`\`)
|
|
725
|
+
- Do NOT add any text before or after the JSON
|
|
726
|
+
- Ensure all strings are properly escaped (use double quotes, escape backslashes and quotes)
|
|
727
|
+
|
|
728
|
+
CRITICAL: USE IsNewFile METADATA TO DISTINGUISH NEW vs MODIFIED FILES:
|
|
729
|
+
- Each file includes "IsNewFile: true" or "IsNewFile: false"
|
|
730
|
+
- IsNewFile: false \u2192 File EXISTED BEFORE in git history \u2192 Use "refactor", "fix", or "chore"
|
|
731
|
+
- IsNewFile: true \u2192 File is TRULY NEW (never existed) \u2192 Use "feat" for new functionality
|
|
732
|
+
- NEVER use "feat: add initial" for files with "IsNewFile: false" - these are modifications!
|
|
733
|
+
- For files marked "[EXISTING FILE - was modified]" in diff section \u2192 Use refactor/fix/chore, NOT feat
|
|
734
|
+
|
|
735
|
+
CRITICAL GROUPING RULES:
|
|
736
|
+
- Group files by LOGICAL CHANGE based on diff content
|
|
737
|
+
- If files are changed for the same reason, they belong in ONE commit
|
|
738
|
+
- IMPORTANT: Most files with status "modified" are REFACTORING, not new features
|
|
739
|
+
- For refactoring: analyze the diff to understand what changed (renamed variables, restructured code, etc.)
|
|
740
|
+
- Only use "add initial" or "setup" messages if you see truly NEW functionality being created from scratch
|
|
741
|
+
- Target: 3-8 commits for <50 files, 5-12 commits for 50-150 files, 10-20 commits for 150+ files
|
|
742
|
+
- Only separate genuinely DIFFERENT changes
|
|
743
|
+
- CRITICAL: Each file must appear in EXACTLY ONE commit - no duplicates across commits!
|
|
744
|
+
|
|
745
|
+
CRITICAL: FILE TYPE SHORTCUTS (check FIRST, before other rules):
|
|
746
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
747
|
+
|
|
748
|
+
\u{1F6AB} STOP! Check file extensions in DIFF BEFORE asking questions below:
|
|
749
|
+
|
|
750
|
+
\u{1F4DD} ALL files in diff are *.md or *.mdx? \u2192 Type: docs (SKIP all questions below!)
|
|
751
|
+
\u{1F9EA} ALL files in diff are test/, __tests__/, *.test.ts, *.spec.ts? \u2192 Type: test
|
|
752
|
+
\u274C If ANY file has code (.ts, .js, .tsx, .jsx, .py, etc.) \u2192 Continue to questions below
|
|
753
|
+
|
|
754
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
755
|
+
|
|
756
|
+
CRITICAL COMMIT TYPE CLASSIFICATION (same as Phase 1):
|
|
757
|
+
For EACH commit, answer these questions using the DIFF content:
|
|
758
|
+
|
|
759
|
+
1. Does this change add NEW USER-VISIBLE BEHAVIOR?
|
|
760
|
+
- Look at the diff: is there a new API, feature, or capability?
|
|
761
|
+
- Check IsNewFile: false = likely refactor, true = might be feat
|
|
762
|
+
\u2192 YES = feat
|
|
763
|
+
|
|
764
|
+
2. Does this change fix BROKEN functionality?
|
|
765
|
+
- Look for bug fixes, error handling corrections
|
|
766
|
+
\u2192 YES = fix
|
|
767
|
+
|
|
768
|
+
3. Is this ONLY INTERNAL restructuring?
|
|
769
|
+
- Renaming, extracting functions, reorganizing code?
|
|
770
|
+
- IsNewFile: false with balanced +/- = refactor
|
|
771
|
+
\u2192 YES = refactor
|
|
772
|
+
|
|
773
|
+
DEFAULT BIAS: When uncertain between feat and refactor, choose refactor!
|
|
774
|
+
|
|
775
|
+
Rules:
|
|
776
|
+
|
|
777
|
+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
778
|
+
!!! RULE 0 (HIGHEST PRIORITY - OVERRIDES ALL OTHER RULES): !!!
|
|
779
|
+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
780
|
+
!!!
|
|
781
|
+
!!! IF a file has "IsNewFile: false" in its metadata, IT IS A MODIFIED FILE.
|
|
782
|
+
!!! Modified files CANNOT be feat UNLESS they add ENTIRELY NEW user-facing APIs.
|
|
783
|
+
!!!
|
|
784
|
+
!!! BEFORE classifying ANY commit as "feat", CHECK ALL files' IsNewFile flags:
|
|
785
|
+
!!! - If ANY file has IsNewFile: false \u2192 START with refactor/fix/chore
|
|
786
|
+
!!! - If ALL files have IsNewFile: true \u2192 MIGHT be feat (check diff content)
|
|
787
|
+
!!!
|
|
788
|
+
!!! EXAMPLES OF MODIFIED FILES (IsNewFile: false) \u2192 NOT FEAT:
|
|
789
|
+
!!! \u274C WRONG: Modified file adds new function \u2192 feat
|
|
790
|
+
!!! \u2705 RIGHT: Modified file adds new function \u2192 refactor
|
|
791
|
+
!!!
|
|
792
|
+
!!! \u274C WRONG: Modified file adds new class \u2192 feat
|
|
793
|
+
!!! \u2705 RIGHT: Modified file adds new class \u2192 refactor
|
|
794
|
+
!!!
|
|
795
|
+
!!! \u274C WRONG: Modified file adds new CLI command \u2192 feat
|
|
796
|
+
!!! \u2705 RIGHT: Modified file adds new CLI command \u2192 refactor
|
|
797
|
+
!!!
|
|
798
|
+
!!! ONLY USE FEAT for modified files if:
|
|
799
|
+
!!! - Diff shows COMPLETELY NEW public API endpoint (e.g., POST /api/new-resource)
|
|
800
|
+
!!! - Diff shows COMPLETELY NEW product feature visible to end users
|
|
801
|
+
!!!
|
|
802
|
+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
803
|
+
|
|
804
|
+
1. Use conventional commits: feat, fix, refactor, chore, docs, test, build, ci, perf
|
|
805
|
+
2. Group related files - number of commits should scale with file count (see grouping rules above)
|
|
806
|
+
3. Each commit MUST include "reasoning" field based on actual diff content
|
|
807
|
+
4. Message should be lowercase, imperative mood, no period at end
|
|
808
|
+
5. breaking: true only for breaking API changes
|
|
809
|
+
6. Add "body" with bullet points explaining the actual changes you see in the diff
|
|
810
|
+
7. Scope should reflect the affected area (e.g., "cli", "api"), not individual files
|
|
811
|
+
8. CRITICAL: If ALL files in a commit are being DELETED (only deletions in diff), use type "chore" or "refactor", NOT "feat"
|
|
812
|
+
9. CRITICAL: If a commit is mostly deletions (>80% of lines are deletions), use "refactor" or "chore", NOT "feat"
|
|
813
|
+
|
|
814
|
+
10. CRITICAL: WRITE INFORMATIVE COMMIT MESSAGES (look at diff content):
|
|
815
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
816
|
+
|
|
817
|
+
\u274C BAD (too generic):
|
|
818
|
+
- "add feature"
|
|
819
|
+
- "update files"
|
|
820
|
+
- "refactor code"
|
|
821
|
+
- "fix bug"
|
|
822
|
+
- "improve implementation"
|
|
823
|
+
|
|
824
|
+
\u2705 GOOD (specific, based on diff):
|
|
825
|
+
- "add rate limiting middleware with Redis backend"
|
|
826
|
+
- "update API client to support pagination parameters"
|
|
827
|
+
- "refactor workflow executor to use async/await pattern"
|
|
828
|
+
- "fix memory leak in event listener cleanup"
|
|
829
|
+
- "improve error handling in authentication flow"
|
|
830
|
+
|
|
831
|
+
Guidelines:
|
|
832
|
+
- Read the DIFF to understand WHAT changed
|
|
833
|
+
- Include specific component/module names from diff
|
|
834
|
+
- Mention the technology/pattern if relevant (Redis, JWT, factory pattern)
|
|
835
|
+
- For body: describe concrete changes, not just "update X file"
|
|
836
|
+
- Use technical terms that developers will understand
|
|
837
|
+
|
|
838
|
+
12. CRITICAL: SPECIFIC TYPE DETECTION (check diff content BEFORE defaulting to refactor):
|
|
839
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
840
|
+
|
|
841
|
+
\u{1F4DD} docs: Documentation ONLY (no code logic changes)
|
|
842
|
+
\u2705 ALL files in diff are markdown (*.md, *.mdx) \u2192 docs (even if IsNewFile: true)
|
|
843
|
+
\u2705 Diff shows ONLY markdown content changes
|
|
844
|
+
\u2705 README.md, ADR files, API docs, benchmarks docs
|
|
845
|
+
\u2705 Files in docs/ or doc/ directory with only markdown
|
|
846
|
+
\u274C NOT docs if diff includes ANY code logic changes
|
|
847
|
+
|
|
848
|
+
**IMPORTANT**: If ALL files end with .md or .mdx \u2192 ALWAYS use docs, NEVER feat!
|
|
849
|
+
|
|
850
|
+
\u{1F41B} fix: Corrects BROKEN functionality (look for bug-fix patterns in diff)
|
|
851
|
+
\u2705 Diff adds try-catch, null checks, validation
|
|
852
|
+
\u2705 Diff fixes incorrect calculations or logic errors
|
|
853
|
+
\u2705 Diff corrects typos in user-facing strings
|
|
854
|
+
\u2705 Commit message/body mentions "fix", "bug", "error", "crash"
|
|
855
|
+
\u274C NOT fix if adding new behavior
|
|
856
|
+
|
|
857
|
+
\u{1F9EA} test: Test files ONLY
|
|
858
|
+
\u2705 Diff shows files in test/, __tests__/, *.test.ts, *.spec.ts
|
|
859
|
+
\u2705 Adding test cases, updating test fixtures
|
|
860
|
+
\u274C NOT test if diff also changes source code
|
|
861
|
+
|
|
862
|
+
13. IsNewFile flag (see RULE 0 above for detailed logic):
|
|
863
|
+
- IsNewFile: false \u2192 refactor/fix/chore/docs/test (NOT feat)
|
|
864
|
+
- IsNewFile: true \u2192 might be feat (check diff)
|
|
865
|
+
|
|
866
|
+
EXACT JSON SCHEMA (copy this structure):
|
|
867
|
+
{
|
|
868
|
+
"commits": [
|
|
869
|
+
{
|
|
870
|
+
"id": "c1",
|
|
871
|
+
"type": "refactor",
|
|
872
|
+
"scope": "workflow",
|
|
873
|
+
"message": "extract job executor to separate class with dependency injection",
|
|
874
|
+
"body": "- create JobExecutor class with injectable dependencies\\n- move execution logic from runtime to executor\\n- add unit tests for isolated executor behavior",
|
|
875
|
+
"files": ["src/runtime/workflow-runtime.ts", "src/executor/job-executor.ts", "tests/executor.test.ts"],
|
|
876
|
+
"releaseHint": "patch",
|
|
877
|
+
"breaking": false,
|
|
878
|
+
"reasoning": {
|
|
879
|
+
"newBehavior": false,
|
|
880
|
+
"fixesBug": false,
|
|
881
|
+
"internalOnly": true,
|
|
882
|
+
"explanation": "Diff shows extraction of JobExecutor class from runtime - improves testability through dependency injection pattern, no user-facing changes",
|
|
883
|
+
"confidence": 0.95
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
]
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
VALID TYPE VALUES: feat, fix, refactor, chore, docs, test, build, ci, perf
|
|
890
|
+
VALID RELEASEHINT VALUES: none, patch, minor, major
|
|
891
|
+
|
|
892
|
+
EXAMPLE OUTPUT (based on actual diff content):
|
|
893
|
+
{
|
|
894
|
+
"commits": [
|
|
895
|
+
{
|
|
896
|
+
"id": "c1",
|
|
897
|
+
"type": "refactor",
|
|
898
|
+
"scope": "cli",
|
|
899
|
+
"message": "migrate command routing to builder pattern with fluent API",
|
|
900
|
+
"body": "- replace imperative routing with CommandRouterBuilder\\n- add fluent API for route registration (addRoute, withMiddleware)\\n- extract plugin discovery to PluginDiscoveryService class\\n- update integration tests for new routing pattern",
|
|
901
|
+
"files": ["src/commands/routing.ts", "src/commands/router-builder.ts", "src/plugins/discovery-service.ts", "tests/integration/routing.test.ts"],
|
|
902
|
+
"releaseHint": "patch",
|
|
903
|
+
"breaking": false,
|
|
904
|
+
"reasoning": {
|
|
905
|
+
"newBehavior": false,
|
|
906
|
+
"fixesBug": false,
|
|
907
|
+
"internalOnly": true,
|
|
908
|
+
"explanation": "IsNewFile: false for routing.ts (modified). Diff shows refactoring to builder pattern - improves code organization and testability without changing CLI behavior",
|
|
909
|
+
"confidence": 0.95
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
]
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
REAL-WORLD EXAMPLES WITH DIFF CONTEXT (learn from these):
|
|
916
|
+
|
|
917
|
+
Example 1: Modified files - use diff to determine if feat or refactor
|
|
918
|
+
Diff shows:
|
|
919
|
+
- Renamed variables (oldName \u2192 newName)
|
|
920
|
+
- Restructured functions (extract to separate modules)
|
|
921
|
+
- Updated imports and exports
|
|
922
|
+
\u274C WRONG: feat(core): add new commit plan logic
|
|
923
|
+
\u2705 CORRECT: refactor(core): restructure commit plan and prompt generation
|
|
924
|
+
Reason: Diff shows reorganization, not new functionality
|
|
925
|
+
|
|
926
|
+
Example 2: New package - check IsNewFile flags in diff
|
|
927
|
+
All files show: IsNewFile: true, implements resource broker from scratch
|
|
928
|
+
\u2705 CORRECT: feat(core-resource-broker): add resource broker for rate limiting
|
|
929
|
+
Reason: Genuinely new package with new functionality
|
|
930
|
+
|
|
931
|
+
Example 3: Moved files - IsNewFile: false despite status "added"
|
|
932
|
+
Diff shows: [EXISTING FILE - was modified], same content as before
|
|
933
|
+
\u2705 CORRECT: refactor(analytics): reorganize analytics package structure
|
|
934
|
+
Reason: Files moved/reorganized, not newly created
|
|
935
|
+
|
|
936
|
+
Example 4: Bug fix in existing files
|
|
937
|
+
Diff shows: Fix null pointer exception, add validation check
|
|
938
|
+
\u2705 CORRECT: fix(auth): handle null token in authentication middleware
|
|
939
|
+
Reason: Fixing broken functionality = fix, not feat
|
|
940
|
+
|
|
941
|
+
Example 5: Documentation ONLY \u2192 docs, NOT chore or feat
|
|
942
|
+
Files:
|
|
943
|
+
- README.md (modified, +50/-20, IsNewFile: false)
|
|
944
|
+
- docs/API.md (modified, +30/-10, IsNewFile: false)
|
|
945
|
+
Diff shows: Updated markdown content, improved examples, no code changes
|
|
946
|
+
\u274C WRONG: chore(docs): update documentation
|
|
947
|
+
\u274C WRONG: feat(docs): add documentation
|
|
948
|
+
\u2705 CORRECT: docs: improve README and API documentation
|
|
949
|
+
Reason: ALL files are markdown = docs (regardless of IsNewFile)
|
|
950
|
+
|
|
951
|
+
Example 5b: NEW documentation files \u2192 docs, NOT feat
|
|
952
|
+
Files:
|
|
953
|
+
- docs/benchmarks/README.md (added, +200/-0, IsNewFile: true)
|
|
954
|
+
- docs/benchmarks/RESULTS.md (added, +100/-0, IsNewFile: true)
|
|
955
|
+
Diff shows: New markdown files with benchmarks documentation
|
|
956
|
+
\u274C WRONG: feat(docs): add benchmarks documentation
|
|
957
|
+
\u2705 CORRECT: docs(benchmarks): add benchmarks documentation
|
|
958
|
+
Reason: ALL files are .md = docs type (even if IsNewFile: true)
|
|
959
|
+
|
|
960
|
+
Example 6: Bug fix with error handling \u2192 fix, NOT refactor
|
|
961
|
+
Files:
|
|
962
|
+
- src/api/auth.ts (modified, +15/-5, IsNewFile: false)
|
|
963
|
+
Diff shows: Added try-catch around token validation, null check before access
|
|
964
|
+
\u2705 CORRECT: fix(api): handle null token in authentication
|
|
965
|
+
Reason: Adds error handling to prevent crash = bug fix
|
|
966
|
+
|
|
967
|
+
Example 7: Test files ONLY \u2192 test, NOT chore
|
|
968
|
+
Files:
|
|
969
|
+
- tests/auth.test.ts (added, +200/-0, IsNewFile: true)
|
|
970
|
+
- tests/helpers/mock-data.ts (added, +50/-0, IsNewFile: true)
|
|
971
|
+
Diff shows: New test suites for authentication module
|
|
972
|
+
\u2705 CORRECT: test(auth): add authentication test suite
|
|
973
|
+
Reason: Test files only = test type
|
|
974
|
+
`;
|
|
975
|
+
function buildPrompt(summaries, recentCommits) {
|
|
976
|
+
const fileList = summaries.map((s) => {
|
|
977
|
+
const stats = s.binary ? "binary" : `+${s.additions}/-${s.deletions}`;
|
|
978
|
+
const isNew = s.isNewFile ? "IsNewFile: true" : "IsNewFile: false";
|
|
979
|
+
return `- ${s.path} (${s.status}, ${stats}, ${isNew})`;
|
|
980
|
+
}).join("\n");
|
|
981
|
+
const styleHint = recentCommits.length > 0 ? `
|
|
982
|
+
Recent commit style:
|
|
983
|
+
${recentCommits.slice(0, 5).map((c) => `- "${c}"`).join("\n")}` : "";
|
|
984
|
+
return `Files changed:
|
|
985
|
+
${fileList}
|
|
986
|
+
${styleHint}
|
|
987
|
+
|
|
988
|
+
Generate commit plan as JSON. If you're unsure about commit type/message from paths alone, set needsMoreContext: true and list files in requestedFiles.`;
|
|
989
|
+
}
|
|
990
|
+
function buildEnhancedPrompt(summaries, patternAnalysis, recentCommits) {
|
|
991
|
+
const fileList = summaries.map((s) => {
|
|
992
|
+
const stats = s.binary ? "binary" : `+${s.additions}/-${s.deletions}`;
|
|
993
|
+
const isNew = s.isNewFile ? "IsNewFile: true" : "IsNewFile: false";
|
|
994
|
+
return `- ${s.path} (${s.status}, ${stats}, ${isNew})`;
|
|
995
|
+
}).join("\n");
|
|
996
|
+
const styleHint = recentCommits.length > 0 ? `
|
|
997
|
+
Recent commit style:
|
|
998
|
+
${recentCommits.slice(0, 5).map((c) => `- "${c}"`).join("\n")}` : "";
|
|
999
|
+
const patternHint = patternAnalysis.confidence > 0.7 ? `
|
|
1000
|
+
|
|
1001
|
+
\u{1F3AF} PATTERN DETECTED (confidence: ${(patternAnalysis.confidence * 100).toFixed(0)}%):
|
|
1002
|
+
Pattern type: ${patternAnalysis.patternType}
|
|
1003
|
+
Suggested commit type: ${patternAnalysis.suggestedType || "unknown"}
|
|
1004
|
+
|
|
1005
|
+
Hints:
|
|
1006
|
+
${patternAnalysis.hints.map((h) => ` \u2022 ${h}`).join("\n")}
|
|
1007
|
+
|
|
1008
|
+
\u26A0\uFE0F IMPORTANT: Consider this pattern analysis when determining commit types!` : "";
|
|
1009
|
+
return `Files changed:
|
|
1010
|
+
${fileList}
|
|
1011
|
+
${styleHint}
|
|
1012
|
+
${patternHint}
|
|
1013
|
+
|
|
1014
|
+
Generate commit plan as JSON. If you're unsure about commit type/message from paths alone, set needsMoreContext: true and list files in requestedFiles.`;
|
|
1015
|
+
}
|
|
1016
|
+
function buildPromptWithDiff(summaries, diffs, recentCommits) {
|
|
1017
|
+
const fileList = summaries.map((s) => {
|
|
1018
|
+
const stats = s.binary ? "binary" : `+${s.additions}/-${s.deletions}`;
|
|
1019
|
+
const isNew = s.isNewFile ? "IsNewFile: true" : "IsNewFile: false";
|
|
1020
|
+
return `- ${s.path} (${s.status}, ${stats}, ${isNew})`;
|
|
1021
|
+
}).join("\n");
|
|
1022
|
+
const diffContent = Array.from(diffs.entries()).map(([path, diff]) => {
|
|
1023
|
+
const summary = summaries.find((s) => s.path === path);
|
|
1024
|
+
const isNewLabel = summary?.isNewFile ? " [NEW FILE - never existed before]" : " [EXISTING FILE - was modified]";
|
|
1025
|
+
const truncatedDiff = diff.length > 2e3 ? diff.slice(0, 2e3) + "\n... (truncated)" : diff;
|
|
1026
|
+
return `### ${path}${isNewLabel}
|
|
1027
|
+
\`\`\`diff
|
|
1028
|
+
${truncatedDiff}
|
|
1029
|
+
\`\`\``;
|
|
1030
|
+
}).join("\n\n");
|
|
1031
|
+
const styleHint = recentCommits.length > 0 ? `
|
|
1032
|
+
Recent commit style:
|
|
1033
|
+
${recentCommits.slice(0, 5).map((c) => `- "${c}"`).join("\n")}` : "";
|
|
1034
|
+
return `Files changed:
|
|
1035
|
+
${fileList}
|
|
1036
|
+
${styleHint}
|
|
1037
|
+
|
|
1038
|
+
Diff content for requested files:
|
|
1039
|
+
${diffContent}
|
|
1040
|
+
|
|
1041
|
+
Now generate accurate commit plan based on the actual changes you see:`;
|
|
1042
|
+
}
|
|
1043
|
+
function cleanJsonResponse(rawResponse) {
|
|
1044
|
+
let cleaned = rawResponse.trim();
|
|
1045
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "");
|
|
1046
|
+
cleaned = cleaned.replace(/\s*```$/i, "");
|
|
1047
|
+
cleaned = cleaned.trim();
|
|
1048
|
+
const jsonStart = cleaned.indexOf("{");
|
|
1049
|
+
const jsonEnd = cleaned.lastIndexOf("}");
|
|
1050
|
+
if (jsonStart !== -1 && jsonEnd !== -1 && jsonStart < jsonEnd) {
|
|
1051
|
+
cleaned = cleaned.substring(jsonStart, jsonEnd + 1);
|
|
1052
|
+
}
|
|
1053
|
+
cleaned = cleaned.replace(/,(\s*[}\]])/g, "$1");
|
|
1054
|
+
return cleaned;
|
|
1055
|
+
}
|
|
1056
|
+
function parseResponse(response, summaries, patternAnalysis) {
|
|
1057
|
+
const cleaned = cleanJsonResponse(response);
|
|
1058
|
+
let parsed;
|
|
1059
|
+
try {
|
|
1060
|
+
parsed = JSON.parse(cleaned);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
const preview = cleaned.substring(0, 300).replace(/\n/g, " ");
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`Failed to parse LLM response as JSON. Preview: "${preview}${cleaned.length > 300 ? "..." : ""}" Error: ${error instanceof Error ? error.message : String(error)}`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (!parsed.commits || !Array.isArray(parsed.commits)) {
|
|
1068
|
+
throw new Error(
|
|
1069
|
+
`LLM response missing "commits" array. Got: ${JSON.stringify(parsed).substring(0, 200)}`
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
if (parsed.commits.length === 0) {
|
|
1073
|
+
if (parsed.needsMoreContext) {
|
|
1074
|
+
return {
|
|
1075
|
+
needsMoreContext: true,
|
|
1076
|
+
requestedFiles: Array.isArray(parsed.requestedFiles) ? parsed.requestedFiles : [],
|
|
1077
|
+
commits: [],
|
|
1078
|
+
averageConfidence: 0
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
throw new Error("LLM response has empty commits array and needsMoreContext is not set");
|
|
1082
|
+
}
|
|
1083
|
+
const commits = parsed.commits.map((commit, index) => {
|
|
1084
|
+
if (!commit.type) {
|
|
1085
|
+
throw new Error(`Commit ${index + 1} missing required field "type"`);
|
|
1086
|
+
}
|
|
1087
|
+
if (!commit.files || !Array.isArray(commit.files)) {
|
|
1088
|
+
throw new Error(`Commit ${index + 1} missing required field "files" array`);
|
|
1089
|
+
}
|
|
1090
|
+
if (commit.files.length === 0) {
|
|
1091
|
+
throw new Error(`Commit ${index + 1} has empty files array`);
|
|
1092
|
+
}
|
|
1093
|
+
if (!commit.message) {
|
|
1094
|
+
throw new Error(`Commit ${index + 1} missing required field "message"`);
|
|
1095
|
+
}
|
|
1096
|
+
const type = normalizeType(commit.type);
|
|
1097
|
+
const files = commit.files;
|
|
1098
|
+
const reasoning = commit.reasoning;
|
|
1099
|
+
let parsedReasoning;
|
|
1100
|
+
let confidence = 0.5;
|
|
1101
|
+
if (reasoning && typeof reasoning === "object") {
|
|
1102
|
+
confidence = typeof reasoning.confidence === "number" ? reasoning.confidence : 0.5;
|
|
1103
|
+
parsedReasoning = {
|
|
1104
|
+
newBehavior: Boolean(reasoning.newBehavior),
|
|
1105
|
+
fixesBug: Boolean(reasoning.fixesBug),
|
|
1106
|
+
internalOnly: Boolean(reasoning.internalOnly),
|
|
1107
|
+
explanation: typeof reasoning.explanation === "string" ? reasoning.explanation : "No explanation provided",
|
|
1108
|
+
confidence
|
|
1109
|
+
};
|
|
1110
|
+
} else {
|
|
1111
|
+
confidence = typeof commit.confidence === "number" ? commit.confidence : 0.5;
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
id: commit.id || `c${index + 1}`,
|
|
1115
|
+
type,
|
|
1116
|
+
scope: typeof commit.scope === "string" ? commit.scope : void 0,
|
|
1117
|
+
message: typeof commit.message === "string" ? commit.message : "update files",
|
|
1118
|
+
body: typeof commit.body === "string" ? commit.body : void 0,
|
|
1119
|
+
files,
|
|
1120
|
+
releaseHint: normalizeReleaseHint(commit.releaseHint),
|
|
1121
|
+
breaking: Boolean(commit.breaking),
|
|
1122
|
+
reasoning: parsedReasoning,
|
|
1123
|
+
confidence
|
|
1124
|
+
// keep for internal use
|
|
1125
|
+
};
|
|
1126
|
+
});
|
|
1127
|
+
const totalConfidence = commits.reduce((sum, c) => sum + (c.confidence ?? 0.5), 0);
|
|
1128
|
+
const averageConfidence = commits.length > 0 ? totalConfidence / commits.length : 0;
|
|
1129
|
+
const commitsWithFixedTypes = summaries ? commits.map((c) => fixCommitType(c, summaries, patternAnalysis)) : commits;
|
|
1130
|
+
const commitsWithoutConfidence = commitsWithFixedTypes.map((c) => {
|
|
1131
|
+
const { confidence: _, ...commit } = c;
|
|
1132
|
+
return commit;
|
|
1133
|
+
});
|
|
1134
|
+
return {
|
|
1135
|
+
needsMoreContext: Boolean(parsed.needsMoreContext),
|
|
1136
|
+
requestedFiles: Array.isArray(parsed.requestedFiles) ? parsed.requestedFiles : [],
|
|
1137
|
+
commits: commitsWithoutConfidence,
|
|
1138
|
+
averageConfidence
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
function fixCommitType(commit, summaries, patternAnalysis) {
|
|
1142
|
+
const commitFiles = commit.files;
|
|
1143
|
+
const commitSummaries = summaries.filter((s) => commitFiles.includes(s.path));
|
|
1144
|
+
if (commitSummaries.length === 0) {
|
|
1145
|
+
return commit;
|
|
1146
|
+
}
|
|
1147
|
+
if (commit.type === "feat" && commit.confidence < 0.7 && commit.reasoning) {
|
|
1148
|
+
const { newBehavior, internalOnly, fixesBug } = commit.reasoning;
|
|
1149
|
+
if (internalOnly && !newBehavior) {
|
|
1150
|
+
return {
|
|
1151
|
+
...commit,
|
|
1152
|
+
type: "refactor",
|
|
1153
|
+
reasoning: {
|
|
1154
|
+
...commit.reasoning,
|
|
1155
|
+
explanation: `[Conservative bias] ${commit.reasoning.explanation}. Low confidence + internalOnly \u2192 refactor`
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
if (fixesBug && !newBehavior) {
|
|
1160
|
+
return {
|
|
1161
|
+
...commit,
|
|
1162
|
+
type: "fix",
|
|
1163
|
+
reasoning: {
|
|
1164
|
+
...commit.reasoning,
|
|
1165
|
+
explanation: `[Conservative bias] ${commit.reasoning.explanation}. Fixes bug \u2192 fix`
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
if (!newBehavior) {
|
|
1170
|
+
return {
|
|
1171
|
+
...commit,
|
|
1172
|
+
type: "refactor",
|
|
1173
|
+
reasoning: {
|
|
1174
|
+
...commit.reasoning,
|
|
1175
|
+
explanation: `[Conservative bias] ${commit.reasoning.explanation}. No new behavior \u2192 refactor`
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const allDeleted = commitSummaries.every((s) => s.status === "deleted");
|
|
1181
|
+
if (allDeleted && commit.type === "feat") {
|
|
1182
|
+
return {
|
|
1183
|
+
...commit,
|
|
1184
|
+
type: "chore",
|
|
1185
|
+
message: commit.message.replace(/^add /i, "remove ").replace(/^added /i, "removed ")
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
const totalAdditions = commitSummaries.reduce((sum, s) => sum + s.additions, 0);
|
|
1189
|
+
const totalDeletions = commitSummaries.reduce((sum, s) => sum + s.deletions, 0);
|
|
1190
|
+
const totalChanges = totalAdditions + totalDeletions;
|
|
1191
|
+
if (totalChanges > 0) {
|
|
1192
|
+
const deletionRatio = totalDeletions / totalChanges;
|
|
1193
|
+
if (deletionRatio > 0.8 && commit.type === "feat") {
|
|
1194
|
+
return {
|
|
1195
|
+
...commit,
|
|
1196
|
+
type: "refactor"
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (patternAnalysis && patternAnalysis.confidence > 0.8 && patternAnalysis.suggestedType) {
|
|
1201
|
+
const llmType = commit.type;
|
|
1202
|
+
const patternType = patternAnalysis.suggestedType;
|
|
1203
|
+
if (llmType !== patternType) {
|
|
1204
|
+
return {
|
|
1205
|
+
...commit,
|
|
1206
|
+
type: patternType
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const allModified = commitSummaries.every((s) => s.status === "modified");
|
|
1211
|
+
if (allModified && totalChanges > 0 && commit.type === "feat") {
|
|
1212
|
+
const additionRatio = totalAdditions / totalChanges;
|
|
1213
|
+
if (additionRatio < 0.4) {
|
|
1214
|
+
return {
|
|
1215
|
+
...commit,
|
|
1216
|
+
type: "refactor"
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (additionRatio < 0.6) {
|
|
1220
|
+
return {
|
|
1221
|
+
...commit,
|
|
1222
|
+
type: "refactor"
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const hasPackageJson = commitSummaries.some((s) => s.path.endsWith("package.json"));
|
|
1227
|
+
if (hasPackageJson && commitSummaries.length >= 10) {
|
|
1228
|
+
const allAdded = commitSummaries.every((s) => s.status === "added");
|
|
1229
|
+
const allIsNewFile = commitSummaries.every((s) => s.isNewFile === true);
|
|
1230
|
+
if (allAdded && allIsNewFile && commit.type === "chore") {
|
|
1231
|
+
return {
|
|
1232
|
+
...commit,
|
|
1233
|
+
type: "feat",
|
|
1234
|
+
message: commit.message.replace(/^initialize /i, "add ").replace(/^setup /i, "add ")
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return commit;
|
|
1239
|
+
}
|
|
1240
|
+
function normalizeType(type) {
|
|
1241
|
+
const validTypes = [
|
|
1242
|
+
"feat",
|
|
1243
|
+
"fix",
|
|
1244
|
+
"refactor",
|
|
1245
|
+
"chore",
|
|
1246
|
+
"docs",
|
|
1247
|
+
"test",
|
|
1248
|
+
"build",
|
|
1249
|
+
"ci",
|
|
1250
|
+
"perf"
|
|
1251
|
+
];
|
|
1252
|
+
if (typeof type === "string") {
|
|
1253
|
+
const normalized = type.toLowerCase();
|
|
1254
|
+
if (validTypes.includes(normalized)) {
|
|
1255
|
+
return normalized;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return "chore";
|
|
1259
|
+
}
|
|
1260
|
+
function normalizeReleaseHint(hint) {
|
|
1261
|
+
if (typeof hint === "string") {
|
|
1262
|
+
const normalized = hint.toLowerCase();
|
|
1263
|
+
if (["none", "patch", "minor", "major"].includes(normalized)) {
|
|
1264
|
+
return normalized;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return "none";
|
|
1268
|
+
}
|
|
1269
|
+
function generateHeuristicPlan(summaries) {
|
|
1270
|
+
if (summaries.length === 0) {
|
|
1271
|
+
return [];
|
|
1272
|
+
}
|
|
1273
|
+
const commits = [];
|
|
1274
|
+
let commitIndex = 1;
|
|
1275
|
+
const { packageGroups, remainingFiles } = groupPackageJsonChanges(summaries);
|
|
1276
|
+
for (const group of packageGroups) {
|
|
1277
|
+
commits.push({
|
|
1278
|
+
id: `c${commitIndex++}`,
|
|
1279
|
+
type: "chore",
|
|
1280
|
+
scope: inferPackageScope(group.map((f) => f.path)),
|
|
1281
|
+
message: "update dependencies",
|
|
1282
|
+
files: group.map((f) => f.path),
|
|
1283
|
+
releaseHint: "none",
|
|
1284
|
+
breaking: false
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
const { pairedGroups, unpairedFiles } = pairTestsWithImplementation(remainingFiles);
|
|
1288
|
+
for (const group of pairedGroups) {
|
|
1289
|
+
const implFile = group.find((f) => !isTestFile(f.path));
|
|
1290
|
+
const type = inferTypeFromChanges(implFile);
|
|
1291
|
+
commits.push({
|
|
1292
|
+
id: `c${commitIndex++}`,
|
|
1293
|
+
type,
|
|
1294
|
+
scope: inferScope(group.map((f) => f.path)),
|
|
1295
|
+
message: generateMessage(type, group),
|
|
1296
|
+
files: group.map((f) => f.path),
|
|
1297
|
+
releaseHint: inferReleaseHint(type),
|
|
1298
|
+
breaking: false
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
const categoryGroups = groupByCategory(unpairedFiles);
|
|
1302
|
+
for (const [category, files] of categoryGroups) {
|
|
1303
|
+
const type = categoryToType(category);
|
|
1304
|
+
const scope = inferScope(files.map((f) => f.path));
|
|
1305
|
+
commits.push({
|
|
1306
|
+
id: `c${commitIndex++}`,
|
|
1307
|
+
type,
|
|
1308
|
+
scope,
|
|
1309
|
+
message: generateMessage(type, files),
|
|
1310
|
+
files: files.map((f) => f.path),
|
|
1311
|
+
releaseHint: inferReleaseHint(type),
|
|
1312
|
+
breaking: false
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
return commits;
|
|
1316
|
+
}
|
|
1317
|
+
function groupPackageJsonChanges(summaries) {
|
|
1318
|
+
const packageFiles = summaries.filter((f) => f.path.endsWith("package.json"));
|
|
1319
|
+
const otherFiles = summaries.filter((f) => !f.path.endsWith("package.json"));
|
|
1320
|
+
if (packageFiles.length === 0) {
|
|
1321
|
+
return { packageGroups: [], remainingFiles: summaries };
|
|
1322
|
+
}
|
|
1323
|
+
const packageGroups = [];
|
|
1324
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1325
|
+
for (const pkgFile of packageFiles) {
|
|
1326
|
+
const pkgDir = dirname(pkgFile.path);
|
|
1327
|
+
const related = otherFiles.filter((f) => {
|
|
1328
|
+
const fileDir = dirname(f.path);
|
|
1329
|
+
return fileDir === pkgDir || fileDir.startsWith(pkgDir + "/");
|
|
1330
|
+
});
|
|
1331
|
+
const configRelated = related.filter((f) => {
|
|
1332
|
+
const name = basename(f.path);
|
|
1333
|
+
return name.includes("config") || name.includes("tsconfig") || name.endsWith(".json") || name.startsWith(".");
|
|
1334
|
+
});
|
|
1335
|
+
const group = [pkgFile, ...configRelated];
|
|
1336
|
+
packageGroups.push(group);
|
|
1337
|
+
claimed.add(pkgFile);
|
|
1338
|
+
configRelated.forEach((f) => claimed.add(f));
|
|
1339
|
+
}
|
|
1340
|
+
const remainingFiles = summaries.filter((f) => !claimed.has(f));
|
|
1341
|
+
return { packageGroups, remainingFiles };
|
|
1342
|
+
}
|
|
1343
|
+
function pairTestsWithImplementation(summaries) {
|
|
1344
|
+
const testFiles = summaries.filter((f) => isTestFile(f.path));
|
|
1345
|
+
const implFiles = summaries.filter((f) => !isTestFile(f.path));
|
|
1346
|
+
const pairedGroups = [];
|
|
1347
|
+
const pairedImpls = /* @__PURE__ */ new Set();
|
|
1348
|
+
const pairedTests = /* @__PURE__ */ new Set();
|
|
1349
|
+
for (const testFile of testFiles) {
|
|
1350
|
+
const implPath = getImplementationPath(testFile.path);
|
|
1351
|
+
const implFile = implFiles.find((f) => f.path === implPath);
|
|
1352
|
+
if (implFile) {
|
|
1353
|
+
pairedGroups.push([implFile, testFile]);
|
|
1354
|
+
pairedImpls.add(implFile);
|
|
1355
|
+
pairedTests.add(testFile);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const unpairedFiles = [
|
|
1359
|
+
...implFiles.filter((f) => !pairedImpls.has(f)),
|
|
1360
|
+
...testFiles.filter((f) => !pairedTests.has(f))
|
|
1361
|
+
];
|
|
1362
|
+
return { pairedGroups, unpairedFiles };
|
|
1363
|
+
}
|
|
1364
|
+
function isTestFile(path) {
|
|
1365
|
+
return path.includes(".test.") || path.includes(".spec.") || path.includes("/__tests__/");
|
|
1366
|
+
}
|
|
1367
|
+
function getImplementationPath(testPath) {
|
|
1368
|
+
let implPath = testPath.replace(/\.test\.(ts|tsx|js|jsx)$/, ".$1").replace(/\.spec\.(ts|tsx|js|jsx)$/, ".$1");
|
|
1369
|
+
implPath = implPath.replace("/__tests__/", "/");
|
|
1370
|
+
return implPath;
|
|
1371
|
+
}
|
|
1372
|
+
function groupByCategory(summaries) {
|
|
1373
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1374
|
+
for (const summary of summaries) {
|
|
1375
|
+
const category = categorizeFile(summary.path);
|
|
1376
|
+
const existing = groups.get(category) || [];
|
|
1377
|
+
existing.push(summary);
|
|
1378
|
+
groups.set(category, existing);
|
|
1379
|
+
}
|
|
1380
|
+
return groups;
|
|
1381
|
+
}
|
|
1382
|
+
function inferPackageScope(paths) {
|
|
1383
|
+
const packagePath = paths.find((p) => p.endsWith("package.json"));
|
|
1384
|
+
if (!packagePath) {
|
|
1385
|
+
return void 0;
|
|
1386
|
+
}
|
|
1387
|
+
const parts = packagePath.split("/");
|
|
1388
|
+
if (parts.length > 1) {
|
|
1389
|
+
return parts[parts.length - 2];
|
|
1390
|
+
}
|
|
1391
|
+
return void 0;
|
|
1392
|
+
}
|
|
1393
|
+
function inferTypeFromChanges(file) {
|
|
1394
|
+
if (!file) {
|
|
1395
|
+
return "chore";
|
|
1396
|
+
}
|
|
1397
|
+
const { additions, deletions, status } = file;
|
|
1398
|
+
if (status === "added" || additions > 0 && deletions === 0) {
|
|
1399
|
+
return "feat";
|
|
1400
|
+
}
|
|
1401
|
+
if (status === "deleted" || deletions > 0 && additions === 0) {
|
|
1402
|
+
return "chore";
|
|
1403
|
+
}
|
|
1404
|
+
if (additions > deletions * 2) {
|
|
1405
|
+
return "feat";
|
|
1406
|
+
}
|
|
1407
|
+
if (deletions > additions * 2) {
|
|
1408
|
+
return "refactor";
|
|
1409
|
+
}
|
|
1410
|
+
return "refactor";
|
|
1411
|
+
}
|
|
1412
|
+
function categorizeFile(path) {
|
|
1413
|
+
const ext = extname(path);
|
|
1414
|
+
const name = basename(path);
|
|
1415
|
+
const dir = dirname(path);
|
|
1416
|
+
if (path.includes(".test.") || path.includes(".spec.") || path.includes("__tests__") || dir.includes("/test/") || dir.includes("/tests/")) {
|
|
1417
|
+
return "test";
|
|
1418
|
+
}
|
|
1419
|
+
if (ext === ".md" || dir.includes("/docs/") || name === "README.md") {
|
|
1420
|
+
return "docs";
|
|
1421
|
+
}
|
|
1422
|
+
if (name.startsWith(".") || name.includes("config") || ["package.json", "tsconfig.json", "eslint.config.js"].includes(name)) {
|
|
1423
|
+
return "config";
|
|
1424
|
+
}
|
|
1425
|
+
if (dir.includes(".github") || dir.includes(".gitlab") || name.includes("ci")) {
|
|
1426
|
+
return "ci";
|
|
1427
|
+
}
|
|
1428
|
+
if (dir.includes("/build/") || dir.includes("/dist/") || name.includes("build")) {
|
|
1429
|
+
return "build";
|
|
1430
|
+
}
|
|
1431
|
+
const topDir = dir.split("/")[0] || "root";
|
|
1432
|
+
return `src:${topDir}`;
|
|
1433
|
+
}
|
|
1434
|
+
function categoryToType(category) {
|
|
1435
|
+
if (category === "test") {
|
|
1436
|
+
return "test";
|
|
1437
|
+
}
|
|
1438
|
+
if (category === "docs") {
|
|
1439
|
+
return "docs";
|
|
1440
|
+
}
|
|
1441
|
+
if (category === "config") {
|
|
1442
|
+
return "chore";
|
|
1443
|
+
}
|
|
1444
|
+
if (category === "ci") {
|
|
1445
|
+
return "ci";
|
|
1446
|
+
}
|
|
1447
|
+
if (category === "build") {
|
|
1448
|
+
return "build";
|
|
1449
|
+
}
|
|
1450
|
+
return "chore";
|
|
1451
|
+
}
|
|
1452
|
+
function inferScope(paths) {
|
|
1453
|
+
if (paths.length === 0) {
|
|
1454
|
+
return void 0;
|
|
1455
|
+
}
|
|
1456
|
+
const dirs = paths.map((p) => dirname(p).split("/"));
|
|
1457
|
+
const firstDir = dirs[0];
|
|
1458
|
+
if (!firstDir) {
|
|
1459
|
+
return void 0;
|
|
1460
|
+
}
|
|
1461
|
+
if (dirs.length === 1) {
|
|
1462
|
+
return firstDir.length > 1 ? firstDir[1] : firstDir[0];
|
|
1463
|
+
}
|
|
1464
|
+
const commonPrefix = [];
|
|
1465
|
+
const minLength = Math.min(...dirs.map((d) => d.length));
|
|
1466
|
+
for (let i = 0; i < minLength; i++) {
|
|
1467
|
+
const segment = firstDir[i];
|
|
1468
|
+
if (segment && dirs.every((d) => d[i] === segment)) {
|
|
1469
|
+
commonPrefix.push(segment);
|
|
1470
|
+
} else {
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (commonPrefix.length > 0) {
|
|
1475
|
+
const scope = commonPrefix[commonPrefix.length - 1];
|
|
1476
|
+
if (scope && scope !== "." && scope !== "src") {
|
|
1477
|
+
return scope;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return void 0;
|
|
1481
|
+
}
|
|
1482
|
+
function generateMessage(type, files) {
|
|
1483
|
+
const count = files.length;
|
|
1484
|
+
const allAdded = files.every((f) => f.status === "added");
|
|
1485
|
+
const allDeleted = files.every((f) => f.status === "deleted");
|
|
1486
|
+
if (type === "test") {
|
|
1487
|
+
if (allAdded) {
|
|
1488
|
+
return `add ${count} test file${count > 1 ? "s" : ""}`;
|
|
1489
|
+
}
|
|
1490
|
+
return `update ${count} test file${count > 1 ? "s" : ""}`;
|
|
1491
|
+
}
|
|
1492
|
+
if (type === "docs") {
|
|
1493
|
+
if (allAdded) {
|
|
1494
|
+
return `add documentation`;
|
|
1495
|
+
}
|
|
1496
|
+
return `update documentation`;
|
|
1497
|
+
}
|
|
1498
|
+
if (type === "ci") {
|
|
1499
|
+
return `update ci configuration`;
|
|
1500
|
+
}
|
|
1501
|
+
if (type === "build") {
|
|
1502
|
+
return `update build configuration`;
|
|
1503
|
+
}
|
|
1504
|
+
if (type === "chore") {
|
|
1505
|
+
if (files.some((f) => f.path.includes("package.json"))) {
|
|
1506
|
+
return `update dependencies`;
|
|
1507
|
+
}
|
|
1508
|
+
return `update configuration`;
|
|
1509
|
+
}
|
|
1510
|
+
if (allAdded) {
|
|
1511
|
+
return `add ${count} file${count > 1 ? "s" : ""}`;
|
|
1512
|
+
}
|
|
1513
|
+
if (allDeleted) {
|
|
1514
|
+
return `remove ${count} file${count > 1 ? "s" : ""}`;
|
|
1515
|
+
}
|
|
1516
|
+
return `update ${count} file${count > 1 ? "s" : ""}`;
|
|
1517
|
+
}
|
|
1518
|
+
function inferReleaseHint(type, _files) {
|
|
1519
|
+
switch (type) {
|
|
1520
|
+
case "feat":
|
|
1521
|
+
return "minor";
|
|
1522
|
+
case "fix":
|
|
1523
|
+
return "patch";
|
|
1524
|
+
case "perf":
|
|
1525
|
+
return "patch";
|
|
1526
|
+
case "refactor":
|
|
1527
|
+
return "patch";
|
|
1528
|
+
default:
|
|
1529
|
+
return "none";
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// src/generator/pattern-detector.ts
|
|
1534
|
+
function analyzePatterns(summaries) {
|
|
1535
|
+
if (summaries.length === 0) {
|
|
1536
|
+
return {
|
|
1537
|
+
patternType: "mixed",
|
|
1538
|
+
confidence: 0,
|
|
1539
|
+
hints: [],
|
|
1540
|
+
suggestedType: null
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
if (isNewPackagePattern(summaries)) {
|
|
1544
|
+
const packagePath = summaries.find((s) => s.path.endsWith("package.json"))?.path;
|
|
1545
|
+
const packageName = packagePath ? extractPackageName(packagePath) : "unknown";
|
|
1546
|
+
return {
|
|
1547
|
+
patternType: "new-package",
|
|
1548
|
+
confidence: 0.95,
|
|
1549
|
+
hints: [
|
|
1550
|
+
`New package detected: ${packageName}`,
|
|
1551
|
+
`${summaries.length} new files including package.json`,
|
|
1552
|
+
"All files are truly new (isNewFile: true)",
|
|
1553
|
+
"This is a new feature (feat), not chore"
|
|
1554
|
+
],
|
|
1555
|
+
suggestedType: "feat"
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
if (isBulkMovePattern(summaries)) {
|
|
1559
|
+
const dirs = countUniqueDirs(summaries, 3);
|
|
1560
|
+
return {
|
|
1561
|
+
patternType: "refactor-move",
|
|
1562
|
+
confidence: 0.9,
|
|
1563
|
+
hints: [
|
|
1564
|
+
`Bulk move pattern: ${summaries.length} files added`,
|
|
1565
|
+
`Files existed before (isNewFile: false)`,
|
|
1566
|
+
`Organized into ${dirs} director${dirs === 1 ? "y" : "ies"}`,
|
|
1567
|
+
"This is refactoring (reorganization), not new feature"
|
|
1568
|
+
],
|
|
1569
|
+
suggestedType: "refactor"
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
if (isRefactorModificationPattern(summaries)) {
|
|
1573
|
+
const ratio = calculateAdditionRatio(summaries);
|
|
1574
|
+
return {
|
|
1575
|
+
patternType: "refactor-modify",
|
|
1576
|
+
confidence: 0.85,
|
|
1577
|
+
hints: [
|
|
1578
|
+
"All files are modified (not new)",
|
|
1579
|
+
`Low addition ratio: ${(ratio * 100).toFixed(0)}%`,
|
|
1580
|
+
"Mostly structural changes or deletions",
|
|
1581
|
+
"This is refactoring, not new feature"
|
|
1582
|
+
],
|
|
1583
|
+
suggestedType: "refactor"
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
const allDeleted = summaries.every((s) => s.status === "deleted");
|
|
1587
|
+
if (allDeleted) {
|
|
1588
|
+
return {
|
|
1589
|
+
patternType: "deletions",
|
|
1590
|
+
confidence: 0.98,
|
|
1591
|
+
hints: [
|
|
1592
|
+
"All files are deleted",
|
|
1593
|
+
"This is cleanup (chore), not feature"
|
|
1594
|
+
],
|
|
1595
|
+
suggestedType: "chore"
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
const totalAdd = summaries.reduce((sum, s) => sum + s.additions, 0);
|
|
1599
|
+
const totalDel = summaries.reduce((sum, s) => sum + s.deletions, 0);
|
|
1600
|
+
const deletionRatio = totalDel / (totalAdd + totalDel);
|
|
1601
|
+
if (deletionRatio > 0.8) {
|
|
1602
|
+
return {
|
|
1603
|
+
patternType: "deletions",
|
|
1604
|
+
confidence: 0.95,
|
|
1605
|
+
hints: [
|
|
1606
|
+
`Mostly deletions: ${(deletionRatio * 100).toFixed(0)}%`,
|
|
1607
|
+
"This is refactoring or cleanup, not feature"
|
|
1608
|
+
],
|
|
1609
|
+
suggestedType: "refactor"
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
patternType: "mixed",
|
|
1614
|
+
confidence: 0,
|
|
1615
|
+
hints: [],
|
|
1616
|
+
suggestedType: null
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
function isNewPackagePattern(summaries) {
|
|
1620
|
+
const hasPackageJson = summaries.some((s) => s.path.endsWith("package.json"));
|
|
1621
|
+
if (!hasPackageJson) {
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1624
|
+
if (summaries.length < 10) {
|
|
1625
|
+
return false;
|
|
1626
|
+
}
|
|
1627
|
+
const allAdded = summaries.every((s) => s.status === "added");
|
|
1628
|
+
if (!allAdded) {
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1631
|
+
const allIsNewFile = summaries.every((s) => s.isNewFile === true);
|
|
1632
|
+
if (!allIsNewFile) {
|
|
1633
|
+
return false;
|
|
1634
|
+
}
|
|
1635
|
+
const packageJsonPath = summaries.find((s) => s.path.endsWith("package.json")).path;
|
|
1636
|
+
const packageDir = packageJsonPath.split("/").slice(0, -1).join("/");
|
|
1637
|
+
const filesInPackage = summaries.filter((s) => s.path.startsWith(packageDir + "/"));
|
|
1638
|
+
const inPackageRatio = filesInPackage.length / summaries.length;
|
|
1639
|
+
return inPackageRatio > 0.8;
|
|
1640
|
+
}
|
|
1641
|
+
function isBulkMovePattern(summaries) {
|
|
1642
|
+
if (summaries.length < 20) {
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
const allAdded = summaries.every((s) => s.status === "added");
|
|
1646
|
+
if (!allAdded) {
|
|
1647
|
+
return false;
|
|
1648
|
+
}
|
|
1649
|
+
const notNewCount = summaries.filter((s) => s.isNewFile === false).length;
|
|
1650
|
+
const notNewRatio = notNewCount / summaries.length;
|
|
1651
|
+
if (notNewRatio <= 0.5) {
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
const uniqueDirs = countUniqueDirs(summaries, 3);
|
|
1655
|
+
return uniqueDirs < 5;
|
|
1656
|
+
}
|
|
1657
|
+
function isRefactorModificationPattern(summaries) {
|
|
1658
|
+
const allModified = summaries.every((s) => s.status === "modified");
|
|
1659
|
+
if (!allModified) {
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
const additionRatio = calculateAdditionRatio(summaries);
|
|
1663
|
+
return additionRatio < 0.4;
|
|
1664
|
+
}
|
|
1665
|
+
function countUniqueDirs(summaries, depth) {
|
|
1666
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1667
|
+
for (const summary of summaries) {
|
|
1668
|
+
const parts = summary.path.split("/");
|
|
1669
|
+
if (parts.length >= depth) {
|
|
1670
|
+
const dirPath = parts.slice(0, depth).join("/");
|
|
1671
|
+
dirs.add(dirPath);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return dirs.size;
|
|
1675
|
+
}
|
|
1676
|
+
function calculateAdditionRatio(summaries) {
|
|
1677
|
+
const totalAdditions = summaries.reduce((sum, s) => sum + s.additions, 0);
|
|
1678
|
+
const totalDeletions = summaries.reduce((sum, s) => sum + s.deletions, 0);
|
|
1679
|
+
const totalChanges = totalAdditions + totalDeletions;
|
|
1680
|
+
if (totalChanges === 0) {
|
|
1681
|
+
return 0;
|
|
1682
|
+
}
|
|
1683
|
+
return totalAdditions / totalChanges;
|
|
1684
|
+
}
|
|
1685
|
+
function extractPackageName(path) {
|
|
1686
|
+
const parts = path.split("/");
|
|
1687
|
+
const packageJsonIndex = parts.indexOf("package.json");
|
|
1688
|
+
if (packageJsonIndex > 0) {
|
|
1689
|
+
const packageName = parts[packageJsonIndex - 1];
|
|
1690
|
+
return packageName ?? "unknown";
|
|
1691
|
+
}
|
|
1692
|
+
return "unknown";
|
|
1693
|
+
}
|
|
1694
|
+
var SecretsDetectedError = class _SecretsDetectedError extends Error {
|
|
1695
|
+
secretMatches;
|
|
1696
|
+
constructor(matches, message) {
|
|
1697
|
+
super(message);
|
|
1698
|
+
this.name = "SecretsDetectedError";
|
|
1699
|
+
this.secretMatches = matches;
|
|
1700
|
+
if (Error.captureStackTrace) {
|
|
1701
|
+
Error.captureStackTrace(this, _SecretsDetectedError);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
var SECRET_FILE_PATTERNS = [
|
|
1706
|
+
// Environment files
|
|
1707
|
+
".env",
|
|
1708
|
+
".env.*",
|
|
1709
|
+
"*.env",
|
|
1710
|
+
".envrc",
|
|
1711
|
+
// NPM/Node
|
|
1712
|
+
".npmrc",
|
|
1713
|
+
".yarnrc",
|
|
1714
|
+
".yarnrc.yml",
|
|
1715
|
+
// SSH/GPG keys
|
|
1716
|
+
"*.key",
|
|
1717
|
+
"*.pem",
|
|
1718
|
+
"*.p12",
|
|
1719
|
+
"*.pfx",
|
|
1720
|
+
"id_rsa",
|
|
1721
|
+
"id_dsa",
|
|
1722
|
+
"id_ecdsa",
|
|
1723
|
+
"id_ed25519",
|
|
1724
|
+
"*.pub",
|
|
1725
|
+
// AWS
|
|
1726
|
+
".aws/**",
|
|
1727
|
+
"credentials",
|
|
1728
|
+
// Docker
|
|
1729
|
+
".docker/config.json",
|
|
1730
|
+
// Git credentials
|
|
1731
|
+
".git-credentials",
|
|
1732
|
+
".netrc",
|
|
1733
|
+
// Service account files
|
|
1734
|
+
"*-service-account.json",
|
|
1735
|
+
"*-serviceaccount.json",
|
|
1736
|
+
"service-account*.json",
|
|
1737
|
+
"serviceaccount*.json",
|
|
1738
|
+
// Kubernetes
|
|
1739
|
+
"kubeconfig",
|
|
1740
|
+
"*.kubeconfig",
|
|
1741
|
+
// Terraform
|
|
1742
|
+
"*.tfvars",
|
|
1743
|
+
"terraform.tfstate",
|
|
1744
|
+
"terraform.tfstate.backup",
|
|
1745
|
+
// Other common secrets
|
|
1746
|
+
"secrets.yml",
|
|
1747
|
+
"secrets.yaml",
|
|
1748
|
+
"secret.yml",
|
|
1749
|
+
"secret.yaml",
|
|
1750
|
+
"passwords.txt",
|
|
1751
|
+
"password.txt"
|
|
1752
|
+
];
|
|
1753
|
+
var SECRET_CONTENT_PATTERNS = [
|
|
1754
|
+
// API keys/tokens
|
|
1755
|
+
{ pattern: /api[_-]?key[s]?['":\s]*[a-zA-Z0-9_-]{20,}/i, name: "API Key" },
|
|
1756
|
+
{ pattern: /auth[_-]?token[s]?['":\s]*[a-zA-Z0-9_-]{20,}/i, name: "Auth Token" },
|
|
1757
|
+
{ pattern: /access[_-]?token[s]?['":\s]*[a-zA-Z0-9_-]{20,}/i, name: "Access Token" },
|
|
1758
|
+
// AWS
|
|
1759
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, name: "AWS Access Key ID" },
|
|
1760
|
+
{ pattern: /aws[_-]?secret[_-]?access[_-]?key/i, name: "AWS Secret Access Key" },
|
|
1761
|
+
// NPM
|
|
1762
|
+
{ pattern: /\/\/registry\.npmjs\.org\/:_authToken=/, name: "NPM Auth Token" },
|
|
1763
|
+
{ pattern: /npm_[A-Za-z0-9]{30,}/, name: "NPM Token" },
|
|
1764
|
+
// GitHub
|
|
1765
|
+
{ pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, name: "GitHub Token" },
|
|
1766
|
+
// Slack
|
|
1767
|
+
{ pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[A-Za-z0-9]{24,}/, name: "Slack Token" },
|
|
1768
|
+
// Private keys
|
|
1769
|
+
{ pattern: /-----BEGIN (RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY-----/, name: "Private Key" },
|
|
1770
|
+
// Passwords
|
|
1771
|
+
{ pattern: /password['":\s]*['"]/i, name: "Password" },
|
|
1772
|
+
// Generic patterns
|
|
1773
|
+
{ pattern: /secret['":\s]*['"]/i, name: "Secret" }
|
|
1774
|
+
];
|
|
1775
|
+
function isSecretFile(filePath) {
|
|
1776
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
1777
|
+
return SECRET_FILE_PATTERNS.some(
|
|
1778
|
+
(pattern) => minimatch(normalizedPath, pattern, { matchBase: true })
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
function detectSecretFiles(files) {
|
|
1782
|
+
return files.filter(isSecretFile);
|
|
1783
|
+
}
|
|
1784
|
+
function formatSecretsWarning(secretFiles) {
|
|
1785
|
+
const count = secretFiles.length;
|
|
1786
|
+
const filesList = secretFiles.map((f) => ` - ${f}`).join("\n");
|
|
1787
|
+
return [
|
|
1788
|
+
`\u{1F6A8} CRITICAL SECURITY ERROR: Detected ${count} file(s) with potential secrets:`,
|
|
1789
|
+
filesList,
|
|
1790
|
+
"",
|
|
1791
|
+
"\u26D4\uFE0F COMMIT GENERATION ABORTED",
|
|
1792
|
+
"",
|
|
1793
|
+
"These files contain sensitive data (API keys, tokens, credentials)",
|
|
1794
|
+
"that MUST NOT be committed to git or sent to LLM.",
|
|
1795
|
+
"",
|
|
1796
|
+
"\u2705 Actions to fix:",
|
|
1797
|
+
" 1. Add these files to .gitignore",
|
|
1798
|
+
" 2. Remove secrets from the files (use environment variables instead)",
|
|
1799
|
+
" 3. If already committed, use git filter-branch or BFG to remove from history",
|
|
1800
|
+
"",
|
|
1801
|
+
"\u26A0\uFE0F If you already ran commit:generate before, the secrets may have been",
|
|
1802
|
+
"sent to OpenAI. Rotate your credentials immediately."
|
|
1803
|
+
].join("\n");
|
|
1804
|
+
}
|
|
1805
|
+
function detectSecretsWithLocation(diffs) {
|
|
1806
|
+
const matches = [];
|
|
1807
|
+
for (const [file, diff] of diffs.entries()) {
|
|
1808
|
+
const lines = diff.split("\n");
|
|
1809
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
1810
|
+
const line = lines[lineIndex] ?? "";
|
|
1811
|
+
for (const { pattern, name } of SECRET_CONTENT_PATTERNS) {
|
|
1812
|
+
pattern.lastIndex = 0;
|
|
1813
|
+
const match = pattern.exec(line);
|
|
1814
|
+
if (match) {
|
|
1815
|
+
const matchedText = match[0];
|
|
1816
|
+
const column = match.index;
|
|
1817
|
+
const snippetStart = Math.max(0, column - 20);
|
|
1818
|
+
const snippetEnd = Math.min(line.length, column + matchedText.length + 20);
|
|
1819
|
+
const snippet = line.substring(snippetStart, snippetEnd);
|
|
1820
|
+
const displayText = matchedText.length > 50 ? matchedText.substring(0, 50) + "..." : matchedText;
|
|
1821
|
+
matches.push({
|
|
1822
|
+
file,
|
|
1823
|
+
line: lineIndex + 1,
|
|
1824
|
+
// 1-based line numbers
|
|
1825
|
+
column: column + 1,
|
|
1826
|
+
// 1-based column numbers
|
|
1827
|
+
pattern: pattern.source,
|
|
1828
|
+
patternName: name,
|
|
1829
|
+
snippet: snippet.trim(),
|
|
1830
|
+
matchedText: displayText
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return matches;
|
|
1837
|
+
}
|
|
1838
|
+
function formatSecretsReport(matches) {
|
|
1839
|
+
const count = matches.length;
|
|
1840
|
+
const fileCount = new Set(matches.map((m) => m.file)).size;
|
|
1841
|
+
const lines = [
|
|
1842
|
+
`\u{1F6A8} CRITICAL SECURITY ERROR: Detected ${count} potential secret(s) in ${fileCount} file(s)`,
|
|
1843
|
+
"",
|
|
1844
|
+
"\u26D4\uFE0F COMMIT GENERATION BLOCKED",
|
|
1845
|
+
""
|
|
1846
|
+
];
|
|
1847
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1848
|
+
for (const match of matches) {
|
|
1849
|
+
const existing = byFile.get(match.file) ?? [];
|
|
1850
|
+
existing.push(match);
|
|
1851
|
+
byFile.set(match.file, existing);
|
|
1852
|
+
}
|
|
1853
|
+
for (const [file, fileMatches] of byFile.entries()) {
|
|
1854
|
+
lines.push(`\u{1F4C4} ${file}:`);
|
|
1855
|
+
for (const match of fileMatches) {
|
|
1856
|
+
lines.push(` Line ${match.line}:${match.column} - ${match.patternName}`);
|
|
1857
|
+
lines.push(` Pattern: ${match.pattern.substring(0, 60)}${match.pattern.length > 60 ? "..." : ""}`);
|
|
1858
|
+
lines.push(` Matched: ${match.matchedText}`);
|
|
1859
|
+
lines.push(` Context: ...${match.snippet}...`);
|
|
1860
|
+
lines.push("");
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
lines.push(
|
|
1864
|
+
"\u{1F512} These files contain sensitive data (API keys, tokens, credentials)",
|
|
1865
|
+
"that MUST NOT be committed to git or sent to LLM.",
|
|
1866
|
+
"",
|
|
1867
|
+
"\u2705 Actions to fix:",
|
|
1868
|
+
" 1. Review each match above - some may be false positives (e.g., examples in comments)",
|
|
1869
|
+
" 2. If real secrets: Add files to .gitignore and remove secrets (use env vars)",
|
|
1870
|
+
" 3. If false positives: Use --allow-secrets flag to proceed with confirmation",
|
|
1871
|
+
"",
|
|
1872
|
+
"\u26A0\uFE0F If secrets were already sent to LLM in previous runs, rotate credentials immediately!"
|
|
1873
|
+
);
|
|
1874
|
+
return lines.join("\n");
|
|
1875
|
+
}
|
|
1876
|
+
async function promptUserConfirmation(question, defaultValue = false, autoConfirm = false) {
|
|
1877
|
+
if (autoConfirm) {
|
|
1878
|
+
console.log(`${question} [auto-confirmed with --yes]`);
|
|
1879
|
+
return true;
|
|
1880
|
+
}
|
|
1881
|
+
const rl = readline.createInterface({
|
|
1882
|
+
input: process.stdin,
|
|
1883
|
+
output: process.stdout
|
|
1884
|
+
});
|
|
1885
|
+
const defaultHint = defaultValue ? "[Y/n]" : "[y/N]";
|
|
1886
|
+
const fullQuestion = `${question} ${defaultHint}: `;
|
|
1887
|
+
return new Promise((resolve) => {
|
|
1888
|
+
rl.question(fullQuestion, (answer) => {
|
|
1889
|
+
rl.close();
|
|
1890
|
+
const normalized = answer.trim().toLowerCase();
|
|
1891
|
+
if (normalized === "") {
|
|
1892
|
+
resolve(defaultValue);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
if (normalized === "y" || normalized === "yes") {
|
|
1896
|
+
resolve(true);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (normalized === "n" || normalized === "no") {
|
|
1900
|
+
resolve(false);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
resolve(defaultValue);
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// src/generator/commit-plan.ts
|
|
1909
|
+
var CONFIDENCE_THRESHOLD = 0.7;
|
|
1910
|
+
var MAX_LLM_RETRIES = 2;
|
|
1911
|
+
var VALID_COMMIT_TYPES = /* @__PURE__ */ new Set(["feat", "fix", "refactor", "chore", "docs", "test", "build", "ci", "perf"]);
|
|
1912
|
+
function normalizeCommitId(id) {
|
|
1913
|
+
if (!id) {
|
|
1914
|
+
return void 0;
|
|
1915
|
+
}
|
|
1916
|
+
const trimmed = id.trim();
|
|
1917
|
+
if (!trimmed) {
|
|
1918
|
+
return void 0;
|
|
1919
|
+
}
|
|
1920
|
+
if (/^c\d+$/i.test(trimmed)) {
|
|
1921
|
+
return `c${trimmed.replace(/^c/i, "")}`;
|
|
1922
|
+
}
|
|
1923
|
+
if (/^\d+$/.test(trimmed)) {
|
|
1924
|
+
return `c${trimmed}`;
|
|
1925
|
+
}
|
|
1926
|
+
return trimmed;
|
|
1927
|
+
}
|
|
1928
|
+
function buildFallbackMessage(files) {
|
|
1929
|
+
const firstFile = files[0];
|
|
1930
|
+
const fileName = firstFile ? firstFile.split("/").pop() ?? firstFile : "files";
|
|
1931
|
+
return files.length === 1 ? `update ${fileName}` : `update ${files.length} files`;
|
|
1932
|
+
}
|
|
1933
|
+
function toSafeCommitGroup(commit, id) {
|
|
1934
|
+
const files = Array.isArray(commit.files) ? commit.files.filter((file) => typeof file === "string" && file.length > 0) : [];
|
|
1935
|
+
if (files.length === 0) {
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
const uniqueFiles = [...new Set(files)];
|
|
1939
|
+
const type = typeof commit.type === "string" && VALID_COMMIT_TYPES.has(commit.type) ? commit.type : "chore";
|
|
1940
|
+
const message = typeof commit.message === "string" && commit.message.trim().length > 0 ? commit.message.trim() : buildFallbackMessage(uniqueFiles);
|
|
1941
|
+
const releaseHint = commit.releaseHint ?? (type === "feat" ? "minor" : type === "fix" || type === "refactor" ? "patch" : "none");
|
|
1942
|
+
return {
|
|
1943
|
+
id,
|
|
1944
|
+
type,
|
|
1945
|
+
scope: commit.scope,
|
|
1946
|
+
message,
|
|
1947
|
+
body: commit.body,
|
|
1948
|
+
files: uniqueFiles,
|
|
1949
|
+
releaseHint,
|
|
1950
|
+
breaking: Boolean(commit.breaking),
|
|
1951
|
+
reasoning: commit.reasoning ?? {
|
|
1952
|
+
newBehavior: false,
|
|
1953
|
+
fixesBug: false,
|
|
1954
|
+
internalOnly: true,
|
|
1955
|
+
explanation: "Generated from Phase 3 fallback due invalid or incomplete LLM commit action.",
|
|
1956
|
+
confidence: 0.3
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
async function generateCommitPlan(options) {
|
|
1961
|
+
const { cwd, onProgress } = options;
|
|
1962
|
+
const logger = useLogger();
|
|
1963
|
+
const analytics = useAnalytics();
|
|
1964
|
+
const llm = useLLM();
|
|
1965
|
+
const startTime = Date.now();
|
|
1966
|
+
const gitStatus = await getGitStatus(cwd);
|
|
1967
|
+
const allFiles = getAllChangedFiles(gitStatus);
|
|
1968
|
+
if (allFiles.length === 0) {
|
|
1969
|
+
return createEmptyPlan(cwd, gitStatus);
|
|
1970
|
+
}
|
|
1971
|
+
const secretFiles = detectSecretFiles(allFiles);
|
|
1972
|
+
if (secretFiles.length > 0) {
|
|
1973
|
+
const basicMatches = secretFiles.map((file) => ({
|
|
1974
|
+
file,
|
|
1975
|
+
line: 0,
|
|
1976
|
+
column: 0,
|
|
1977
|
+
pattern: "SECRET_FILE_PATTERN",
|
|
1978
|
+
patternName: "Secret File Pattern",
|
|
1979
|
+
snippet: "",
|
|
1980
|
+
matchedText: file
|
|
1981
|
+
}));
|
|
1982
|
+
const warning = formatSecretsWarning(secretFiles);
|
|
1983
|
+
await logger.error("\u{1F6A8} SECRETS DETECTED", new Error("Secrets detected"), {
|
|
1984
|
+
secretFiles
|
|
1985
|
+
});
|
|
1986
|
+
console.error("\n" + warning + "\n");
|
|
1987
|
+
if (!options.allowSecrets) {
|
|
1988
|
+
throw new SecretsDetectedError(
|
|
1989
|
+
basicMatches,
|
|
1990
|
+
`Secrets detected in ${secretFiles.length} file(s). Use --allow-secrets to bypass after review, or add files to .gitignore.`
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
console.log("\n\u26A0\uFE0F WARNING: --allow-secrets flag detected\n");
|
|
1994
|
+
const confirmed = await promptUserConfirmation(
|
|
1995
|
+
`\u26A0\uFE0F Proceed with committing ${secretFiles.length} file(s) that may contain secrets?`,
|
|
1996
|
+
false
|
|
1997
|
+
// default: NO
|
|
1998
|
+
);
|
|
1999
|
+
if (!confirmed) {
|
|
2000
|
+
throw new SecretsDetectedError(
|
|
2001
|
+
basicMatches,
|
|
2002
|
+
"User declined to commit files with potential secrets."
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
await logger.warn("User confirmed to proceed with files containing potential secrets", {
|
|
2006
|
+
secretFiles,
|
|
2007
|
+
confirmedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2008
|
+
});
|
|
2009
|
+
console.log("\u2705 User confirmed - continuing with commit generation...\n");
|
|
2010
|
+
}
|
|
2011
|
+
const summaries = await getFileSummaries(cwd, allFiles);
|
|
2012
|
+
const patternAnalysis = analyzePatterns(summaries);
|
|
2013
|
+
if (patternAnalysis.confidence > 0.7) {
|
|
2014
|
+
await analytics?.track("commit.pattern-detected", {
|
|
2015
|
+
patternType: patternAnalysis.patternType,
|
|
2016
|
+
confidence: patternAnalysis.confidence,
|
|
2017
|
+
suggestedType: patternAnalysis.suggestedType,
|
|
2018
|
+
fileCount: summaries.length,
|
|
2019
|
+
hints: patternAnalysis.hints
|
|
2020
|
+
});
|
|
2021
|
+
await logger.debug(`Pattern detected: ${patternAnalysis.patternType} (confidence: ${patternAnalysis.confidence.toFixed(2)})`, {
|
|
2022
|
+
suggestedType: patternAnalysis.suggestedType,
|
|
2023
|
+
hints: patternAnalysis.hints
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
const recentCommits = options.recentCommits ?? await getRecentCommits(cwd, 10);
|
|
2027
|
+
let commits;
|
|
2028
|
+
let llmUsed = false;
|
|
2029
|
+
let tokensUsed;
|
|
2030
|
+
let escalated = false;
|
|
2031
|
+
if (llm) {
|
|
2032
|
+
try {
|
|
2033
|
+
const supportsNativeTools = typeof llm.chatWithTools === "function";
|
|
2034
|
+
let parsed;
|
|
2035
|
+
if (supportsNativeTools) {
|
|
2036
|
+
await logger.debug("Using native tools approach (chatWithTools)");
|
|
2037
|
+
onProgress?.("Analyzing with native tools...");
|
|
2038
|
+
const result = await generateWithNativeTools(
|
|
2039
|
+
llm,
|
|
2040
|
+
summaries,
|
|
2041
|
+
patternAnalysis,
|
|
2042
|
+
recentCommits,
|
|
2043
|
+
logger,
|
|
2044
|
+
onProgress
|
|
2045
|
+
);
|
|
2046
|
+
parsed = result.parsed;
|
|
2047
|
+
tokensUsed = result.tokensUsed;
|
|
2048
|
+
llmUsed = true;
|
|
2049
|
+
} else {
|
|
2050
|
+
await logger.debug("Using text-based parsing (fallback)");
|
|
2051
|
+
onProgress?.("Analyzing with text-based LLM...");
|
|
2052
|
+
const prompt = buildEnhancedPrompt(summaries, patternAnalysis, recentCommits);
|
|
2053
|
+
const result = await retryLLMCall(
|
|
2054
|
+
() => llm.complete(prompt, {
|
|
2055
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
2056
|
+
temperature: 0.3,
|
|
2057
|
+
maxTokens: 2e3
|
|
2058
|
+
}),
|
|
2059
|
+
"Phase 1",
|
|
2060
|
+
logger,
|
|
2061
|
+
onProgress
|
|
2062
|
+
);
|
|
2063
|
+
parsed = parseResponse(result.content, summaries, patternAnalysis);
|
|
2064
|
+
llmUsed = true;
|
|
2065
|
+
tokensUsed = result.tokensUsed;
|
|
2066
|
+
}
|
|
2067
|
+
const shouldEscalate = parsed.needsMoreContext || parsed.averageConfidence < CONFIDENCE_THRESHOLD || summaries.length >= 10;
|
|
2068
|
+
if (shouldEscalate) {
|
|
2069
|
+
const reason = summaries.length >= 10 ? `${summaries.length} files (\u226510)` : `confidence ${(parsed.averageConfidence * 100).toFixed(0)}%`;
|
|
2070
|
+
await logger.debug(`Escalating to Phase 2: ${reason}`, {
|
|
2071
|
+
fileCount: summaries.length,
|
|
2072
|
+
confidence: parsed.averageConfidence,
|
|
2073
|
+
needsMoreContext: parsed.needsMoreContext,
|
|
2074
|
+
requestedFiles: parsed.requestedFiles
|
|
2075
|
+
});
|
|
2076
|
+
onProgress?.(`${reason} - fetching diff...`);
|
|
2077
|
+
let filesToDiff;
|
|
2078
|
+
if (parsed.requestedFiles.length > 0) {
|
|
2079
|
+
const validFiles = parsed.requestedFiles.filter((f) => summaries.some((s) => s.path === f));
|
|
2080
|
+
filesToDiff = validFiles.slice(0, 15);
|
|
2081
|
+
if (validFiles.length > 15) {
|
|
2082
|
+
await logger.warn(`LLM requested ${validFiles.length} files, truncated to 15 most critical`, {
|
|
2083
|
+
requestedCount: validFiles.length,
|
|
2084
|
+
truncatedCount: 15
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
} else {
|
|
2088
|
+
const sortedByChanges = [...summaries].sort((a, b) => {
|
|
2089
|
+
const aChanges = (a.additions ?? 0) + (a.deletions ?? 0);
|
|
2090
|
+
const bChanges = (b.additions ?? 0) + (b.deletions ?? 0);
|
|
2091
|
+
return bChanges - aChanges;
|
|
2092
|
+
});
|
|
2093
|
+
filesToDiff = sortedByChanges.slice(0, 15).map((s) => s.path);
|
|
2094
|
+
await logger.debug("Auto-selected top 15 most changed files for Phase 2", {
|
|
2095
|
+
totalFiles: summaries.length,
|
|
2096
|
+
selectedCount: filesToDiff.length
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
const diffs = await getFileDiffs(cwd, filesToDiff);
|
|
2100
|
+
const secretMatches = detectSecretsWithLocation(diffs);
|
|
2101
|
+
if (secretMatches.length > 0) {
|
|
2102
|
+
const report = formatSecretsReport(secretMatches);
|
|
2103
|
+
await logger.error("\u{1F6A8} SECRETS DETECTED IN DIFF CONTENT", new Error("Secrets in diff"), {
|
|
2104
|
+
secretMatches
|
|
2105
|
+
});
|
|
2106
|
+
onProgress?.("\u{1F6A8} Secrets detected in diff");
|
|
2107
|
+
console.error("\n" + report + "\n");
|
|
2108
|
+
if (!options.allowSecrets) {
|
|
2109
|
+
throw new SecretsDetectedError(
|
|
2110
|
+
secretMatches,
|
|
2111
|
+
`Secrets detected in ${secretMatches.length} location(s). Use --allow-secrets to bypass after review, or remove secrets before committing.`
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
console.log("\n\u26A0\uFE0F WARNING: --allow-secrets flag detected\n");
|
|
2115
|
+
const confirmed = await promptUserConfirmation(
|
|
2116
|
+
`\u26A0\uFE0F Proceed with committing changes that contain ${secretMatches.length} potential secret(s)?`,
|
|
2117
|
+
false,
|
|
2118
|
+
// default: NO
|
|
2119
|
+
options.autoConfirm
|
|
2120
|
+
// auto-confirm if --yes flag
|
|
2121
|
+
);
|
|
2122
|
+
if (!confirmed) {
|
|
2123
|
+
throw new SecretsDetectedError(
|
|
2124
|
+
secretMatches,
|
|
2125
|
+
"User declined to commit changes with potential secrets."
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
await logger.warn("User confirmed to proceed with diff containing potential secrets", {
|
|
2129
|
+
secretMatches: secretMatches.map((m) => ({ file: m.file, line: m.line, pattern: m.patternName })),
|
|
2130
|
+
confirmedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2131
|
+
});
|
|
2132
|
+
console.log("\u2705 User confirmed - continuing with Phase 2 analysis...\n");
|
|
2133
|
+
onProgress?.("Re-analyzing with diff context (Phase 2)...");
|
|
2134
|
+
}
|
|
2135
|
+
if (diffs.size > 0) {
|
|
2136
|
+
const maxTokensPhase2 = Math.min(6e3, 3e3 + Math.floor(summaries.length / 20) * 500);
|
|
2137
|
+
await logger.debug("Re-analyzing with diff context (Phase 2)", {
|
|
2138
|
+
filesWithDiff: filesToDiff.length,
|
|
2139
|
+
supportsNativeTools
|
|
2140
|
+
});
|
|
2141
|
+
onProgress?.("Re-analyzing with diff context (Phase 2)...");
|
|
2142
|
+
if (supportsNativeTools) {
|
|
2143
|
+
const resultWithDiff = await generateWithNativeToolsPhase2(
|
|
2144
|
+
llm,
|
|
2145
|
+
summaries,
|
|
2146
|
+
diffs,
|
|
2147
|
+
recentCommits,
|
|
2148
|
+
logger,
|
|
2149
|
+
onProgress
|
|
2150
|
+
);
|
|
2151
|
+
parsed = resultWithDiff.parsed;
|
|
2152
|
+
tokensUsed = (tokensUsed ?? 0) + (resultWithDiff.tokensUsed ?? 0);
|
|
2153
|
+
escalated = true;
|
|
2154
|
+
} else {
|
|
2155
|
+
const promptWithDiff = buildPromptWithDiff(summaries, diffs, recentCommits);
|
|
2156
|
+
const resultWithDiff = await retryLLMCall(
|
|
2157
|
+
() => llm.complete(promptWithDiff, {
|
|
2158
|
+
systemPrompt: SYSTEM_PROMPT_WITH_DIFF,
|
|
2159
|
+
temperature: 0.3,
|
|
2160
|
+
maxTokens: maxTokensPhase2
|
|
2161
|
+
}),
|
|
2162
|
+
"Phase 2",
|
|
2163
|
+
logger,
|
|
2164
|
+
onProgress
|
|
2165
|
+
);
|
|
2166
|
+
parsed = parseResponse(resultWithDiff.content, summaries, patternAnalysis);
|
|
2167
|
+
tokensUsed = (tokensUsed ?? 0) + (resultWithDiff.tokensUsed ?? 0);
|
|
2168
|
+
escalated = true;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
commits = parsed.commits;
|
|
2173
|
+
commits = await validateAndFixCommits(commits, summaries, llm, llmUsed, logger, onProgress);
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
if (error instanceof SecretsDetectedError) {
|
|
2176
|
+
throw error;
|
|
2177
|
+
}
|
|
2178
|
+
await logger.warn("LLM generation failed after retries, falling back to heuristics", {
|
|
2179
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2180
|
+
});
|
|
2181
|
+
onProgress?.("LLM failed, using heuristics...");
|
|
2182
|
+
commits = generateHeuristicPlan(summaries);
|
|
2183
|
+
}
|
|
2184
|
+
} else {
|
|
2185
|
+
commits = generateHeuristicPlan(summaries);
|
|
2186
|
+
}
|
|
2187
|
+
const typeDistribution = commits.reduce((acc, commit) => {
|
|
2188
|
+
acc[commit.type] = (acc[commit.type] || 0) + 1;
|
|
2189
|
+
return acc;
|
|
2190
|
+
}, {});
|
|
2191
|
+
await analytics?.track("commit.generation-complete", {
|
|
2192
|
+
totalFiles: summaries.length,
|
|
2193
|
+
totalCommits: commits.length,
|
|
2194
|
+
llmUsed,
|
|
2195
|
+
escalated,
|
|
2196
|
+
tokensUsed,
|
|
2197
|
+
durationMs: Date.now() - startTime,
|
|
2198
|
+
typeDistribution,
|
|
2199
|
+
cwd
|
|
2200
|
+
});
|
|
2201
|
+
return {
|
|
2202
|
+
schemaVersion: "1.0",
|
|
2203
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2204
|
+
repoRoot: cwd,
|
|
2205
|
+
gitStatus,
|
|
2206
|
+
commits,
|
|
2207
|
+
metadata: {
|
|
2208
|
+
totalFiles: summaries.length,
|
|
2209
|
+
totalCommits: commits.length,
|
|
2210
|
+
llmUsed,
|
|
2211
|
+
tokensUsed,
|
|
2212
|
+
escalated
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
function createEmptyPlan(cwd, gitStatus) {
|
|
2217
|
+
return {
|
|
2218
|
+
schemaVersion: "1.0",
|
|
2219
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2220
|
+
repoRoot: cwd,
|
|
2221
|
+
gitStatus,
|
|
2222
|
+
commits: [],
|
|
2223
|
+
metadata: {
|
|
2224
|
+
totalFiles: 0,
|
|
2225
|
+
totalCommits: 0,
|
|
2226
|
+
llmUsed: false
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
async function generateWithNativeTools(llm, summaries, patternAnalysis, recentCommits, logger, _onProgress) {
|
|
2231
|
+
if (!llm || !llm.chatWithTools) {
|
|
2232
|
+
throw new Error("LLM does not support native tools (chatWithTools)");
|
|
2233
|
+
}
|
|
2234
|
+
const userPrompt = buildEnhancedPrompt(summaries, patternAnalysis, recentCommits);
|
|
2235
|
+
const messages = [
|
|
2236
|
+
{
|
|
2237
|
+
role: "system",
|
|
2238
|
+
content: SYSTEM_PROMPT
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
role: "user",
|
|
2242
|
+
content: userPrompt
|
|
2243
|
+
}
|
|
2244
|
+
];
|
|
2245
|
+
const response = await llm.chatWithTools(messages, {
|
|
2246
|
+
tools: [COMMIT_PLAN_TOOL],
|
|
2247
|
+
toolChoice: {
|
|
2248
|
+
type: "function",
|
|
2249
|
+
function: { name: "generate_commit_plan" }
|
|
2250
|
+
},
|
|
2251
|
+
temperature: 0.3
|
|
2252
|
+
});
|
|
2253
|
+
const toolCall = response.toolCalls?.[0];
|
|
2254
|
+
if (!toolCall || toolCall.name !== "generate_commit_plan") {
|
|
2255
|
+
throw new Error("LLM did not call generate_commit_plan tool");
|
|
2256
|
+
}
|
|
2257
|
+
const toolArgs = toolCall.input;
|
|
2258
|
+
const totalConfidence = toolArgs.commits.reduce((sum, c) => {
|
|
2259
|
+
const confidence = c.reasoning?.confidence ?? 0.5;
|
|
2260
|
+
return sum + confidence;
|
|
2261
|
+
}, 0);
|
|
2262
|
+
const averageConfidence = toolArgs.commits.length > 0 ? totalConfidence / toolArgs.commits.length : 0;
|
|
2263
|
+
await logger.debug("Native tools Phase 1 result", {
|
|
2264
|
+
needsMoreContext: toolArgs.needsMoreContext,
|
|
2265
|
+
requestedFiles: toolArgs.requestedFiles?.length ?? 0,
|
|
2266
|
+
commitsCount: toolArgs.commits.length,
|
|
2267
|
+
averageConfidence
|
|
2268
|
+
});
|
|
2269
|
+
return {
|
|
2270
|
+
parsed: {
|
|
2271
|
+
needsMoreContext: Boolean(toolArgs.needsMoreContext),
|
|
2272
|
+
requestedFiles: toolArgs.requestedFiles ?? [],
|
|
2273
|
+
commits: toolArgs.commits,
|
|
2274
|
+
averageConfidence
|
|
2275
|
+
},
|
|
2276
|
+
tokensUsed: response.usage ? response.usage.promptTokens + response.usage.completionTokens : void 0
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
async function generateMissingFilesCommit(llm, missingSummaries, existingCommits, logger, onProgress) {
|
|
2280
|
+
if (!llm || !llm.chatWithTools) {
|
|
2281
|
+
return null;
|
|
2282
|
+
}
|
|
2283
|
+
const existingCommitsContext = existingCommits.map((c) => {
|
|
2284
|
+
const filesPreview = c.files.length <= 3 ? c.files.join(", ") : `${c.files.slice(0, 3).join(", ")} and ${c.files.length - 3} more`;
|
|
2285
|
+
return `[${c.id}] ${c.type}${c.scope ? `(${c.scope})` : ""}: ${c.message}
|
|
2286
|
+
Files: ${filesPreview}`;
|
|
2287
|
+
}).join("\n\n");
|
|
2288
|
+
const missingFilesList = missingSummaries.map((s) => {
|
|
2289
|
+
const stats = s.binary ? "binary" : `+${s.additions}/-${s.deletions}`;
|
|
2290
|
+
const isNew = s.isNewFile ? "IsNewFile: true" : "IsNewFile: false";
|
|
2291
|
+
return `- ${s.path} (${s.status}, ${stats}, ${isNew})`;
|
|
2292
|
+
}).join("\n");
|
|
2293
|
+
const systemPrompt = `You are analyzing files that were not included in the initial commit plan.
|
|
2294
|
+
|
|
2295
|
+
CONTEXT: These files were not classified by the LLM in previous phases. Your task is to determine:
|
|
2296
|
+
1. Why they were missed (config files, minor changes, unrelated changes, etc.)
|
|
2297
|
+
2. Whether they belong to an EXISTING commit or need a NEW commit
|
|
2298
|
+
3. What type of commit they should be (chore, refactor, fix, feat, docs, test, etc.)
|
|
2299
|
+
|
|
2300
|
+
CRITICAL: You have TWO actions available:
|
|
2301
|
+
|
|
2302
|
+
ACTION 1: extend_existing (PREFER THIS if file is related to existing commit)
|
|
2303
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2304
|
+
Use when a file logically belongs to an already created commit.
|
|
2305
|
+
|
|
2306
|
+
Example: If existing commit is "feat(fs): add fs adapter" and you see:
|
|
2307
|
+
- packages/adapters-fs/src/secure-storage.test.ts (test for fs adapter)
|
|
2308
|
+
|
|
2309
|
+
Then use:
|
|
2310
|
+
{
|
|
2311
|
+
"action": "extend_existing",
|
|
2312
|
+
"existingCommitId": "c1",
|
|
2313
|
+
"files": ["packages/adapters-fs/src/secure-storage.test.ts"]
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
The file will be added to commit c1 instead of creating a new commit.
|
|
2317
|
+
|
|
2318
|
+
ACTION 2: create_new (use when file is unrelated to existing commits)
|
|
2319
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2320
|
+
Use when files don't fit into any existing commit.
|
|
2321
|
+
|
|
2322
|
+
DEFAULT to "chore" unless you see clear evidence of feat/fix/refactor.
|
|
2323
|
+
|
|
2324
|
+
IMPORTANT: These are leftover files - they are usually:
|
|
2325
|
+
- Configuration files (package.json, tsconfig.json, etc.)
|
|
2326
|
+
- Minor updates to existing files
|
|
2327
|
+
- Test files or documentation
|
|
2328
|
+
- Build/tooling changes
|
|
2329
|
+
|
|
2330
|
+
CRITICAL: WRITE INFORMATIVE COMMIT MESSAGES (not generic):
|
|
2331
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
2332
|
+
\u274C BAD (too generic):
|
|
2333
|
+
- "update files"
|
|
2334
|
+
- "update configuration"
|
|
2335
|
+
- "update additional files"
|
|
2336
|
+
- "update 30 files"
|
|
2337
|
+
|
|
2338
|
+
\u2705 GOOD (specific and descriptive):
|
|
2339
|
+
- "update TypeScript and ESLint configuration for strict mode"
|
|
2340
|
+
- "update package dependencies for security patches"
|
|
2341
|
+
- "update build configuration to support ESM modules"
|
|
2342
|
+
|
|
2343
|
+
Guidelines:
|
|
2344
|
+
- Include WHAT was changed (specific files/components)
|
|
2345
|
+
- Include WHY if relevant (context about the change)
|
|
2346
|
+
- Use concrete nouns (not "files", "additional files")
|
|
2347
|
+
- Add context that helps reviewers understand the change`;
|
|
2348
|
+
const userPrompt = `Existing commits (you can add files to these using extend_existing action):
|
|
2349
|
+
${existingCommitsContext}
|
|
2350
|
+
|
|
2351
|
+
Files that need classification (${missingSummaries.length} remaining):
|
|
2352
|
+
${missingFilesList}
|
|
2353
|
+
|
|
2354
|
+
IMPORTANT: You don't need to classify ALL files in one response if there are too many.
|
|
2355
|
+
- If you can confidently classify some files \u2192 do it
|
|
2356
|
+
- If you're unsure about some files \u2192 skip them (they'll be processed in next iteration)
|
|
2357
|
+
- Focus on files you can group logically
|
|
2358
|
+
|
|
2359
|
+
For each file, choose the appropriate action:
|
|
2360
|
+
1. extend_existing - if file belongs to an existing commit (e.g., test file for fs adapter \u2192 add to "feat(fs): add fs adapter")
|
|
2361
|
+
2. create_new - if file doesn't fit into any existing commit
|
|
2362
|
+
|
|
2363
|
+
You may create:
|
|
2364
|
+
- ONE new commit if all remaining files are related (same purpose, same change type)
|
|
2365
|
+
- MULTIPLE new commits if files have different purposes (e.g., separate config changes from test updates)
|
|
2366
|
+
- MIX of extend_existing and create_new actions (recommended!)
|
|
2367
|
+
|
|
2368
|
+
CRITICAL: Be specific in each commit message - describe WHAT is being changed, not just "update files".
|
|
2369
|
+
PREFER extend_existing when possible to avoid creating unnecessary commits.`;
|
|
2370
|
+
const messages = [
|
|
2371
|
+
{ role: "system", content: systemPrompt },
|
|
2372
|
+
{ role: "user", content: userPrompt }
|
|
2373
|
+
];
|
|
2374
|
+
try {
|
|
2375
|
+
onProgress?.("Classifying remaining files (Phase 3)...");
|
|
2376
|
+
const response = await llm.chatWithTools(messages, {
|
|
2377
|
+
tools: [COMMIT_PLAN_TOOL_PHASE3],
|
|
2378
|
+
toolChoice: {
|
|
2379
|
+
type: "function",
|
|
2380
|
+
function: { name: "generate_commit_plan" }
|
|
2381
|
+
},
|
|
2382
|
+
temperature: 0.3
|
|
2383
|
+
});
|
|
2384
|
+
const toolCall = response.toolCalls?.[0];
|
|
2385
|
+
if (!toolCall || toolCall.name !== "generate_commit_plan") {
|
|
2386
|
+
await logger.warn("Phase 3: LLM did not call tool, using fallback");
|
|
2387
|
+
return null;
|
|
2388
|
+
}
|
|
2389
|
+
const toolArgs = toolCall.input;
|
|
2390
|
+
if (!toolArgs.commits || toolArgs.commits.length === 0) {
|
|
2391
|
+
await logger.warn("Phase 3: LLM returned empty commits, using fallback");
|
|
2392
|
+
return null;
|
|
2393
|
+
}
|
|
2394
|
+
const newCommits = [];
|
|
2395
|
+
let extendedCount = 0;
|
|
2396
|
+
let createdCount = 0;
|
|
2397
|
+
for (const commit of toolArgs.commits) {
|
|
2398
|
+
if (!Array.isArray(commit.files) || commit.files.length === 0) {
|
|
2399
|
+
await logger.warn("Phase 3: Skipping commit action with empty files", {
|
|
2400
|
+
action: commit.action,
|
|
2401
|
+
existingCommitId: commit.existingCommitId
|
|
2402
|
+
});
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
const action = commit.action || "create_new";
|
|
2406
|
+
if (action === "extend_existing") {
|
|
2407
|
+
const normalizedRequestedId = normalizeCommitId(commit.existingCommitId);
|
|
2408
|
+
const existingCommit = existingCommits.find((c) => normalizeCommitId(c.id) === normalizedRequestedId);
|
|
2409
|
+
if (existingCommit) {
|
|
2410
|
+
existingCommit.files.push(...commit.files);
|
|
2411
|
+
extendedCount++;
|
|
2412
|
+
await logger.debug("Phase 3: Extended existing commit", {
|
|
2413
|
+
commitId: existingCommit.id,
|
|
2414
|
+
message: existingCommit.message,
|
|
2415
|
+
addedFiles: commit.files.length
|
|
2416
|
+
});
|
|
2417
|
+
} else {
|
|
2418
|
+
await logger.warn("Phase 3: Commit ID not found, creating new instead", {
|
|
2419
|
+
requestedId: commit.existingCommitId,
|
|
2420
|
+
normalizedRequestedId,
|
|
2421
|
+
availableIds: existingCommits.map((c) => c.id).slice(0, 10)
|
|
2422
|
+
});
|
|
2423
|
+
const safeCommit = toSafeCommitGroup(
|
|
2424
|
+
commit,
|
|
2425
|
+
`c${existingCommits.length + newCommits.length + 1}`
|
|
2426
|
+
);
|
|
2427
|
+
if (safeCommit) {
|
|
2428
|
+
newCommits.push(safeCommit);
|
|
2429
|
+
createdCount++;
|
|
2430
|
+
} else {
|
|
2431
|
+
await logger.warn("Phase 3: Fallback commit had no valid files, skipped", {
|
|
2432
|
+
requestedId: commit.existingCommitId
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
} else {
|
|
2437
|
+
const safeCommit = toSafeCommitGroup(
|
|
2438
|
+
commit,
|
|
2439
|
+
`c${existingCommits.length + newCommits.length + 1}`
|
|
2440
|
+
);
|
|
2441
|
+
if (safeCommit) {
|
|
2442
|
+
newCommits.push(safeCommit);
|
|
2443
|
+
createdCount++;
|
|
2444
|
+
} else {
|
|
2445
|
+
await logger.warn("Phase 3: create_new action had no valid files, skipped");
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
await logger.debug("Phase 3: Processed commits", {
|
|
2450
|
+
total: toolArgs.commits.length,
|
|
2451
|
+
extended: extendedCount,
|
|
2452
|
+
created: createdCount,
|
|
2453
|
+
newCommitsCount: newCommits.length
|
|
2454
|
+
});
|
|
2455
|
+
return newCommits;
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
await logger.warn("Phase 3 failed, using fallback", {
|
|
2458
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2459
|
+
});
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
async function generateWithNativeToolsPhase2(llm, summaries, diffs, recentCommits, logger, _onProgress) {
|
|
2464
|
+
if (!llm || !llm.chatWithTools) {
|
|
2465
|
+
throw new Error("LLM does not support native tools (chatWithTools)");
|
|
2466
|
+
}
|
|
2467
|
+
const userPrompt = buildPromptWithDiff(summaries, diffs, recentCommits);
|
|
2468
|
+
const messages = [
|
|
2469
|
+
{
|
|
2470
|
+
role: "system",
|
|
2471
|
+
content: SYSTEM_PROMPT_WITH_DIFF
|
|
2472
|
+
},
|
|
2473
|
+
{
|
|
2474
|
+
role: "user",
|
|
2475
|
+
content: userPrompt
|
|
2476
|
+
}
|
|
2477
|
+
];
|
|
2478
|
+
const response = await llm.chatWithTools(messages, {
|
|
2479
|
+
tools: [COMMIT_PLAN_TOOL],
|
|
2480
|
+
toolChoice: {
|
|
2481
|
+
type: "function",
|
|
2482
|
+
function: { name: "generate_commit_plan" }
|
|
2483
|
+
},
|
|
2484
|
+
temperature: 0.3
|
|
2485
|
+
});
|
|
2486
|
+
const toolCall = response.toolCalls?.[0];
|
|
2487
|
+
if (!toolCall || toolCall.name !== "generate_commit_plan") {
|
|
2488
|
+
throw new Error("LLM did not call generate_commit_plan tool in Phase 2");
|
|
2489
|
+
}
|
|
2490
|
+
const toolArgs = toolCall.input;
|
|
2491
|
+
const totalConfidence = toolArgs.commits.reduce((sum, c) => {
|
|
2492
|
+
const confidence = c.reasoning?.confidence ?? 0.5;
|
|
2493
|
+
return sum + confidence;
|
|
2494
|
+
}, 0);
|
|
2495
|
+
const averageConfidence = toolArgs.commits.length > 0 ? totalConfidence / toolArgs.commits.length : 0;
|
|
2496
|
+
await logger.debug("Native tools Phase 2 result", {
|
|
2497
|
+
commitsCount: toolArgs.commits.length,
|
|
2498
|
+
averageConfidence
|
|
2499
|
+
});
|
|
2500
|
+
return {
|
|
2501
|
+
parsed: {
|
|
2502
|
+
needsMoreContext: false,
|
|
2503
|
+
// Phase 2 is final, no more escalation
|
|
2504
|
+
requestedFiles: [],
|
|
2505
|
+
commits: toolArgs.commits,
|
|
2506
|
+
averageConfidence
|
|
2507
|
+
},
|
|
2508
|
+
tokensUsed: response.usage ? response.usage.promptTokens + response.usage.completionTokens : void 0
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
async function validateAndFixCommits(commits, summaries, llm, llmUsed, logger, onProgress) {
|
|
2512
|
+
const realFiles = new Set(summaries.map((s) => s.path));
|
|
2513
|
+
for (const commit of commits) {
|
|
2514
|
+
const validFiles = [];
|
|
2515
|
+
for (const file of commit.files) {
|
|
2516
|
+
if (realFiles.has(file)) {
|
|
2517
|
+
validFiles.push(file);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
commit.files = validFiles;
|
|
2521
|
+
}
|
|
2522
|
+
let nonEmptyCommits = commits.filter((c) => c.files.length > 0);
|
|
2523
|
+
const seenFiles = /* @__PURE__ */ new Set();
|
|
2524
|
+
const duplicates = [];
|
|
2525
|
+
for (const commit of nonEmptyCommits) {
|
|
2526
|
+
const uniqueFiles = [];
|
|
2527
|
+
for (const file of commit.files) {
|
|
2528
|
+
if (!seenFiles.has(file)) {
|
|
2529
|
+
uniqueFiles.push(file);
|
|
2530
|
+
seenFiles.add(file);
|
|
2531
|
+
} else {
|
|
2532
|
+
duplicates.push({ file, commit: commit.id });
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
commit.files = uniqueFiles;
|
|
2536
|
+
}
|
|
2537
|
+
if (duplicates.length > 0) {
|
|
2538
|
+
logger.warn(`LLM returned ${duplicates.length} duplicate file(s) across commits - removed duplicates`, {
|
|
2539
|
+
duplicateCount: duplicates.length,
|
|
2540
|
+
samples: duplicates.slice(0, 5).map((d) => `${d.file} in ${d.commit}`)
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
nonEmptyCommits = nonEmptyCommits.filter((c) => c.files.length > 0);
|
|
2544
|
+
const MAX_PHASE3_ITERATIONS = 5;
|
|
2545
|
+
let phase3Iteration = 0;
|
|
2546
|
+
while (phase3Iteration < MAX_PHASE3_ITERATIONS) {
|
|
2547
|
+
const allFilesInCommits = new Set(nonEmptyCommits.flatMap((c) => c.files));
|
|
2548
|
+
const missingFiles = summaries.map((s) => s.path).filter((f) => !allFilesInCommits.has(f));
|
|
2549
|
+
if (missingFiles.length === 0) {
|
|
2550
|
+
if (phase3Iteration > 0) {
|
|
2551
|
+
await logger.info("Phase 3: All files classified", {
|
|
2552
|
+
totalIterations: phase3Iteration,
|
|
2553
|
+
totalCommits: nonEmptyCommits.length
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
break;
|
|
2557
|
+
}
|
|
2558
|
+
phase3Iteration++;
|
|
2559
|
+
await logger.debug(`Phase 3 iteration ${phase3Iteration}/${MAX_PHASE3_ITERATIONS}`, {
|
|
2560
|
+
missingFiles: missingFiles.length,
|
|
2561
|
+
processedSoFar: summaries.length - missingFiles.length,
|
|
2562
|
+
totalFiles: summaries.length,
|
|
2563
|
+
progress: `${Math.round((summaries.length - missingFiles.length) / summaries.length * 100)}%`
|
|
2564
|
+
});
|
|
2565
|
+
if (onProgress) {
|
|
2566
|
+
const progress = Math.round((summaries.length - missingFiles.length) / summaries.length * 100);
|
|
2567
|
+
onProgress(`Classifying remaining files (Phase 3, iteration ${phase3Iteration}, ${progress}%)...`);
|
|
2568
|
+
}
|
|
2569
|
+
const missingSummaries = summaries.filter((s) => missingFiles.includes(s.path));
|
|
2570
|
+
let missingCommits = null;
|
|
2571
|
+
if (llmUsed && llm) {
|
|
2572
|
+
missingCommits = await generateMissingFilesCommit(
|
|
2573
|
+
llm,
|
|
2574
|
+
missingSummaries,
|
|
2575
|
+
nonEmptyCommits,
|
|
2576
|
+
// ← pass updated commits list (includes previous iterations)
|
|
2577
|
+
logger,
|
|
2578
|
+
onProgress
|
|
2579
|
+
);
|
|
2580
|
+
if (missingCommits && missingCommits.length > 0) {
|
|
2581
|
+
await logger.debug(`Phase 3 iteration ${phase3Iteration}: Processed commits`, {
|
|
2582
|
+
commitCount: missingCommits.length,
|
|
2583
|
+
filesCount: missingCommits.reduce((sum, c) => sum + c.files.length, 0)
|
|
2584
|
+
});
|
|
2585
|
+
nonEmptyCommits.push(...missingCommits);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
const newMissingFiles = summaries.map((s) => s.path).filter((f) => !new Set(nonEmptyCommits.flatMap((c) => c.files)).has(f));
|
|
2589
|
+
if (newMissingFiles.length === missingFiles.length) {
|
|
2590
|
+
await logger.warn("Phase 3: LLM made no progress, stopping iterations", {
|
|
2591
|
+
iteration: phase3Iteration,
|
|
2592
|
+
stillMissing: newMissingFiles.length
|
|
2593
|
+
});
|
|
2594
|
+
break;
|
|
2595
|
+
}
|
|
2596
|
+
if (newMissingFiles.length <= 3 && phase3Iteration >= 2) {
|
|
2597
|
+
await logger.debug("Phase 3: Few files left, proceeding to fallback");
|
|
2598
|
+
break;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
const finalMissingFiles = summaries.map((s) => s.path).filter((f) => !new Set(nonEmptyCommits.flatMap((c) => c.files)).has(f));
|
|
2602
|
+
if (finalMissingFiles.length > 0) {
|
|
2603
|
+
await logger.warn("Phase 3: Max iterations reached or LLM struggled, using fallback", {
|
|
2604
|
+
totalIterations: phase3Iteration,
|
|
2605
|
+
remainingFiles: finalMissingFiles.length
|
|
2606
|
+
});
|
|
2607
|
+
const firstFile = finalMissingFiles[0];
|
|
2608
|
+
const fileName = firstFile ? firstFile.split("/").pop() ?? firstFile : "files";
|
|
2609
|
+
const commitMessage = finalMissingFiles.length === 1 ? `update ${fileName}` : `update ${finalMissingFiles.length} remaining files`;
|
|
2610
|
+
const fallbackCommit = {
|
|
2611
|
+
id: `c${nonEmptyCommits.length + 1}`,
|
|
2612
|
+
type: "chore",
|
|
2613
|
+
message: commitMessage,
|
|
2614
|
+
files: finalMissingFiles,
|
|
2615
|
+
releaseHint: "none",
|
|
2616
|
+
breaking: false,
|
|
2617
|
+
reasoning: {
|
|
2618
|
+
newBehavior: false,
|
|
2619
|
+
fixesBug: false,
|
|
2620
|
+
internalOnly: true,
|
|
2621
|
+
explanation: `Files not classified after ${phase3Iteration} Phase 3 iteration(s): ${finalMissingFiles.slice(0, 3).join(", ")}${finalMissingFiles.length > 3 ? ` and ${finalMissingFiles.length - 3} more` : ""}`,
|
|
2622
|
+
confidence: 0.3
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
2625
|
+
nonEmptyCommits.push(fallbackCommit);
|
|
2626
|
+
}
|
|
2627
|
+
return nonEmptyCommits;
|
|
2628
|
+
}
|
|
2629
|
+
async function retryLLMCall(llmFn, phase, logger, onProgress) {
|
|
2630
|
+
let lastError;
|
|
2631
|
+
for (let attempt = 1; attempt <= MAX_LLM_RETRIES; attempt++) {
|
|
2632
|
+
try {
|
|
2633
|
+
const result = await llmFn();
|
|
2634
|
+
parseResponse(result.content);
|
|
2635
|
+
if (attempt > 1) {
|
|
2636
|
+
await logger.debug(`${phase} succeeded on attempt ${attempt}/${MAX_LLM_RETRIES}`);
|
|
2637
|
+
}
|
|
2638
|
+
return result;
|
|
2639
|
+
} catch (error) {
|
|
2640
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
2641
|
+
await logger.error(`\u{1F50D} LLM call failed (attempt ${attempt})`, lastError, {
|
|
2642
|
+
errorName: lastError.name,
|
|
2643
|
+
errorMessage: lastError.message,
|
|
2644
|
+
errorStack: lastError.stack
|
|
2645
|
+
});
|
|
2646
|
+
const errorType = getErrorType(lastError);
|
|
2647
|
+
if (attempt < MAX_LLM_RETRIES) {
|
|
2648
|
+
await logger.warn(`${phase} failed (attempt ${attempt}/${MAX_LLM_RETRIES}): ${errorType}`, {
|
|
2649
|
+
errorMessage: lastError.message,
|
|
2650
|
+
errorType,
|
|
2651
|
+
attempt,
|
|
2652
|
+
stack: lastError.stack?.split("\n").slice(0, 3).join("\n")
|
|
2653
|
+
// First 3 lines only
|
|
2654
|
+
});
|
|
2655
|
+
onProgress?.(`${phase} ${errorType}, retrying (${attempt}/${MAX_LLM_RETRIES})...`);
|
|
2656
|
+
await new Promise((resolve) => {
|
|
2657
|
+
setTimeout(resolve, 1e3 * Math.pow(2, attempt - 1));
|
|
2658
|
+
});
|
|
2659
|
+
} else {
|
|
2660
|
+
await logger.error(`${phase} failed after ${MAX_LLM_RETRIES} attempts: ${errorType}`, lastError, {
|
|
2661
|
+
errorType
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
throw lastError || new Error(`${phase} failed after ${MAX_LLM_RETRIES} attempts`);
|
|
2667
|
+
}
|
|
2668
|
+
function getErrorType(error) {
|
|
2669
|
+
const message = error.message.toLowerCase();
|
|
2670
|
+
if (message.includes("429") || message.includes("rate limit") || message.includes("too many requests")) {
|
|
2671
|
+
return "rate limited (429)";
|
|
2672
|
+
}
|
|
2673
|
+
if (message.includes("500") || message.includes("502") || message.includes("503")) {
|
|
2674
|
+
return "server error (5xx)";
|
|
2675
|
+
}
|
|
2676
|
+
if (message.includes("timeout") || message.includes("timed out")) {
|
|
2677
|
+
return "timeout";
|
|
2678
|
+
}
|
|
2679
|
+
if (message.includes("network") || message.includes("econnrefused") || message.includes("enotfound")) {
|
|
2680
|
+
return "network error";
|
|
2681
|
+
}
|
|
2682
|
+
if (message.includes("json") || message.includes("parse") || message.includes("unexpected token")) {
|
|
2683
|
+
return "invalid JSON";
|
|
2684
|
+
}
|
|
2685
|
+
if (message.includes("missing") || message.includes("missing required field")) {
|
|
2686
|
+
return "invalid structure";
|
|
2687
|
+
}
|
|
2688
|
+
const preview = error.message.substring(0, 50).replace(/\n/g, " ");
|
|
2689
|
+
return `error: ${preview}${error.message.length > 50 ? "..." : ""}`;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
export { COMMIT_PLAN_TOOL, COMMIT_PLAN_TOOL_PHASE3, SYSTEM_PROMPT, SYSTEM_PROMPT_WITH_DIFF, buildPrompt, buildPromptWithDiff, generateCommitPlan, generateHeuristicPlan, parseResponse };
|
|
2693
|
+
//# sourceMappingURL=index.js.map
|
|
2694
|
+
//# sourceMappingURL=index.js.map
|