gitxplain 0.1.0 → 0.1.3
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/.env.example +5 -10
- package/IMPLEMENTATION.md +225 -0
- package/README.md +154 -0
- package/cli/index.js +446 -19
- package/cli/services/aiService.js +2 -2
- package/cli/services/chatService.js +663 -0
- package/cli/services/commitService.js +379 -0
- package/cli/services/envLoader.js +33 -0
- package/cli/services/gitConnectionService.js +267 -0
- package/cli/services/gitService.js +590 -0
- package/cli/services/mergeService.js +609 -0
- package/cli/services/outputFormatter.js +217 -11
- package/cli/services/promptService.js +66 -2
- package/cli/services/splitService.js +472 -0
- package/package.json +4 -3
- package/prompts/commit.txt +57 -0
- package/prompts/split.txt +44 -0
|
@@ -11,7 +11,15 @@ const ANSI = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
function supportsColor() {
|
|
14
|
-
|
|
14
|
+
if (process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== "0") {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (process.env.NO_COLOR != null) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Boolean(process.stdout?.isTTY);
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
function colorize(text, color) {
|
|
@@ -22,35 +30,233 @@ function colorize(text, color) {
|
|
|
22
30
|
return `${color}${text}${ANSI.reset}`;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
function stripInlineMarkdown(text) {
|
|
34
|
+
return text
|
|
35
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
36
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
37
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
38
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
39
|
+
.replace(/_([^_]+)_/g, "$1")
|
|
40
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
|
|
41
|
+
.trimEnd();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeMarkdownLine(line, state) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
|
|
47
|
+
if (/^```/.test(trimmed)) {
|
|
48
|
+
state.inCodeBlock = !state.inCodeBlock;
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (state.inCodeBlock) {
|
|
53
|
+
return ` ${line.replace(/^\s*/, "")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (/^---+$/.test(trimmed) || /^\*\*\*+$/.test(trimmed)) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let normalizedHeading = trimmed
|
|
61
|
+
.replace(/^#{1,6}\s+/, "")
|
|
62
|
+
.replace(/^([0-9]+\.)\s+/, "");
|
|
63
|
+
normalizedHeading = stripInlineMarkdown(normalizedHeading).replace(/:\s*$/, "").trim();
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
/^(summary|issues? fixed|issue|root cause|fix(?: explanation)?|impact|risk level|severity|technical breakdown|full analysis|line-by-line code walkthrough|code review|security review|security findings|review findings|suggestions|recommended mitigations)$/i.test(
|
|
67
|
+
normalizedHeading
|
|
68
|
+
)
|
|
69
|
+
) {
|
|
70
|
+
return `${normalizedHeading}:`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (/^>\s*/.test(trimmed)) {
|
|
74
|
+
return stripInlineMarkdown(trimmed.replace(/^>\s*/, ""));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const bulletMatch = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
78
|
+
if (bulletMatch) {
|
|
79
|
+
const [, indent, marker, content] = bulletMatch;
|
|
80
|
+
return `${indent}${marker} ${stripInlineMarkdown(content)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return stripInlineMarkdown(line);
|
|
84
|
+
}
|
|
85
|
+
|
|
25
86
|
function formatTargetLabel(commitData) {
|
|
26
87
|
return commitData.analysisType === "range" ? "Range" : "Commit";
|
|
27
88
|
}
|
|
28
89
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
90
|
+
function normalizeHeading(line) {
|
|
91
|
+
const match = line.match(/^([0-9]+\.)?\s*(Summary|Issues? Fixed|Issue|Root Cause|Fix(?: Explanation)?|Impact|Risk Level|Severity|Technical Breakdown|Full Analysis|Line-by-Line Code Walkthrough|Code Review|Security Review|Security Findings|Review Findings|Suggestions|Recommended Mitigations)\s*:?\s*$/i);
|
|
92
|
+
|
|
93
|
+
if (!match) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return `${match[2]}:`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isFileHeading(line) {
|
|
101
|
+
return /^(?:File|Path)\s*:/i.test(line) || /^[A-Za-z0-9_./-]+\.[A-Za-z0-9]+:\s*$/.test(line);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function classifyTone(line) {
|
|
105
|
+
if (/^\s*low(?:\b|[.:])/i.test(line)) {
|
|
106
|
+
return "good";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (/^\s*medium(?:\b|[.:])/i.test(line)) {
|
|
110
|
+
return "neutral";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (/^\s*high(?:\b|[.:])/i.test(line)) {
|
|
114
|
+
return "bad";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
/\b(no significant findings|no security findings|none apparent|looks good|safe|improved|improvement|fixed|resolved|successful|passes?|low risk|low severity)\b/i.test(
|
|
119
|
+
line
|
|
120
|
+
)
|
|
121
|
+
) {
|
|
122
|
+
return "good";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
/\b(issue|issues|bug|broken|failure|failing|risk|risky|severity|vulnerability|insecure|regression|warning|problem|bad|missing|error|high risk|high severity)\b/i.test(
|
|
127
|
+
line
|
|
128
|
+
)
|
|
129
|
+
) {
|
|
130
|
+
return "bad";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (/\b(suggestion|consider|follow-up|todo|medium risk|medium severity)\b/i.test(line)) {
|
|
134
|
+
return "neutral";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function colorizeByTone(line, tone) {
|
|
141
|
+
if (tone === "good") {
|
|
142
|
+
return colorize(line, ANSI.green);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (tone === "bad") {
|
|
146
|
+
return colorize(line, ANSI.red);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (tone === "neutral") {
|
|
150
|
+
return colorize(line, ANSI.yellow);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return line;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatBulletLine(line) {
|
|
157
|
+
const match = line.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
|
|
158
|
+
|
|
159
|
+
if (!match) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const [, indent, marker, content] = match;
|
|
164
|
+
const tone = classifyTone(content);
|
|
165
|
+
const coloredMarker =
|
|
166
|
+
tone === "good"
|
|
167
|
+
? colorize(marker, ANSI.green)
|
|
168
|
+
: tone === "bad"
|
|
169
|
+
? colorize(marker, ANSI.red)
|
|
170
|
+
: tone === "neutral"
|
|
171
|
+
? colorize(marker, ANSI.yellow)
|
|
172
|
+
: colorize(marker, ANSI.cyan);
|
|
173
|
+
|
|
174
|
+
return `${indent}${coloredMarker} ${colorizeByTone(content, tone)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatSeverityLine(line) {
|
|
178
|
+
if (/\brisk level\b|\bseverity\b/i.test(line) === false) {
|
|
179
|
+
return null;
|
|
32
180
|
}
|
|
33
181
|
|
|
34
|
-
if (
|
|
182
|
+
if (/\blow\b/i.test(line)) {
|
|
35
183
|
return colorize(line, ANSI.green);
|
|
36
184
|
}
|
|
37
185
|
|
|
38
|
-
if (
|
|
186
|
+
if (/\bmedium\b/i.test(line)) {
|
|
39
187
|
return colorize(line, ANSI.yellow);
|
|
40
188
|
}
|
|
41
189
|
|
|
42
|
-
if (
|
|
190
|
+
if (/\bhigh\b/i.test(line)) {
|
|
43
191
|
return colorize(line, ANSI.red);
|
|
44
192
|
}
|
|
45
193
|
|
|
46
|
-
return line;
|
|
194
|
+
return colorize(line, ANSI.bold + ANSI.yellow);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatLine(line) {
|
|
198
|
+
const trimmed = line.trim();
|
|
199
|
+
|
|
200
|
+
if (trimmed === "") {
|
|
201
|
+
return "";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const normalizedHeading = normalizeHeading(trimmed);
|
|
205
|
+
|
|
206
|
+
if (normalizedHeading) {
|
|
207
|
+
return colorize(normalizedHeading, ANSI.bold + ANSI.cyan);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (isFileHeading(trimmed)) {
|
|
211
|
+
return colorize(trimmed, ANSI.bold + ANSI.cyan);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const bulletLine = formatBulletLine(line);
|
|
215
|
+
|
|
216
|
+
if (bulletLine) {
|
|
217
|
+
return bulletLine;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const severityLine = formatSeverityLine(trimmed);
|
|
221
|
+
|
|
222
|
+
if (severityLine) {
|
|
223
|
+
return severityLine;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return colorizeByTone(line, classifyTone(line));
|
|
47
227
|
}
|
|
48
228
|
|
|
49
229
|
function formatExplanation(explanation) {
|
|
50
|
-
|
|
230
|
+
const state = { inCodeBlock: false };
|
|
231
|
+
const lines = explanation
|
|
232
|
+
.replaceAll("\r\n", "\n")
|
|
51
233
|
.split("\n")
|
|
52
|
-
.map((line) =>
|
|
53
|
-
|
|
234
|
+
.map((line) => normalizeMarkdownLine(line, state));
|
|
235
|
+
const formatted = [];
|
|
236
|
+
let previousWasBlank = false;
|
|
237
|
+
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
const trimmed = line.trim();
|
|
240
|
+
const formattedLine = formatLine(line);
|
|
241
|
+
const isHeading = normalizeHeading(trimmed) != null || isFileHeading(trimmed);
|
|
242
|
+
|
|
243
|
+
if (trimmed === "") {
|
|
244
|
+
if (!previousWasBlank && formatted.length > 0) {
|
|
245
|
+
formatted.push("");
|
|
246
|
+
}
|
|
247
|
+
previousWasBlank = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (isHeading && formatted.length > 0 && !previousWasBlank) {
|
|
252
|
+
formatted.push("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
formatted.push(formattedLine);
|
|
256
|
+
previousWasBlank = false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return formatted.join("\n").trimEnd();
|
|
54
260
|
}
|
|
55
261
|
|
|
56
262
|
export function formatPreamble({ mode, commitData, options, promptMeta }) {
|
|
@@ -14,7 +14,9 @@ const PROMPT_FILES = {
|
|
|
14
14
|
impact: "impact.txt",
|
|
15
15
|
lines: "lines.txt",
|
|
16
16
|
review: "review.txt",
|
|
17
|
-
security: "security.txt"
|
|
17
|
+
security: "security.txt",
|
|
18
|
+
split: "split.txt",
|
|
19
|
+
commit: "commit.txt"
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
function fillTemplate(template, values) {
|
|
@@ -60,15 +62,77 @@ function buildRangePrelude(commitData) {
|
|
|
60
62
|
].join("\n");
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
function isDiffMetadataLine(line) {
|
|
66
|
+
return (
|
|
67
|
+
line.startsWith("diff --git ") ||
|
|
68
|
+
line.startsWith("index ") ||
|
|
69
|
+
line.startsWith("--- ") ||
|
|
70
|
+
line.startsWith("+++ ") ||
|
|
71
|
+
line.startsWith("@@ ")
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stripCommentPrefix(line) {
|
|
76
|
+
return line.replace(/^(?:\/\/+|\/\*+|\*+\/?|\#+|<!--|-->|;+)/, "").trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isCommentLikeLine(line) {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
|
|
82
|
+
if (trimmed === "") {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (/^(?:\/\/+|\/\*+|\*+\/?|\#+|<!--|-->|;+)/.test(trimmed)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function classifyDiff(diff) {
|
|
94
|
+
const addedOrRemovedLines = diff
|
|
95
|
+
.split("\n")
|
|
96
|
+
.filter((line) => (line.startsWith("+") || line.startsWith("-")) && !isDiffMetadataLine(line))
|
|
97
|
+
.map((line) => line.slice(1));
|
|
98
|
+
|
|
99
|
+
if (addedOrRemovedLines.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
summary: "No content changes detected beyond diff metadata."
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nonCommentLines = addedOrRemovedLines.filter((line) => {
|
|
106
|
+
if (isCommentLikeLine(line)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return stripCommentPrefix(line) !== "";
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (nonCommentLines.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
summary:
|
|
116
|
+
"All changed lines appear to be comments or whitespace. Treat this as a non-behavioral documentation/annotation update unless the diff proves otherwise."
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
summary: "Changed lines include executable or data content. Do not assume the edit is comments-only."
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
63
125
|
export function buildPrompt(mode, commitData, options = {}) {
|
|
64
126
|
const filename = PROMPT_FILES[mode] ?? PROMPT_FILES.full;
|
|
65
127
|
const template = readFileSync(path.join(PROMPT_DIR, filename), "utf8");
|
|
66
128
|
const truncation = truncateDiff(commitData.diff, options.maxDiffLines ?? 800);
|
|
129
|
+
const diffClassification = classifyDiff(truncation.diff);
|
|
67
130
|
const prompt = fillTemplate(`${buildRangePrelude(commitData)}${template}`, {
|
|
68
131
|
commit_message: commitData.commitMessage,
|
|
69
132
|
files_changed: commitData.filesChanged.join("\n"),
|
|
70
133
|
stats: commitData.stats,
|
|
71
|
-
diff: truncation.diff
|
|
134
|
+
diff: truncation.diff,
|
|
135
|
+
change_hints: diffClassification.summary
|
|
72
136
|
});
|
|
73
137
|
|
|
74
138
|
return {
|