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.
Files changed (3) hide show
  1. package/bin/cli.js +73 -54
  2. package/lib/llm.js +473 -432
  3. 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
- async function processPath(targetPath) {
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
- const fullPath = path.join(targetPath, item);
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
- const validExtensions = [
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 ${filename} (Empty File)`);
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
- console.log(` Analyzing ${filename} in ${mode} mode...`);
946
- try {
947
- let comments = [];
948
- let commentedCode;
949
- if (mode !== 'clean' && mode !== 'prune') {
950
- const preProcessMode = isOverwrite ? 'prune' : 'clean';
951
- const cleanData = spliceComments(data, [], preProcessMode, ext);
952
- comments = await getComments(cleanData, filename, config, mode);
953
- commentedCode = spliceComments(cleanData, comments, mode, ext);
954
- } else {
955
- commentedCode = spliceComments(data, [], mode, ext);
956
- }
957
- if (isDryRun) {
958
- console.log(`\n --- DRY RUN PREVIEW: ${filename} ---`);
959
- console.log(commentedCode);
960
- console.log(`---------------------------------------\n`);
961
- const answer = await askQuestion("Type 'write' to save to file, or press any key to discard: ");
962
- if (answer.toLowerCase() === 'write') {
963
- const tempPath = targetPath + '.tmp';
964
- fs.writeFileSync(tempPath, commentedCode, 'utf8');
965
- fs.renameSync(tempPath, targetPath);
966
- console.log(` Successfully saved ${targetPath}`);
967
- } else {
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 commented ${targetPath}`);
974
+ console.log(` Successfully saved ${targetPath}`);
975
+ } else {
976
+ console.log(` Skipped ${targetPath}`);
975
977
  }
