devsplain 2.2.3 → 2.3.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/bin/cli.js +73 -54
- package/bin/post-commit.js +0 -17
- package/bin/setup-hook.js +109 -21
- package/lib/config.js +6 -10
- package/lib/llm.js +473 -281
- package/package.json +1 -1
package/lib/llm.js
CHANGED
|
@@ -1,281 +1,473 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
[
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
1
|
+
/** [ds]
|
|
2
|
+
* Sends an HTTP request with automatic retry logic and timeout management.
|
|
3
|
+
* @param {string} url - The target URL.
|
|
4
|
+
* @param {object} options - Fetch options including method, headers, and body.
|
|
5
|
+
* @param {number} maxRetries - Maximum number of retry attempts.
|
|
6
|
+
* @param {number} initialDelay - Initial delay in milliseconds for exponential backoff.
|
|
7
|
+
* @returns {Promise<Response>} - Returns the successful Response object.
|
|
8
|
+
* @throws {Error} Throws a RateLimitError (with .isRateLimit=true) on 429, or the last error after exhausting retries.
|
|
9
|
+
*/
|
|
10
|
+
async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000) {
|
|
11
|
+
let lastError;
|
|
12
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(url, {
|
|
17
|
+
...options,
|
|
18
|
+
signal: controller.signal
|
|
19
|
+
});
|
|
20
|
+
clearTimeout(timeoutId);
|
|
21
|
+
if (!response) {
|
|
22
|
+
throw new Error("No response received from fetch");
|
|
23
|
+
}
|
|
24
|
+
if (response.ok) {
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
27
|
+
// Surface 429 rate limits immediately so the adaptive controller can react [ds]
|
|
28
|
+
if (response.status === 429) {
|
|
29
|
+
const err = new Error(`HTTP Error 429: ${response.statusText || 'Too Many Requests'}`);
|
|
30
|
+
err.isRateLimit = true;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (response.status >= 500 && response.status < 600) {
|
|
34
|
+
lastError = new Error(`HTTP Error ${response.status}: ${response.statusText}`);
|
|
35
|
+
} else {
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
// Propagate rate limit errors immediately without retrying [ds]
|
|
41
|
+
if (err.isRateLimit) {
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
if (err.name === 'AbortError') {
|
|
45
|
+
lastError = new Error("Request timed out after 45 seconds");
|
|
46
|
+
} else {
|
|
47
|
+
lastError = err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (attempt < maxRetries - 1) {
|
|
52
|
+
const backoffDelay = initialDelay * Math.pow(2, attempt);
|
|
53
|
+
console.warn(`[devsplain] AI request failed. Retrying in ${backoffDelay}ms... (Attempt ${attempt + 1}/${maxRetries})`);
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw lastError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Chunking Constants ───────────────────────────────────────────────────────
|
|
61
|
+
const CHUNK_SIZE = 200;
|
|
62
|
+
const CHUNK_OVERLAP = 20;
|
|
63
|
+
const CHUNK_THRESHOLD = 250;
|
|
64
|
+
|
|
65
|
+
// ─── Adaptive Concurrency Controller ──────────────────────────────────────────
|
|
66
|
+
// Shared mutable state: starts at the requested concurrency and drops to 1 on 429 [ds]
|
|
67
|
+
let _concurrencyLimit = 2;
|
|
68
|
+
let _hitRateLimit = false;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset the adaptive concurrency controller for a new run.
|
|
72
|
+
* @param {number} initialLimit - Starting concurrency level.
|
|
73
|
+
*/
|
|
74
|
+
function resetConcurrency(initialLimit = 2) {
|
|
75
|
+
_concurrencyLimit = initialLimit;
|
|
76
|
+
_hitRateLimit = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Zero-dependency promise pool that respects the adaptive concurrency limit.
|
|
81
|
+
* If a task throws a 429 RateLimitError, concurrency is reduced to 1 for
|
|
82
|
+
* the remainder of the run, and the failed task is retried after a backoff.
|
|
83
|
+
* @param {Array} items - Items to process.
|
|
84
|
+
* @param {Function} taskFn - Async function to run per item.
|
|
85
|
+
* @returns {Promise<Array>} - Resolved results in order.
|
|
86
|
+
*/
|
|
87
|
+
async function runWithConcurrency(items, taskFn) {
|
|
88
|
+
const results = [];
|
|
89
|
+
const executing = new Set();
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < items.length; i++) {
|
|
92
|
+
const item = items[i];
|
|
93
|
+
const task = (async () => {
|
|
94
|
+
try {
|
|
95
|
+
return await taskFn(item);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// On 429, downshift to serial and retry the item after backoff [ds]
|
|
98
|
+
if (err.isRateLimit) {
|
|
99
|
+
if (!_hitRateLimit) {
|
|
100
|
+
_hitRateLimit = true;
|
|
101
|
+
_concurrencyLimit = 1;
|
|
102
|
+
console.warn(`[devsplain] Rate limit hit — switching to serial mode.`);
|
|
103
|
+
}
|
|
104
|
+
// Wait for all in-flight tasks to settle before retrying [ds]
|
|
105
|
+
await Promise.allSettled([...executing]);
|
|
106
|
+
const backoff = 2000 + Math.random() * 1000;
|
|
107
|
+
console.warn(`[devsplain] Backing off for ${Math.round(backoff)}ms...`);
|
|
108
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
109
|
+
return await taskFn(item);
|
|
110
|
+
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
const tracked = task.then(
|
|
116
|
+
val => { executing.delete(tracked); return val; },
|
|
117
|
+
err => { executing.delete(tracked); throw err; }
|
|
118
|
+
);
|
|
119
|
+
executing.add(tracked);
|
|
120
|
+
results.push(tracked);
|
|
121
|
+
|
|
122
|
+
if (executing.size >= _concurrencyLimit) {
|
|
123
|
+
await Promise.race(executing);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return Promise.all(results);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Prompt Builder ───────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the LLM prompt for a block of numbered code lines.
|
|
133
|
+
* @param {string} numberedCode - Code with line numbers prepended.
|
|
134
|
+
* @param {string} language - Filename or language identifier.
|
|
135
|
+
* @param {string} mode - Documentation mode ('default', 'light', 'full').
|
|
136
|
+
* @returns {string} The assembled prompt string.
|
|
137
|
+
*/
|
|
138
|
+
function buildPrompt(numberedCode, language, mode) {
|
|
139
|
+
const extMatch = language.match(/\.[0-9a-z]+$/i);
|
|
140
|
+
const ext = extMatch ? extMatch[0].toLowerCase() : '';
|
|
141
|
+
const isPython = ext === '.py';
|
|
142
|
+
const isRubyOrShell = ['.rb', '.sh'].includes(ext);
|
|
143
|
+
const isHTML = ['.html', '.vue', '.svelte'].includes(ext);
|
|
144
|
+
const isCss = ['.css', '.scss'].includes(ext);
|
|
145
|
+
const isSql = ext === '.sql';
|
|
146
|
+
let singleLineToken = '//';
|
|
147
|
+
let blockExample = '/** Calculates the total price */';
|
|
148
|
+
let inlineExample = '// Check for null values';
|
|
149
|
+
|
|
150
|
+
if (isPython || isRubyOrShell) {
|
|
151
|
+
singleLineToken = '#';
|
|
152
|
+
blockExample = '# Calculates the total price';
|
|
153
|
+
inlineExample = '# Check for null values';
|
|
154
|
+
} else if (isHTML) {
|
|
155
|
+
singleLineToken = '<!--';
|
|
156
|
+
blockExample = '<!-- Calculates the total price -->';
|
|
157
|
+
inlineExample = '<!-- Check for null values -->';
|
|
158
|
+
} else if (isCss) {
|
|
159
|
+
singleLineToken = '/*';
|
|
160
|
+
blockExample = '/* Calculates the total price */';
|
|
161
|
+
inlineExample = '/* Check for null values */';
|
|
162
|
+
} else if (isSql) {
|
|
163
|
+
singleLineToken = '--';
|
|
164
|
+
blockExample = '-- Calculates the total price';
|
|
165
|
+
inlineExample = '-- Check for null values';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let instruction = `Provide block comments above functions and sparse inline comments for complex logic.`;
|
|
169
|
+
if (mode === 'light') {
|
|
170
|
+
instruction = `Provide ONLY block comments above functions. Keep it minimal.`;
|
|
171
|
+
} else if (mode === 'full') {
|
|
172
|
+
instruction = `Provide highly detailed block comments above functions, and exhaustive step-by-step inline comments explaining every conditional branch, loop, variable assignment, and logical block inside function bodies. Do not be sparse; explain the code's execution flow in detail.`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let rule5 = `5. IMPORTANT: Use ONLY ${singleLineToken} for comments. DO NOT use docstrings or multi-line string literals like """ or ''' for comments.`;
|
|
176
|
+
if (isCss) {
|
|
177
|
+
rule5 = `5. IMPORTANT: In CSS/SCSS, you MUST use /* ... */ for comments. DO NOT use // comments under any circumstances.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Anti-triviality negative constraints to eliminate syntax-narrating clutter [ds]
|
|
181
|
+
const antiTrivialityRules = `
|
|
182
|
+
ANTI-TRIVIALITY RULES (STRICTLY ENFORCED):
|
|
183
|
+
6. NEVER write comments that merely narrate the syntax (e.g. NEVER write "// Loop over items" above a for loop, "// Return result" above a return, "// Increment i" above i++, or "// Define variable" above a declaration).
|
|
184
|
+
7. NEVER comment standard variable initializations, obvious assignments, or self-describing code.
|
|
185
|
+
8. ONLY write comments where:
|
|
186
|
+
- The WHY or architectural intent is non-obvious.
|
|
187
|
+
- An edge case, security workaround, or regex heuristic is being handled.
|
|
188
|
+
- A tricky formula, index manipulation (e.g. 0-indexed vs 1-indexed), or protocol-specific behavior occurs.
|
|
189
|
+
9. Prefer comprehensive function-level block comments over cluttered inline comments. Quality over quantity.`;
|
|
190
|
+
|
|
191
|
+
const prompt = `
|
|
192
|
+
You are a code documentation engine. Analyze the following ${language} code which has line numbers prepended to it.
|
|
193
|
+
${instruction}
|
|
194
|
+
|
|
195
|
+
CRITICAL RULES:
|
|
196
|
+
1. You MUST respond with ONLY a raw, valid JSON array of objects. NO markdown formatting, NO backticks, NO explanations, NO text before or after the JSON.
|
|
197
|
+
2. Each object must have exactly two properties: "line" (the integer line number where the comment should be inserted ABOVE) and "comment" (the text of the comment itself).
|
|
198
|
+
3. Do NOT include the original code in your response.
|
|
199
|
+
4. If no comments are needed, return an empty array: [].
|
|
200
|
+
${rule5}
|
|
201
|
+
${antiTrivialityRules}
|
|
202
|
+
|
|
203
|
+
Example Output:
|
|
204
|
+
[
|
|
205
|
+
{ "line": 4, "comment": "${blockExample}" },
|
|
206
|
+
{ "line": 12, "comment": "${inlineExample}" }
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
Here is the source code:
|
|
210
|
+
${numberedCode}
|
|
211
|
+
`.trim();
|
|
212
|
+
|
|
213
|
+
return prompt;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Single-Chunk Comment Fetcher ─────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fetch comments for a single chunk of code from the configured AI provider.
|
|
220
|
+
* @param {string} prompt - The assembled prompt.
|
|
221
|
+
* @param {object} config - Provider config (provider, model, apiKey, baseUrl).
|
|
222
|
+
* @returns {Promise<string>} Raw text response from the AI.
|
|
223
|
+
*/
|
|
224
|
+
async function fetchFromProvider(prompt, config) {
|
|
225
|
+
let textResponse = "";
|
|
226
|
+
|
|
227
|
+
if (config.provider === 'gemini') {
|
|
228
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
|
|
229
|
+
let data;
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetchWithRetry(url, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: {
|
|
234
|
+
'Content-Type': 'application/json'
|
|
235
|
+
},
|
|
236
|
+
body: JSON.stringify({
|
|
237
|
+
"contents": [{ "parts": [{ "text": prompt }] }]
|
|
238
|
+
})
|
|
239
|
+
});
|
|
240
|
+
data = await response.json();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Re-throw rate limit errors so the concurrency controller can catch them [ds]
|
|
243
|
+
if (error.isRateLimit) throw error;
|
|
244
|
+
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
245
|
+
}
|
|
246
|
+
if (data.error) {
|
|
247
|
+
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
248
|
+
throw new Error(`API Error: ${msg}`);
|
|
249
|
+
}
|
|
250
|
+
if (!data.candidates || !data.candidates[0] || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts[0]) {
|
|
251
|
+
const reason = data.candidates?.[0]?.finishReason || 'Unknown error';
|
|
252
|
+
throw new Error(`AI Provider returned no content (finish reason: ${reason})`);
|
|
253
|
+
}
|
|
254
|
+
textResponse = data.candidates[0].content.parts[0].text;
|
|
255
|
+
} else if (config.provider === 'claude') {
|
|
256
|
+
const url = `${config.baseUrl}/v1/messages`;
|
|
257
|
+
let data;
|
|
258
|
+
try {
|
|
259
|
+
const response = await fetchWithRetry(url, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: {
|
|
262
|
+
'Content-Type': 'application/json',
|
|
263
|
+
'x-api-key': config.apiKey,
|
|
264
|
+
'anthropic-version': '2023-06-01'
|
|
265
|
+
},
|
|
266
|
+
body: JSON.stringify({
|
|
267
|
+
"model": config.model,
|
|
268
|
+
"max_tokens": 8192,
|
|
269
|
+
"messages": [{
|
|
270
|
+
"role": "user",
|
|
271
|
+
"content": prompt
|
|
272
|
+
}]
|
|
273
|
+
})
|
|
274
|
+
});
|
|
275
|
+
data = await response.json();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error.isRateLimit) throw error;
|
|
278
|
+
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
279
|
+
}
|
|
280
|
+
if (data.error) {
|
|
281
|
+
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
282
|
+
throw new Error(`API Error: ${msg}`);
|
|
283
|
+
}
|
|
284
|
+
if (!data.content || !data.content[0] || typeof data.content[0].text !== 'string') {
|
|
285
|
+
throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
|
|
286
|
+
}
|
|
287
|
+
textResponse = data.content[0].text;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const url = `${config.baseUrl}/v1/chat/completions`;
|
|
291
|
+
let data;
|
|
292
|
+
|
|
293
|
+
const reqBody = {
|
|
294
|
+
"model": config.model,
|
|
295
|
+
"messages": [{
|
|
296
|
+
"role": "user",
|
|
297
|
+
"content": prompt
|
|
298
|
+
}]
|
|
299
|
+
};
|
|
300
|
+
if (config.provider === 'groq') {
|
|
301
|
+
reqBody.max_tokens = 1000;
|
|
302
|
+
} else {
|
|
303
|
+
reqBody.max_tokens = 8192;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const response = await fetchWithRetry(url, {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers: {
|
|
310
|
+
'Content-Type': 'application/json',
|
|
311
|
+
'Authorization': `Bearer ${config.apiKey}`
|
|
312
|
+
},
|
|
313
|
+
body: JSON.stringify(reqBody)
|
|
314
|
+
});
|
|
315
|
+
data = await response.json();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.isRateLimit) throw error;
|
|
318
|
+
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
319
|
+
}
|
|
320
|
+
if (data.error) {
|
|
321
|
+
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
322
|
+
throw new Error(`API Error: ${msg}`);
|
|
323
|
+
}
|
|
324
|
+
if (!data.choices || !data.choices[0] || !data.choices[0].message || typeof data.choices[0].message.content !== 'string') {
|
|
325
|
+
throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
|
|
326
|
+
}
|
|
327
|
+
textResponse = data.choices[0].message.content;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return textResponse;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Response Parser & Validator ──────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Parse, validate, and sanitize the raw text response from the AI provider.
|
|
337
|
+
* @param {string} textResponse - Raw text from the AI.
|
|
338
|
+
* @param {string} mode - Documentation mode.
|
|
339
|
+
* @returns {Array} Validated array of comment objects.
|
|
340
|
+
*/
|
|
341
|
+
function parseAndValidate(textResponse, mode) {
|
|
342
|
+
let cleanText = textResponse.trim();
|
|
343
|
+
const start = cleanText.indexOf('[');
|
|
344
|
+
const end = cleanText.lastIndexOf(']');
|
|
345
|
+
if (start !== -1) {
|
|
346
|
+
if (end !== -1 && end >= start) {
|
|
347
|
+
cleanText = cleanText.substring(start, end + 1);
|
|
348
|
+
} else {
|
|
349
|
+
const lastBrace = cleanText.lastIndexOf('}');
|
|
350
|
+
if (lastBrace > start) {
|
|
351
|
+
cleanText = cleanText.substring(start, lastBrace + 1) + ']';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let parsed;
|
|
357
|
+
try {
|
|
358
|
+
parsed = JSON.parse(cleanText);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
throw new Error(`Parsing Error: Failed to parse LLM response as JSON. Raw response was:\n${textResponse}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!Array.isArray(parsed)) {
|
|
364
|
+
throw new Error("Schema Error: LLM response is not a JSON array.");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const item of parsed) {
|
|
368
|
+
if (typeof item !== 'object' || item === null) {
|
|
369
|
+
throw new Error("Schema Error: Array elements must be objects.");
|
|
370
|
+
}
|
|
371
|
+
if (!Number.isInteger(item.line) || item.line <= 0) {
|
|
372
|
+
throw new Error("Schema Error: 'line' must be a positive integer.");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (mode === 'clean') {
|
|
376
|
+
if (item.action !== 'delete') {
|
|
377
|
+
throw new Error("Schema Error: 'action' must be 'delete' in clean mode.");
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
if (typeof item.comment !== 'string') {
|
|
381
|
+
throw new Error("Schema Error: 'comment' must be a string.");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const trimmedComment = item.comment.trim();
|
|
385
|
+
const commentLines = trimmedComment.split(/\r?\n/);
|
|
386
|
+
let inBlock = false;
|
|
387
|
+
for (const cl of commentLines) {
|
|
388
|
+
const tcl = cl.trim();
|
|
389
|
+
if (!tcl) continue;
|
|
390
|
+
if (inBlock) {
|
|
391
|
+
if (tcl.includes('*/') || tcl.includes('-->')) {
|
|
392
|
+
inBlock = false;
|
|
393
|
+
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const startsWithMarker =
|
|
397
|
+
tcl.startsWith('//') ||
|
|
398
|
+
tcl.startsWith('/*') ||
|
|
399
|
+
tcl.startsWith('*') ||
|
|
400
|
+
tcl.startsWith('#') ||
|
|
401
|
+
tcl.startsWith('<!--') ||
|
|
402
|
+
tcl.startsWith('--');
|
|
403
|
+
if (!startsWithMarker) {
|
|
404
|
+
throw new Error(`Security Error: Comment on line ${item.line} contains invalid non-comment line: "${tcl}"`);
|
|
405
|
+
}
|
|
406
|
+
if ((tcl.startsWith('/*') && !tcl.includes('*/')) || (tcl.startsWith('<!--') && !tcl.includes('-->'))) {
|
|
407
|
+
inBlock = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return parsed;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ─── Core Public API ──────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Fetch and validate AI-generated comments for a source file.
|
|
420
|
+
* Automatically chunks files exceeding CHUNK_THRESHOLD lines into
|
|
421
|
+
* overlapping windows, processes them with adaptive concurrency,
|
|
422
|
+
* and deduplicates comments across chunk boundaries.
|
|
423
|
+
* @param {string} code - Full source code of the file.
|
|
424
|
+
* @param {string} language - Filename or language identifier.
|
|
425
|
+
* @param {object} config - Provider configuration.
|
|
426
|
+
* @param {string} mode - Documentation mode ('default', 'light', 'full', 'clean').
|
|
427
|
+
* @returns {Promise<Array>} Array of validated comment objects with global line numbers.
|
|
428
|
+
*/
|
|
429
|
+
async function getComments(code, language, config, mode = 'default') {
|
|
430
|
+
const lines = code.split(/\r?\n/);
|
|
431
|
+
|
|
432
|
+
// Small files: single-shot processing (no chunking overhead) [ds]
|
|
433
|
+
if (lines.length <= CHUNK_THRESHOLD) {
|
|
434
|
+
const numberedCode = lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
|
|
435
|
+
const prompt = buildPrompt(numberedCode, language, mode);
|
|
436
|
+
const textResponse = await fetchFromProvider(prompt, config);
|
|
437
|
+
return parseAndValidate(textResponse, mode);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Large files: slice into overlapping chunks with global line numbers [ds]
|
|
441
|
+
const chunks = [];
|
|
442
|
+
for (let start = 0; start < lines.length; start += (CHUNK_SIZE - CHUNK_OVERLAP)) {
|
|
443
|
+
const end = Math.min(start + CHUNK_SIZE, lines.length);
|
|
444
|
+
chunks.push({ start, end });
|
|
445
|
+
if (end >= lines.length) break;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Process chunks through the adaptive concurrency pool [ds]
|
|
449
|
+
const chunkResults = await runWithConcurrency(chunks, async (chunk) => {
|
|
450
|
+
const chunkLines = lines.slice(chunk.start, chunk.end);
|
|
451
|
+
const startLineNum = chunk.start + 1;
|
|
452
|
+
const numberedCode = chunkLines.map((line, i) => `${startLineNum + i}: ${line}`).join('\n');
|
|
453
|
+
const prompt = buildPrompt(numberedCode, language, mode);
|
|
454
|
+
const textResponse = await fetchFromProvider(prompt, config);
|
|
455
|
+
return parseAndValidate(textResponse, mode);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Merge and deduplicate: first comment wins for overlapping line numbers [ds]
|
|
459
|
+
const seenLines = new Set();
|
|
460
|
+
const allComments = [];
|
|
461
|
+
for (const chunkComments of chunkResults) {
|
|
462
|
+
for (const c of chunkComments) {
|
|
463
|
+
if (!seenLines.has(c.line)) {
|
|
464
|
+
seenLines.add(c.line);
|
|
465
|
+
allComments.push(c);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return allComments;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
module.exports = { getComments, runWithConcurrency, resetConcurrency, CHUNK_SIZE, CHUNK_OVERLAP, CHUNK_THRESHOLD };
|