devsplain 2.2.4 → 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/lib/llm.js +473 -432
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const { getComments } = require('../lib/llm.js');
|
|
3
|
+
const { getComments, runWithConcurrency, resetConcurrency } = require('../lib/llm.js');
|
|
4
4
|
const { getConfig } = require('../lib/config.js');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
@@ -817,7 +817,7 @@ Options:
|
|
|
817
817
|
};
|
|
818
818
|
|
|
819
819
|
let filepath = '.';
|
|
820
|
-
const flagKeys = ['--provider', '--model', '--api-key', '--base-url'];
|
|
820
|
+
const flagKeys = ['--provider', '--model', '--api-key', '--base-url', '--concurrency'];
|
|
821
821
|
for (let i = 0; i < args.length; i++) {
|
|
822
822
|
const arg = args[i];
|
|
823
823
|
if (arg.startsWith('--')) {
|
|
@@ -877,6 +877,10 @@ Options:
|
|
|
877
877
|
|
|
878
878
|
const isOverwrite = (hasOverwriteFlag || config.autoPrune) && !hasKeepFlag;
|
|
879
879
|
|
|
880
|
+
// Parse --concurrency flag (default: 2, max: 5, min: 1) [ds]
|
|
881
|
+
const cliConcurrency = parseInt(getArgValue('--concurrency'), 10);
|
|
882
|
+
const concurrencyLevel = (cliConcurrency && cliConcurrency >= 1 && cliConcurrency <= 5) ? cliConcurrency : 2;
|
|
883
|
+
|
|
880
884
|
let userIgnorePatterns = [];
|
|
881
885
|
try {
|
|
882
886
|
const ignorePath = path.join(process.cwd(), '.devsplainignore');
|
|
@@ -908,80 +912,95 @@ Options:
|
|
|
908
912
|
return false;
|
|
909
913
|
}
|
|
910
914
|
|
|
911
|
-
|
|
915
|
+
const validExtensions = [
|
|
916
|
+
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
917
|
+
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
918
|
+
'.swift', '.kt', '.dart', '.sh', '.sql'
|
|
919
|
+
];
|
|
920
|
+
|
|
921
|
+
// Separate file discovery from processing for concurrency support [ds]
|
|
922
|
+
function collectFiles(targetPath) {
|
|
923
|
+
const collected = [];
|
|
912
924
|
const stats = fs.statSync(targetPath);
|
|
913
925
|
|
|
914
|
-
if (isPathIgnored(targetPath))
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
926
|
+
if (isPathIgnored(targetPath)) return collected;
|
|
917
927
|
|
|
918
928
|
if (stats.isDirectory()) {
|
|
919
929
|
console.log(`\n Scanning directory: ${targetPath}`);
|
|
920
930
|
const items = fs.readdirSync(targetPath);
|
|
921
931
|
for (const item of items) {
|
|
922
|
-
|
|
923
|
-
await processPath(fullPath);
|
|
932
|
+
collected.push(...collectFiles(path.join(targetPath, item)));
|
|
924
933
|
}
|
|
925
|
-
}
|
|
926
|
-
else if (stats.isFile()) {
|
|
934
|
+
} else if (stats.isFile()) {
|
|
927
935
|
const ext = path.extname(targetPath).toLowerCase();
|
|
928
|
-
|
|
929
|
-
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
930
|
-
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
931
|
-
'.swift', '.kt', '.dart', '.sh', '.sql'
|
|
932
|
-
];
|
|
933
|
-
|
|
934
|
-
if (!validExtensions.includes(ext)) {
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
936
|
+
if (!validExtensions.includes(ext)) return collected;
|
|
937
937
|
|
|
938
|
-
const filename = path.basename(targetPath);
|
|
939
938
|
const data = fs.readFileSync(targetPath, 'utf-8');
|
|
940
939
|
if (data.trim() === '') {
|
|
941
|
-
console.log(` Skipping ${
|
|
942
|
-
return;
|
|
940
|
+
console.log(` Skipping ${path.basename(targetPath)} (Empty File)`);
|
|
941
|
+
return collected;
|
|
943
942
|
}
|
|
943
|
+
collected.push(targetPath);
|
|
944
|
+
}
|
|
945
|
+
return collected;
|
|
946
|
+
}
|
|
944
947
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
console.log(` Skipped ${targetPath}`);
|
|
969
|
-
}
|
|
970
|
-
} else {
|
|
948
|
+
async function processSingleFile(targetPath) {
|
|
949
|
+
const filename = path.basename(targetPath);
|
|
950
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
951
|
+
const data = fs.readFileSync(targetPath, 'utf-8');
|
|
952
|
+
|
|
953
|
+
console.log(` Analyzing ${filename} in ${mode} mode...`);
|
|
954
|
+
try {
|
|
955
|
+
let comments = [];
|
|
956
|
+
let commentedCode;
|
|
957
|
+
if (mode !== 'clean' && mode !== 'prune') {
|
|
958
|
+
const preProcessMode = isOverwrite ? 'prune' : 'clean';
|
|
959
|
+
const cleanData = spliceComments(data, [], preProcessMode, ext);
|
|
960
|
+
comments = await getComments(cleanData, filename, config, mode);
|
|
961
|
+
commentedCode = spliceComments(cleanData, comments, mode, ext);
|
|
962
|
+
} else {
|
|
963
|
+
commentedCode = spliceComments(data, [], mode, ext);
|
|
964
|
+
}
|
|
965
|
+
if (isDryRun) {
|
|
966
|
+
console.log(`\n --- DRY RUN PREVIEW: ${filename} ---`);
|
|
967
|
+
console.log(commentedCode);
|
|
968
|
+
console.log(`---------------------------------------\n`);
|
|
969
|
+
const answer = await askQuestion("Type 'write' to save to file, or press any key to discard: ");
|
|
970
|
+
if (answer.toLowerCase() === 'write') {
|
|
971
971
|
const tempPath = targetPath + '.tmp';
|
|
972
972
|
fs.writeFileSync(tempPath, commentedCode, 'utf8');
|
|
973
973
|
fs.renameSync(tempPath, targetPath);
|
|
974
|
-
console.log(` Successfully
|
|
974
|
+
console.log(` Successfully saved ${targetPath}`);
|
|
975
|
+
} else {
|
|
976
|
+
console.log(` Skipped ${targetPath}`);
|
|
975
977
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
978
|
+
} else {
|
|
979
|
+
const tempPath = targetPath + '.tmp';
|
|
980
|
+
fs.writeFileSync(tempPath, commentedCode, 'utf8');
|
|
981
|
+
fs.renameSync(tempPath, targetPath);
|
|
982
|
+
console.log(` Successfully commented ${targetPath}`);
|
|
980
983
|
}
|
|
984
|
+
successCount++;
|
|
985
|
+
} catch (err) {
|
|
986
|
+
console.error(` Error processing ${filename}: ${err.message}`);
|
|
987
|
+
failCount++;
|
|
981
988
|
}
|
|
982
989
|
}
|
|
983
990
|
|
|
984
|
-
|
|
991
|
+
// Collect all eligible files, then process with adaptive concurrency [ds]
|
|
992
|
+
const filesToProcess = collectFiles(filepath);
|
|
993
|
+
|
|
994
|
+
// Dry-run mode processes files serially to allow interactive prompts [ds]
|
|
995
|
+
if (isDryRun || mode === 'clean' || mode === 'prune') {
|
|
996
|
+
for (const file of filesToProcess) {
|
|
997
|
+
await processSingleFile(file);
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
resetConcurrency(concurrencyLevel);
|
|
1001
|
+
console.log(`\n Processing ${filesToProcess.length} file(s) with concurrency: ${concurrencyLevel}`);
|
|
1002
|
+
await runWithConcurrency(filesToProcess, processSingleFile);
|
|
1003
|
+
}
|
|
985
1004
|
|
|
986
1005
|
if (failCount > 0 && successCount === 0) {
|
|
987
1006
|
console.error("\nFailed: No files were successfully commented.");
|
package/lib/llm.js
CHANGED
|
@@ -1,432 +1,473 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let lastError;
|
|
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
|
-
const
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
} catch (error) {
|
|
242
|
-
throw
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
throw new Error("Schema Error: 'comment' must be a string.");
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 };
|
package/package.json
CHANGED