976
- successCount++;
977
- } catch (err) {
978
- console.error(` Error processing ${filename}: ${err.message}`);
979
- failCount++;
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
- await processPath(filepath);
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
- async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000) {
10
- // Initialize a variable to store the last encountered error for potential re-throwing [ds]
11
- let lastError;
12
- // Enter a retry loop that executes up to 'maxRetries' times [ds]
13
- for (let attempt = 0; attempt < maxRetries; attempt++) {
14
- // Create an AbortController to manually trigger request timeouts [ds]
15
- const controller = new AbortController();
16
- // Schedule an abort action after 45 seconds to prevent hanging connections [ds]
17
- const timeoutId = setTimeout(() => controller.abort(), 45000);
18
- // Begin a try block to handle potential network or processing exceptions [ds]
19
- try {
20
- // Execute the fetch request, passing the URL and options with the abort signal [ds]
21
- const response = await fetch(url, {
22
- // Spread existing options into the fetch configuration [ds]
23
- ...options,
24
- // Attach the abort signal to allow external cancellation [ds]
25
- signal: controller.signal
26
- });
27
- // Clear the timeout timer to clean up the abort signal since the request completed [ds]
28
- clearTimeout(timeoutId);
29
- // Validate that a response object was actually received [ds]
30
- if (!response) {
31
- // Throw a custom error if the response is missing or null [ds]
32
- throw new Error("No response received from fetch");
33
- }
34
- // Check if the HTTP response indicates success (2xx status) [ds]
35
- if (response.ok) {
36
- // Return the response immediately upon successful HTTP completion [ds]
37
- return response;
38
- }
39
- // Identify transient errors: 429 (Too Many Requests) or 5xx (Server Errors) [ds]
40
- if (response.status === 429 || (response.status >= 500 && response.status < 600)) {
41
- // Store the transient error to trigger a retry in the next loop iteration [ds]
42
- lastError = new Error(`HTTP Error ${response.status}: ${response.statusText}`);
43
- } else {
44
- // For non-transient errors (4xx), return the response directly to let caller handle [ds]
45
- return response;
46
- }
47
- // Catch any synchronous errors thrown during the await or setup [ds]
48
- } catch (err) {
49
- // Clear the timeout timer to prevent memory leaks on failure [ds]
50
- clearTimeout(timeoutId);
51
- // Check if the error was caused by the manual timeout abort [ds]
52
- if (err.name === 'AbortError') {
53
- // Convert the AbortError into a descriptive timeout message [ds]
54
- lastError = new Error("Request timed out after 45 seconds");
55
- } else {
56
- // Store any other standard errors for potential retry logic [ds]
57
- lastError = err;
58
- }
59
- }
60
-
61
- // Check if more retry attempts remain before the final try [ds]
62
- if (attempt < maxRetries - 1) {
63
- // Calculate exponential backoff delay: base delay multiplied by 2 to the power of attempts [ds]
64
- const backoffDelay = initialDelay * Math.pow(2, attempt);
65
- // Log a warning to the console indicating a retry is occurring [ds]
66
- console.warn(`[devsplain] AI request failed. Retrying in ${backoffDelay}ms... (Attempt ${attempt + 1}/${maxRetries})`);
67
- // Pause execution for the calculated backoff duration before retrying [ds]
68
- await new Promise(resolve => setTimeout(resolve, backoffDelay));
69
- }
70
- }
71
- // Re-throw the last error if all retry attempts have been exhausted [ds]
72
- throw lastError;
73
- }
74
-
75
- /** [ds]
76
- * Orchestrate code documentation by calling various AI providers.
77
- * Handles prompt construction, provider-specific API calls, and response parsing.
78
- * @param {string} code - The source code to analyze.
79
- * @param {string} language - The programming language and file extension.
80
- * @param {object} config - Provider configuration details.
81
- * @param {string} mode - Documentation density mode ('default', 'light', 'full', 'clean').
82
- * @returns {Promise<Array>} - Array of comment insertion objects.
83
- */
84
- async function getComments(code, language, config, mode = 'default') {
85
- // Split source code into an array of lines, handling Windows and Unix newline styles [ds]
86
- const lines = code.split(/\r?\n/);
87
- // Prepend line numbers to each line to help the AI map comments to specific locations [ds]
88
- const numberedCode = lines.map((line, index) => `${index + 1}: ${line}`).join('\n');
89
-
90
- // Extract the file extension using a regular expression [ds]
91
- const extMatch = language.match(/\.[0-9a-z]+$/i);
92
- // Normalize the extension to lowercase or default to empty string if missing [ds]
93
- const ext = extMatch ? extMatch[0].toLowerCase() : '';
94
- // Determine if the language is Python [ds]
95
- const isPython = ext === '.py';
96
- // Determine if the language is Ruby or Shell Script [ds]
97
- const isRubyOrShell = ['.rb', '.sh'].includes(ext);
98
- // Determine if the language is HTML-based (HTML, Vue, Svelte) [ds]
99
- const isHTML = ['.html', '.vue', '.svelte'].includes(ext);
100
- // Determine if the language is CSS or SCSS [ds]
101
- const isCss = ['.css', '.scss'].includes(ext);
102
- // Determine if the language is SQL [ds]
103
- const isSql = ext === '.sql';
104
- // Initialize comment syntax tokens for standard C-like languages [ds]
105
- let singleLineToken = '//';
106
- let blockExample = '/** Calculates the total price */';
107
- let inlineExample = '// Check for null values';
108
-
109
- // Adjust comment syntax for Python and Shell-based languages [ds]
110
- if (isPython || isRubyOrShell) {
111
- singleLineToken = '#';
112
- blockExample = '# Calculates the total price';
113
- inlineExample = '# Check for null values';
114
- // Adjust comment syntax for HTML template languages [ds]
115
- } else if (isHTML) {
116
- singleLineToken = '<!--';
117
- blockExample = '<!-- Calculates the total price -->';
118
- inlineExample = '<!-- Check for null values -->';
119
- // Adjust comment syntax for CSS stylesheets [ds]
120
- } else if (isCss) {
121
- singleLineToken = '/*';
122
- blockExample = '/* Calculates the total price */';
123
- inlineExample = '/* Check for null values */';
124
- // Adjust comment syntax for SQL queries [ds]
125
- } else if (isSql) {
126
- singleLineToken = '--';
127
- blockExample = '-- Calculates the total price';
128
- inlineExample = '-- Check for null values';
129
- }
130
-
131
- // Set default prompting instruction for standard documentation [ds]
132
- let instruction = `Provide block comments above functions and sparse inline comments for complex logic.`;
133
- // Modify instruction if minimal documentation is requested [ds]
134
- if (mode === 'light') {
135
- instruction = `Provide ONLY block comments above functions. Keep it minimal.`;
136
- // Modify instruction for exhaustive step-by-step documentation mode [ds]
137
- } else if (mode === 'full') {
138
- 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.`;
139
- }
140
-
141
- // Define a placeholder rule for comment syntax restrictions [ds]
142
- let rule5 = `5. IMPORTANT: Use ONLY ${singleLineToken} for comments. DO NOT use docstrings or multi-line string literals like """ or ''' for comments.`;
143
- // Override the syntax rule specifically for CSS files [ds]
144
- if (isCss) {
145
- rule5 = `5. IMPORTANT: In CSS/SCSS, you MUST use /* ... */ for comments. DO NOT use // comments under any circumstances.`;
146
- }
147
-
148
- // Construct the full prompt combining rules, examples, and the numbered source code [ds]
149
- let prompt = `
150
- You are a code documentation engine. Analyze the following ${language} code which has line numbers prepended to it.
151
- ${instruction}
152
-
153
- CRITICAL RULES:
154
- 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.
155
- 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).
156
- 3. Do NOT include the original code in your response.
157
- 4. If no comments are needed, return an empty array: [].
158
- ${rule5}
159
-
160
- Example Output:
161
- [
162
- { "line": 4, "comment": "${blockExample}" },
163
- { "line": 12, "comment": "${inlineExample}" }
164
- ]
165
-
166
- Here is the source code:
167
- ${numberedCode}
168
- `.trim();
169
-
170
- // Initialize a variable to hold the raw text output from the AI provider [ds]
171
- let textResponse = "";
172
-
173
- // Branch to handle the Google Gemini API provider [ds]
174
- if (config.provider === 'gemini') {
175
- // Construct the Google Gemini API endpoint URL with model and API key [ds]
176
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
177
- // Declare a variable to store the parsed API response [ds]
178
- let data;
179
- // Begin error handling for the Gemini API call [ds]
180
- try {
181
- // Send the POST request to the Gemini API with the constructed prompt [ds]
182
- const response = await fetchWithRetry(url, {
183
- method: 'POST',
184
- headers: {
185
- 'Content-Type': 'application/json'
186
- },
187
- body: JSON.stringify({
188
- "contents": [{ "parts": [{ "text": prompt }] }]
189
- })
190
- });
191
- // Parse the JSON response body from the Gemini service [ds]
192
- data = await response.json();
193
- // Wrap network or fetch errors in a standard descriptive error [ds]
194
- } catch (error) {
195
- throw new Error(`AI Provider Request Failed: ${error.message}`);
196
- }
197
- // Check if the Gemini response contains an error object [ds]
198
- if (data.error) {
199
- // Extract the human-readable error message from the Google-specific structure [ds]
200
- const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
201
- // Throw a normalized error based on the API error details [ds]
202
- throw new Error(`API Error: ${msg}`);
203
- }
204
- // Validate that the Gemini response structure contains the expected content [ds]
205
- if (!data.candidates || !data.candidates[0] || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts[0]) {
206
- // Capture the finish reason to provide context for empty content errors [ds]
207
- const reason = data.candidates?.[0]?.finishReason || 'Unknown error';
208
- // Throw an error indicating the AI returned no usable content [ds]
209
- throw new Error(`AI Provider returned no content (finish reason: ${reason})`);
210
- }
211
- // Extract the raw text content from the Gemini response candidates [ds]
212
- textResponse = data.candidates[0].content.parts[0].text;
213
- // Branch to handle the Anthropic Claude API provider [ds]
214
- } else if (config.provider === 'claude') {
215
- // Construct the Anthropic API endpoint URL [ds]
216
- const url = `${config.baseUrl}/v1/messages`;
217
- // Declare a variable to store the parsed Anthropic API response [ds]
218
- let data;
219
- // Begin error handling for the Anthropic API call [ds]
220
- try {
221
- // Send the POST request to Anthropic with version headers and API key [ds]
222
- const response = await fetchWithRetry(url, {
223
- method: 'POST',
224
- headers: {
225
- 'Content-Type': 'application/json',
226
- 'x-api-key': config.apiKey,
227
- 'anthropic-version': '2023-06-01'
228
- },
229
- body: JSON.stringify({
230
- "model": config.model,
231
- "max_tokens": 8192,
232
- "messages": [{
233
- "role": "user",
234
- "content": prompt
235
- }]
236
- })
237
- });
238
- // Parse the JSON response body from the Anthropic service [ds]
239
- data = await response.json();
240
- // Wrap network or fetch errors in a standard descriptive error [ds]
241
- } catch (error) {
242
- throw new Error(`AI Provider Request Failed: ${error.message}`);
243
- }
244
- // Check if the Anthropic response contains an error object [ds]
245
- if (data.error) {
246
- // Extract the human-readable error message from the Anthropic response [ds]
247
- const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
248
- // Throw a normalized error based on the API error details [ds]
249
- throw new Error(`API Error: ${msg}`);
250
- }
251
- // Validate that the Anthropic response structure contains valid text content [ds]
252
- if (!data.content || !data.content[0] || typeof data.content[0].text !== 'string') {
253
- // Throw an error with the raw JSON if the structure is unexpected [ds]
254
- throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
255
- }
256
- // Extract the text content from the first message block [ds]
257
- textResponse = data.content[0].text;
258
- }
259
- // Default branch for OpenAI-compatible APIs (OpenAI, Groq, etc.) [ds]
260
- else {
261
- // Construct the OpenAI-compatible chat completions endpoint [ds]
262
- const url = `${config.baseUrl}/v1/chat/completions`;
263
- // Declare a variable to store the parsed OpenAI-compatible response [ds]
264
- let data;
265
-
266
- // Initialize the request body structure with model and message content [ds]
267
- const reqBody = {
268
- "model": config.model,
269
- "messages": [{
270
- "role": "user",
271
- "content": prompt
272
- }]
273
- };
274
- // Apply specific token limits for Groq provider to avoid issues [ds]
275
- if (config.provider === 'groq') {
276
- reqBody.max_tokens = 1000;
277
- // Apply default high token limit for other OpenAI-compatible providers [ds]
278
- } else {
279
- reqBody.max_tokens = 8192;
280
- }
281
-
282
- // Begin error handling for the OpenAI-compatible API call [ds]
283
- try {
284
- // Send the POST request with Bearer token authentication [ds]
285
- const response = await fetchWithRetry(url, {
286
- method: 'POST',
287
- headers: {
288
- 'Content-Type': 'application/json',
289
- 'Authorization': `Bearer ${config.apiKey}`
290
- },
291
- body: JSON.stringify(reqBody)
292
- });
293
- // Parse the JSON response body from the OpenAI-compatible service [ds]
294
- data = await response.json();
295
- // Wrap network or fetch errors in a standard descriptive error [ds]
296
- } catch (error) {
297
- throw new Error(`AI Provider Request Failed: ${error.message}`);
298
- }
299
- // Check if the OpenAI-compatible response contains an error object [ds]
300
- if (data.error) {
301
- // Extract the human-readable error message from the OpenAI error structure [ds]
302
- const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
303
- // Throw a normalized error based on the API error details [ds]
304
- throw new Error(`API Error: ${msg}`);
305
- }
306
- // Validate that the OpenAI response structure contains a valid message object [ds]
307
- if (!data.choices || !data.choices[0] || !data.choices[0].message || typeof data.choices[0].message.content !== 'string') {
308
- // Throw an error with the raw JSON if the structure is unexpected [ds]
309
- throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
310
- }
311
- // Extract the text content from the first choice's message [ds]
312
- textResponse = data.choices[0].message.content;
313
- }
314
-
315
- // Remove leading and trailing whitespace from the AI text output [ds]
316
- let cleanText = textResponse.trim();
317
- // Locate the starting index of the JSON array [ds]
318
- const start = cleanText.indexOf('[');
319
- // Locate the ending index of the JSON array [ds]
320
- const end = cleanText.lastIndexOf(']');
321
- // Check if a JSON array was actually found in the text [ds]
322
- if (start !== -1) {
323
- // Validate that the end index exists and comes after the start index [ds]
324
- if (end !== -1 && end >= start) {
325
- // Slice the text to contain only the JSON array portion [ds]
326
- cleanText = cleanText.substring(start, end + 1);
327
- } else {
328
- // If ']' is missing, look for the last closing brace to fix truncated JSON [ds]
329
- const lastBrace = cleanText.lastIndexOf('}');
330
- // Ensure the found brace is actually within the current text segment [ds]
331
- if (lastBrace > start) {
332
- // Manually append the missing closing bracket to make valid JSON [ds]
333
- cleanText = cleanText.substring(start, lastBrace + 1) + ']';
334
- }
335
- }
336
- }
337
-
338
- // Declare a variable to store the parsed JSON object [ds]
339
- let parsed;
340
- // Attempt to parse the cleaned text string into a JavaScript object [ds]
341
- try {
342
- // Execute the JSON parsing operation [ds]
343
- parsed = JSON.parse(cleanText);
344
- // Catch parsing errors if the text is malformed [ds]
345
- } catch (e) {
346
- // Throw a descriptive error including the raw response for debugging [ds]
347
- throw new Error(`Parsing Error: Failed to parse LLM response as JSON. Raw response was:\n${textResponse}`);
348
- }
349
-
350
- // Validate that the parsed result is a JavaScript array [ds]
351
- if (!Array.isArray(parsed)) {
352
- // Throw an error if the top-level JSON structure is not an array [ds]
353
- throw new Error("Schema Error: LLM response is not a JSON array.");
354
- }
355
-
356
- // Iterate over each comment object in the parsed array for validation [ds]
357
- for (const item of parsed) {
358
- // Verify that each item is a real object and not null or a primitive [ds]
359
- if (typeof item !== 'object' || item === null) {
360
- // Throw a schema error for invalid object types [ds]
361
- throw new Error("Schema Error: Array elements must be objects.");
362
- }
363
- // Validate that the 'line' property is a positive integer [ds]
364
- if (!Number.isInteger(item.line) || item.line <= 0) {
365
- // Throw a schema error for invalid line numbers [ds]
366
- throw new Error("Schema Error: 'line' must be a positive integer.");
367
- }
368
-
369
- // Enforce specific schema rules for data cleaning mode [ds]
370
- if (mode === 'clean') {
371
- // Ensure the 'action' property is 'delete' if in clean mode [ds]
372
- if (item.action !== 'delete') {
373
- // Throw a schema error for invalid actions in clean mode [ds]
374
- throw new Error("Schema Error: 'action' must be 'delete' in clean mode.");
375
- }
376
- // Handle standard documentation modes that require comment text [ds]
377
- } else {
378
- // Verify that the 'comment' property exists and is a string [ds]
379
- if (typeof item.comment !== 'string') {
380
- // Throw a schema error if comment text is missing or not a string [ds]
381
- throw new Error("Schema Error: 'comment' must be a string.");
382
- }
383
-
384
- // Normalize the comment text by trimming whitespace [ds]
385
- const trimmedComment = item.comment.trim();
386
- // Split the comment into lines to validate syntax per line [ds]
387
- const commentLines = trimmedComment.split(/\r?\n/);
388
- // Track whether we are currently inside a block comment [ds]
389
- let inBlock = false;
390
- // Loop through each line of the comment string [ds]
391
- for (const cl of commentLines) {
392
- // Trim leading/trailing whitespace from the current comment line [ds]
393
- const tcl = cl.trim();
394
- // Skip empty lines during validation [ds]
395
- if (!tcl) continue;
396
- // Check if this line is part of the continuation of a block comment [ds]
397
- if (inBlock) {
398
- // Detect if the line closes a block comment (CSS/HTML style) [ds]
399
- if (tcl.includes('*/') || tcl.includes('-->')) {
400
- // Mark block comment state as false if a closing marker is found [ds]
401
- inBlock = false;
402
- }
403
- // Skip validation for lines inside a block comment [ds]
404
- continue;
405
- }
406
- // Check if the line starts with a valid comment marker [ds]
407
- const startsWithMarker =
408
- tcl.startsWith('//') ||
409
- tcl.startsWith('/*') ||
410
- tcl.startsWith('*') ||
411
- tcl.startsWith('#') ||
412
- tcl.startsWith('<!--') ||
413
- tcl.startsWith('--');
414
- // Throw a strict security error if a line lacks a comment prefix [ds]
415
- if (!startsWithMarker) {
416
- throw new Error(`Security Error: Comment on line ${item.line} contains invalid non-comment line: "${tcl}"`);
417
- }
418
- // Detect if a line opens a block comment without closing it [ds]
419
- if ((tcl.startsWith('/*') && !tcl.includes('*/')) || (tcl.startsWith('<!--') && !tcl.includes('-->'))) {
420
- // Set the block comment state to true for subsequent line processing [ds]
421
- inBlock = true;
422
- }
423
- }
424
- }
425
- }
426
-
427
- // Return the fully validated and parsed array of comments [ds]
428
- return parsed;
429
- }
430
-
431
- // Export the main function for use by external modules [ds]
432
- module.exports = { getComments };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "2.2.4",
3
+ "version": "2.3.0",
4
4
  "description": "An agent-agnostic CLI tool that automatically adds JSDoc and inline comments to your code using free LLMs.",
5
5
  "author": "mwahaj36",
6
6
  "license": "MIT",