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.
@@ -11,7 +11,15 @@ const ANSI = {
11
11
  };
12
12
 
13
13
  function supportsColor() {
14
- return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
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 highlightLine(line) {
30
- if (/^([0-9]+\.)?\s*(Summary|Issue|Root Cause|Fix|Impact|Risk Level|Technical Breakdown|Security Findings|Suggestions|Review Findings):/i.test(line)) {
31
- return colorize(line, ANSI.bold + ANSI.cyan);
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 (/risk/i.test(line) && /\blow\b/i.test(line)) {
182
+ if (/\blow\b/i.test(line)) {
35
183
  return colorize(line, ANSI.green);
36
184
  }
37
185
 
38
- if (/risk/i.test(line) && /\bmedium\b/i.test(line)) {
186
+ if (/\bmedium\b/i.test(line)) {
39
187
  return colorize(line, ANSI.yellow);
40
188
  }
41
189
 
42
- if (/risk/i.test(line) && /\bhigh\b/i.test(line)) {
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
- return explanation
230
+ const state = { inCodeBlock: false };
231
+ const lines = explanation
232
+ .replaceAll("\r\n", "\n")
51
233
  .split("\n")
52
- .map((line) => highlightLine(line))
53
- .join("\n");
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 {