devsplain 2.3.0 → 2.3.2
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/README.md +4 -2
- package/bin/cli.js +131 -14
- package/lib/config.js +108 -7
- package/lib/llm.js +201 -52
- package/package.json +1 -1
package/lib/llm.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/** [ds]
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
2
|
+
* Fetches a URL with automatic retry using exponential backoff.
|
|
3
|
+
* Rate limit (429) errors are re-thrown immediately so callers can apply
|
|
4
|
+
* higher-level throttling; 4xx client errors are returned as-is (no retry),
|
|
5
|
+
* while 5xx server errors and network/timeout failures are retried up to
|
|
6
|
+
* maxRetries times. A 45s AbortController timeout guards each attempt.
|
|
9
7
|
*/
|
|
10
8
|
async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000) {
|
|
11
9
|
let lastError;
|
|
12
10
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
11
|
+
// Fresh AbortController per attempt so a prior timeout does not poison retries [ds]
|
|
13
12
|
const controller = new AbortController();
|
|
14
13
|
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
|
15
14
|
try {
|
|
@@ -22,14 +21,25 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
|
|
|
22
21
|
throw new Error("No response received from fetch");
|
|
23
22
|
}
|
|
24
23
|
if (response.ok) {
|
|
24
|
+
// Opportunistically harvest provider-advertised token limits from response headers to enable dynamic tier scaling [ds]
|
|
25
|
+
if (response.headers && typeof response.headers.get === 'function') {
|
|
26
|
+
const limitTokensHeader = response.headers.get('x-ratelimit-limit-tokens');
|
|
27
|
+
if (limitTokensHeader) {
|
|
28
|
+
const parsedLimit = parseInt(limitTokensHeader, 10);
|
|
29
|
+
if (!isNaN(parsedLimit) && parsedLimit > 0) {
|
|
30
|
+
_discoveredTierLimit = parsedLimit;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
return response;
|
|
26
35
|
}
|
|
27
|
-
//
|
|
36
|
+
// 429 is propagated to the caller (not retried here) so the concurrency controller can globally throttle [ds]
|
|
28
37
|
if (response.status === 429) {
|
|
29
38
|
const err = new Error(`HTTP Error 429: ${response.statusText || 'Too Many Requests'}`);
|
|
30
39
|
err.isRateLimit = true;
|
|
31
40
|
throw err;
|
|
32
41
|
}
|
|
42
|
+
// Retry only transient server errors; other 4xx responses are terminal and returned to caller [ds]
|
|
33
43
|
if (response.status >= 500 && response.status < 600) {
|
|
34
44
|
lastError = new Error(`HTTP Error ${response.status}: ${response.statusText}`);
|
|
35
45
|
} else {
|
|
@@ -37,7 +47,6 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
|
|
|
37
47
|
}
|
|
38
48
|
} catch (err) {
|
|
39
49
|
clearTimeout(timeoutId);
|
|
40
|
-
// Propagate rate limit errors immediately without retrying [ds]
|
|
41
50
|
if (err.isRateLimit) {
|
|
42
51
|
throw err;
|
|
43
52
|
}
|
|
@@ -48,6 +57,7 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
|
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
// Exponential backoff (initialDelay * 2^attempt); skip delay after the final attempt [ds]
|
|
51
61
|
if (attempt < maxRetries - 1) {
|
|
52
62
|
const backoffDelay = initialDelay * Math.pow(2, attempt);
|
|
53
63
|
console.warn(`[devsplain] AI request failed. Retrying in ${backoffDelay}ms... (Attempt ${attempt + 1}/${maxRetries})`);
|
|
@@ -57,13 +67,73 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
|
|
|
57
67
|
throw lastError;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
// ─── Chunking
|
|
70
|
+
// ─── Provider Profiles for Model-Adaptive Chunking ────────────────────────────
|
|
71
|
+
// Baseline free-tier safe defaults: preserves token limits for free/low tiers,
|
|
72
|
+
// while expanding window sizes for high-capacity providers (DeepSeek, Gemini, Claude).
|
|
73
|
+
/** [ds]
|
|
74
|
+
* Per-provider chunking profiles tuned to each API's safe free-tier limits.
|
|
75
|
+
* Size/overlap are measured in lines; threshold triggers a fresh chunk;
|
|
76
|
+
* maxTokens caps the model context window for each provider.
|
|
77
|
+
*/
|
|
78
|
+
const PROVIDER_PROFILES = {
|
|
79
|
+
groq: { size: 200, overlap: 20, threshold: 250, maxTokens: 1000 },
|
|
80
|
+
deepseek: { size: 600, overlap: 50, threshold: 750, maxTokens: 8192 },
|
|
81
|
+
gemini: { size: 800, overlap: 60, threshold: 1000, maxTokens: 8192 },
|
|
82
|
+
claude: { size: 600, overlap: 50, threshold: 750, maxTokens: 8192 },
|
|
83
|
+
openai: { size: 600, overlap: 50, threshold: 750, maxTokens: 4096 },
|
|
84
|
+
default: { size: 250, overlap: 25, threshold: 300, maxTokens: 4096 }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Tracks discovered rate limits from response headers (e.g. x-ratelimit-limit-tokens)
|
|
88
|
+
let _discoveredTierLimit = null;
|
|
89
|
+
|
|
90
|
+
// Baseline constants exported for backward compatibility
|
|
61
91
|
const CHUNK_SIZE = 200;
|
|
62
92
|
const CHUNK_OVERLAP = 20;
|
|
63
93
|
const CHUNK_THRESHOLD = 250;
|
|
64
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Returns the effective chunking parameters for a provider.
|
|
97
|
+
* Allows override via config (e.g., config.chunkSize from --chunk-size flag)
|
|
98
|
+
* or scaled up dynamically if response headers reveal high tier capacity.
|
|
99
|
+
* @param {object} config - Provider configuration object.
|
|
100
|
+
* @returns {object} { size, overlap, threshold, maxTokens }
|
|
101
|
+
*/
|
|
102
|
+
/** [ds]
|
|
103
|
+
* Resolves effective chunk parameters for a provider, honoring explicit
|
|
104
|
+
* config overrides. When response headers previously revealed a high-tier
|
|
105
|
+
* limit (>100k TPM), chunk sizes are scaled up 1.5x (capped) unless the
|
|
106
|
+
* user explicitly pinned chunkSize.
|
|
107
|
+
*/
|
|
108
|
+
function getChunkConfig(config = {}) {
|
|
109
|
+
const provider = (config.provider || '').toLowerCase();
|
|
110
|
+
const base = PROVIDER_PROFILES[provider] || PROVIDER_PROFILES.default;
|
|
111
|
+
|
|
112
|
+
let size = config.chunkSize || base.size;
|
|
113
|
+
let overlap = config.chunkOverlap || base.overlap;
|
|
114
|
+
let threshold = config.chunkThreshold || base.threshold;
|
|
115
|
+
const maxTokens = base.maxTokens;
|
|
116
|
+
|
|
117
|
+
// Dynamic Tier Scaling: if response headers revealed a high-tier token limit (>100k TPM),
|
|
118
|
+
// scale up chunk size safely by 1.5x up to 1000 lines max
|
|
119
|
+
// Only auto-scale when the user hasn't manually overridden the chunk size [ds]
|
|
120
|
+
if (!config.chunkSize && _discoveredTierLimit && _discoveredTierLimit > 100000) {
|
|
121
|
+
size = Math.min(1000, Math.round(size * 1.5));
|
|
122
|
+
threshold = Math.min(1200, Math.round(threshold * 1.5));
|
|
123
|
+
overlap = Math.min(100, Math.round(overlap * 1.5));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { size, overlap, threshold, maxTokens };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function setDiscoveredTierLimit(limit) {
|
|
130
|
+
_discoveredTierLimit = limit;
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
// ─── Adaptive Concurrency Controller ──────────────────────────────────────────
|
|
66
|
-
//
|
|
134
|
+
// Module-level adaptive state: shared across all runWithConcurrency invocations
|
|
135
|
+
// within a single run so a 429 on one task throttles all subsequent tasks.
|
|
136
|
+
// Shared adaptive state: a 429 anywhere flips the whole run into serial mode [ds]
|
|
67
137
|
let _concurrencyLimit = 2;
|
|
68
138
|
let _hitRateLimit = false;
|
|
69
139
|
|
|
@@ -74,6 +144,7 @@ let _hitRateLimit = false;
|
|
|
74
144
|
function resetConcurrency(initialLimit = 2) {
|
|
75
145
|
_concurrencyLimit = initialLimit;
|
|
76
146
|
_hitRateLimit = false;
|
|
147
|
+
_discoveredTierLimit = null;
|
|
77
148
|
}
|
|
78
149
|
|
|
79
150
|
/**
|
|
@@ -84,44 +155,58 @@ function resetConcurrency(initialLimit = 2) {
|
|
|
84
155
|
* @param {Function} taskFn - Async function to run per item.
|
|
85
156
|
* @returns {Promise<Array>} - Resolved results in order.
|
|
86
157
|
*/
|
|
158
|
+
/** [ds]
|
|
159
|
+
* Runs async tasks through a bounded pool. On a 429 RateLimitError, the
|
|
160
|
+
* controller permanently reduces concurrency to 1 for the rest of the run
|
|
161
|
+
* and retries the failed task with exponential backoff (jittered).
|
|
162
|
+
* Preserves input ordering in the returned results array.
|
|
163
|
+
*/
|
|
87
164
|
async function runWithConcurrency(items, taskFn) {
|
|
88
165
|
const results = [];
|
|
89
166
|
const executing = new Set();
|
|
90
167
|
|
|
91
168
|
for (let i = 0; i < items.length; i++) {
|
|
92
169
|
const item = items[i];
|
|
170
|
+
|
|
171
|
+
// Backpressure: wait until a slot frees up before launching the next task [ds]
|
|
172
|
+
while (executing.size >= _concurrencyLimit) {
|
|
173
|
+
await Promise.race(executing);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Self-retry loop wraps each task so a single 429 doesn't abort the whole batch [ds]
|
|
93
177
|
const task = (async () => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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));
|
|
178
|
+
const maxRetries = 3;
|
|
179
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
180
|
+
try {
|
|
109
181
|
return await taskFn(item);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err.isRateLimit && attempt < maxRetries - 1) {
|
|
184
|
+
if (!_hitRateLimit) {
|
|
185
|
+
_hitRateLimit = true;
|
|
186
|
+
// Global throttle: first 429 in the run drops concurrency to serial for all remaining tasks [ds]
|
|
187
|
+
_concurrencyLimit = 1;
|
|
188
|
+
console.warn(`[devsplain] Rate limit hit — switching to serial mode.`);
|
|
189
|
+
}
|
|
190
|
+
// Use a short backoff under test to keep suites fast; add jitter to avoid thundering herd [ds]
|
|
191
|
+
const backoff = (process.env.NODE_ENV === 'test' ? 50 : 2000) * Math.pow(2, attempt) + Math.random() * 500;
|
|
192
|
+
console.warn(`[devsplain] Backing off for ${Math.round(backoff)}ms...`);
|
|
193
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
throw err;
|
|
110
197
|
}
|
|
111
|
-
throw err;
|
|
112
198
|
}
|
|
113
199
|
})();
|
|
114
200
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
201
|
+
let tracked;
|
|
202
|
+
// Attach the removal handler to the tracked promise so the slot frees once the task settles [ds]
|
|
203
|
+
tracked = task.finally(() => {
|
|
204
|
+
executing.delete(tracked);
|
|
205
|
+
});
|
|
206
|
+
// Swallow rejection on the tracked promise to avoid unhandled rejection warnings; callers see it via results [ds]
|
|
207
|
+
tracked.catch(() => {});
|
|
119
208
|
executing.add(tracked);
|
|
120
209
|
results.push(tracked);
|
|
121
|
-
|
|
122
|
-
if (executing.size >= _concurrencyLimit) {
|
|
123
|
-
await Promise.race(executing);
|
|
124
|
-
}
|
|
125
210
|
}
|
|
126
211
|
return Promise.all(results);
|
|
127
212
|
}
|
|
@@ -135,14 +220,22 @@ async function runWithConcurrency(items, taskFn) {
|
|
|
135
220
|
* @param {string} mode - Documentation mode ('default', 'light', 'full').
|
|
136
221
|
* @returns {string} The assembled prompt string.
|
|
137
222
|
*/
|
|
138
|
-
|
|
223
|
+
/** [ds]
|
|
224
|
+
* Assembles a mode-aware prompt instructing the LLM to emit strictly a JSON
|
|
225
|
+
* array of { line, comment } objects, using language-appropriate comment
|
|
226
|
+
* tokens. Includes anti-triviality rules and optional context to disambiguate.
|
|
227
|
+
*/
|
|
228
|
+
function buildPrompt(numberedCode, language, mode, contextSnippet = '') {
|
|
229
|
+
// Derive language from file extension since callers may pass a path or a bare identifier. [ds]
|
|
139
230
|
const extMatch = language.match(/\.[0-9a-z]+$/i);
|
|
140
231
|
const ext = extMatch ? extMatch[0].toLowerCase() : '';
|
|
141
232
|
const isPython = ext === '.py';
|
|
233
|
+
// Ruby and shell both share the '#' comment syntax [ds]
|
|
142
234
|
const isRubyOrShell = ['.rb', '.sh'].includes(ext);
|
|
143
235
|
const isHTML = ['.html', '.vue', '.svelte'].includes(ext);
|
|
144
236
|
const isCss = ['.css', '.scss'].includes(ext);
|
|
145
237
|
const isSql = ext === '.sql';
|
|
238
|
+
// Default comment syntax assumes C-style languages; overridden per detected extension below. [ds]
|
|
146
239
|
let singleLineToken = '//';
|
|
147
240
|
let blockExample = '/** Calculates the total price */';
|
|
148
241
|
let inlineExample = '// Check for null values';
|
|
@@ -165,6 +258,7 @@ function buildPrompt(numberedCode, language, mode) {
|
|
|
165
258
|
inlineExample = '-- Check for null values';
|
|
166
259
|
}
|
|
167
260
|
|
|
261
|
+
// 'light' and 'full' modes reuse the same scaffolding but swap the core instruction string. [ds]
|
|
168
262
|
let instruction = `Provide block comments above functions and sparse inline comments for complex logic.`;
|
|
169
263
|
if (mode === 'light') {
|
|
170
264
|
instruction = `Provide ONLY block comments above functions. Keep it minimal.`;
|
|
@@ -172,12 +266,13 @@ function buildPrompt(numberedCode, language, mode) {
|
|
|
172
266
|
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
267
|
}
|
|
174
268
|
|
|
269
|
+
// Rule 5 must be language-aware: CSS forbids //, while most other languages forbid # or <!-- as a primary comment marker. [ds]
|
|
175
270
|
let rule5 = `5. IMPORTANT: Use ONLY ${singleLineToken} for comments. DO NOT use docstrings or multi-line string literals like """ or ''' for comments.`;
|
|
271
|
+
// SCSS technically allows //, but we force /* */ to keep the prompt safe for plain CSS output. [ds]
|
|
176
272
|
if (isCss) {
|
|
177
273
|
rule5 = `5. IMPORTANT: In CSS/SCSS, you MUST use /* ... */ for comments. DO NOT use // comments under any circumstances.`;
|
|
178
274
|
}
|
|
179
275
|
|
|
180
|
-
// Anti-triviality negative constraints to eliminate syntax-narrating clutter [ds]
|
|
181
276
|
const antiTrivialityRules = `
|
|
182
277
|
ANTI-TRIVIALITY RULES (STRICTLY ENFORCED):
|
|
183
278
|
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).
|
|
@@ -188,6 +283,12 @@ ANTI-TRIVIALITY RULES (STRICTLY ENFORCED):
|
|
|
188
283
|
- A tricky formula, index manipulation (e.g. 0-indexed vs 1-indexed), or protocol-specific behavior occurs.
|
|
189
284
|
9. Prefer comprehensive function-level block comments over cluttered inline comments. Quality over quantity.`;
|
|
190
285
|
|
|
286
|
+
// Context lines are included for semantic grounding only; the model is instructed not to emit comments for them. [ds]
|
|
287
|
+
const contextBlock = contextSnippet ? `
|
|
288
|
+
Top-of-file context (for semantic reference only; DO NOT generate comments for these reference lines):
|
|
289
|
+
${contextSnippet}
|
|
290
|
+
` : '';
|
|
291
|
+
|
|
191
292
|
const prompt = `
|
|
192
293
|
You are a code documentation engine. Analyze the following ${language} code which has line numbers prepended to it.
|
|
193
294
|
${instruction}
|
|
@@ -199,7 +300,7 @@ CRITICAL RULES:
|
|
|
199
300
|
4. If no comments are needed, return an empty array: [].
|
|
200
301
|
${rule5}
|
|
201
302
|
${antiTrivialityRules}
|
|
202
|
-
|
|
303
|
+
${contextBlock}
|
|
203
304
|
Example Output:
|
|
204
305
|
[
|
|
205
306
|
{ "line": 4, "comment": "${blockExample}" },
|
|
@@ -221,10 +322,16 @@ ${numberedCode}
|
|
|
221
322
|
* @param {object} config - Provider config (provider, model, apiKey, baseUrl).
|
|
222
323
|
* @returns {Promise<string>} Raw text response from the AI.
|
|
223
324
|
*/
|
|
325
|
+
/** [ds]
|
|
326
|
+
* Dispatches the prompt to the active provider (Gemini, Claude, or OpenAI-compatible)
|
|
327
|
+
* and normalizes the provider-specific response envelope into a single string.
|
|
328
|
+
* Rate-limit errors are re-thrown unmodified so callers can apply backoff.
|
|
329
|
+
*/
|
|
224
330
|
async function fetchFromProvider(prompt, config) {
|
|
225
331
|
let textResponse = "";
|
|
226
332
|
|
|
227
333
|
if (config.provider === 'gemini') {
|
|
334
|
+
// Gemini is the only provider that takes the API key in the query string rather than a header. [ds]
|
|
228
335
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
|
|
229
336
|
let data;
|
|
230
337
|
try {
|
|
@@ -239,20 +346,24 @@ async function fetchFromProvider(prompt, config) {
|
|
|
239
346
|
});
|
|
240
347
|
data = await response.json();
|
|
241
348
|
} catch (error) {
|
|
242
|
-
//
|
|
349
|
+
// Preserve rate-limit errors so upstream retry logic can detect and handle them. [ds]
|
|
243
350
|
if (error.isRateLimit) throw error;
|
|
244
351
|
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
245
352
|
}
|
|
353
|
+
// Gemini may return errors either as a structured object or a raw string; normalize both. [ds]
|
|
246
354
|
if (data.error) {
|
|
247
355
|
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
248
356
|
throw new Error(`API Error: ${msg}`);
|
|
249
357
|
}
|
|
358
|
+
// Defensive deep-path check: Gemini can return 200 with empty candidates (e.g. safety blocks). [ds]
|
|
250
359
|
if (!data.candidates || !data.candidates[0] || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts[0]) {
|
|
360
|
+
// finishReason distinguishes safety blocks from genuinely empty responses for better diagnostics. [ds]
|
|
251
361
|
const reason = data.candidates?.[0]?.finishReason || 'Unknown error';
|
|
252
362
|
throw new Error(`AI Provider returned no content (finish reason: ${reason})`);
|
|
253
363
|
}
|
|
254
364
|
textResponse = data.candidates[0].content.parts[0].text;
|
|
255
365
|
} else if (config.provider === 'claude') {
|
|
366
|
+
// Anthropic endpoint and API version are pinned to the stable Messages API. [ds]
|
|
256
367
|
const url = `${config.baseUrl}/v1/messages`;
|
|
257
368
|
let data;
|
|
258
369
|
try {
|
|
@@ -265,6 +376,7 @@ async function fetchFromProvider(prompt, config) {
|
|
|
265
376
|
},
|
|
266
377
|
body: JSON.stringify({
|
|
267
378
|
"model": config.model,
|
|
379
|
+
// Anthropic requires an explicit max_tokens; 8192 covers large multi-function chunks. [ds]
|
|
268
380
|
"max_tokens": 8192,
|
|
269
381
|
"messages": [{
|
|
270
382
|
"role": "user",
|
|
@@ -287,6 +399,7 @@ async function fetchFromProvider(prompt, config) {
|
|
|
287
399
|
textResponse = data.content[0].text;
|
|
288
400
|
}
|
|
289
401
|
else {
|
|
402
|
+
// Fallback path assumes OpenAI-compatible /chat/completions schema (OpenAI, Groq, Ollama, etc.). [ds]
|
|
290
403
|
const url = `${config.baseUrl}/v1/chat/completions`;
|
|
291
404
|
let data;
|
|
292
405
|
|
|
@@ -297,11 +410,9 @@ async function fetchFromProvider(prompt, config) {
|
|
|
297
410
|
"content": prompt
|
|
298
411
|
}]
|
|
299
412
|
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
reqBody.max_tokens = 8192;
|
|
304
|
-
}
|
|
413
|
+
// Provider-specific token limits vary; getChunkConfig caps output to avoid truncation mid-JSON. [ds]
|
|
414
|
+
const chunkConfig = getChunkConfig(config);
|
|
415
|
+
reqBody.max_tokens = chunkConfig.maxTokens;
|
|
305
416
|
|
|
306
417
|
try {
|
|
307
418
|
const response = await fetchWithRetry(url, {
|
|
@@ -321,6 +432,7 @@ async function fetchFromProvider(prompt, config) {
|
|
|
321
432
|
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
322
433
|
throw new Error(`API Error: ${msg}`);
|
|
323
434
|
}
|
|
435
|
+
// Verify the nested response shape before accessing content, since some providers return null choices. [ds]
|
|
324
436
|
if (!data.choices || !data.choices[0] || !data.choices[0].message || typeof data.choices[0].message.content !== 'string') {
|
|
325
437
|
throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
|
|
326
438
|
}
|
|
@@ -338,14 +450,24 @@ async function fetchFromProvider(prompt, config) {
|
|
|
338
450
|
* @param {string} mode - Documentation mode.
|
|
339
451
|
* @returns {Array} Validated array of comment objects.
|
|
340
452
|
*/
|
|
453
|
+
/** [ds]
|
|
454
|
+
* Parses raw LLM output into a validated comment array. LLMs frequently wrap JSON in prose or
|
|
455
|
+
* markdown fences, so we defensively locate the outermost '[' ... ']' substring before parsing.
|
|
456
|
+
* If the closing ']' is missing (truncated output), we attempt to repair by appending ']' after
|
|
457
|
+
* the last '}' — a heuristic that recovers the common case of a cut-off JSON array.
|
|
458
|
+
* Also enforces that every returned comment is actually comment-shaped (starts with a known
|
|
459
|
+
* comment marker), preventing prompt-injection payloads from being injected as executable code.
|
|
460
|
+
*/
|
|
341
461
|
function parseAndValidate(textResponse, mode) {
|
|
342
462
|
let cleanText = textResponse.trim();
|
|
463
|
+
// Heuristic extraction: bracketed substring allows recovery even when LLM prefixes/suffixes with prose [ds]
|
|
343
464
|
const start = cleanText.indexOf('[');
|
|
344
465
|
const end = cleanText.lastIndexOf(']');
|
|
345
466
|
if (start !== -1) {
|
|
346
467
|
if (end !== -1 && end >= start) {
|
|
347
468
|
cleanText = cleanText.substring(start, end + 1);
|
|
348
469
|
} else {
|
|
470
|
+
// Truncated-array repair: closing bracket was cut off mid-stream; synthesize one after the final object [ds]
|
|
349
471
|
const lastBrace = cleanText.lastIndexOf('}');
|
|
350
472
|
if (lastBrace > start) {
|
|
351
473
|
cleanText = cleanText.substring(start, lastBrace + 1) + ']';
|
|
@@ -382,6 +504,7 @@ function parseAndValidate(textResponse, mode) {
|
|
|
382
504
|
}
|
|
383
505
|
|
|
384
506
|
const trimmedComment = item.comment.trim();
|
|
507
|
+
// Multi-line comment validation state machine: detect entering/exiting /* ... */ and <!-- ... --> blocks [ds]
|
|
385
508
|
const commentLines = trimmedComment.split(/\r?\n/);
|
|
386
509
|
let inBlock = false;
|
|
387
510
|
for (const cl of commentLines) {
|
|
@@ -393,6 +516,7 @@ function parseAndValidate(textResponse, mode) {
|
|
|
393
516
|
}
|
|
394
517
|
continue;
|
|
395
518
|
}
|
|
519
|
+
// Whitelist of comment-start markers; anything else is treated as an injection attempt [ds]
|
|
396
520
|
const startsWithMarker =
|
|
397
521
|
tcl.startsWith('//') ||
|
|
398
522
|
tcl.startsWith('/*') ||
|
|
@@ -403,6 +527,7 @@ function parseAndValidate(textResponse, mode) {
|
|
|
403
527
|
if (!startsWithMarker) {
|
|
404
528
|
throw new Error(`Security Error: Comment on line ${item.line} contains invalid non-comment line: "${tcl}"`);
|
|
405
529
|
}
|
|
530
|
+
// Block-comment openers without a same-line closer enter block mode; subsequent lines are exempt from marker checks [ds]
|
|
406
531
|
if ((tcl.startsWith('/*') && !tcl.includes('*/')) || (tcl.startsWith('<!--') && !tcl.includes('-->'))) {
|
|
407
532
|
inBlock = true;
|
|
408
533
|
}
|
|
@@ -426,36 +551,50 @@ function parseAndValidate(textResponse, mode) {
|
|
|
426
551
|
* @param {string} mode - Documentation mode ('default', 'light', 'full', 'clean').
|
|
427
552
|
* @returns {Promise<Array>} Array of validated comment objects with global line numbers.
|
|
428
553
|
*/
|
|
554
|
+
/** [ds]
|
|
555
|
+
* Entry point: obtains AI-generated comments for an entire source file.
|
|
556
|
+
* Files under CHUNK_THRESHOLD are sent in a single request. Larger files are split into
|
|
557
|
+
* overlapping windows (CHUNK_SIZE with CHUNK_OVERLAP) so that cross-boundary context is
|
|
558
|
+
* preserved at chunk seams. Each chunk is processed with global line numbers baked into the
|
|
559
|
+
* prompt, then results are filtered to the chunk's own range and deduplicated by line —
|
|
560
|
+
* the dedup step is required because overlap means a comment may be returned by two chunks.
|
|
561
|
+
*/
|
|
429
562
|
async function getComments(code, language, config, mode = 'default') {
|
|
430
563
|
const lines = code.split(/\r?\n/);
|
|
564
|
+
const { size: CHUNK_SIZE, overlap: CHUNK_OVERLAP, threshold: CHUNK_THRESHOLD } = getChunkConfig(config);
|
|
565
|
+
|
|
566
|
+
// Prepend the file header to every non-first chunk as additional context, capped at 25 lines to bound prompt size [ds]
|
|
567
|
+
const contextLinesCount = Math.min(25, lines.length);
|
|
568
|
+
const contextSnippet = lines.slice(0, contextLinesCount).map((line, i) => `${i + 1}: ${line}`).join('\n');
|
|
431
569
|
|
|
432
|
-
// Small files: single-shot processing (no chunking overhead) [ds]
|
|
433
570
|
if (lines.length <= CHUNK_THRESHOLD) {
|
|
434
571
|
const numberedCode = lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
|
|
435
572
|
const prompt = buildPrompt(numberedCode, language, mode);
|
|
436
|
-
const textResponse = await
|
|
573
|
+
const [textResponse] = await runWithConcurrency([prompt], p => fetchFromProvider(p, config));
|
|
437
574
|
return parseAndValidate(textResponse, mode);
|
|
438
575
|
}
|
|
439
576
|
|
|
440
|
-
// Large files: slice into overlapping chunks with global line numbers [ds]
|
|
441
577
|
const chunks = [];
|
|
578
|
+
// Stride is (size - overlap) so consecutive windows share CHUNK_OVERLAP lines at their boundaries [ds]
|
|
442
579
|
for (let start = 0; start < lines.length; start += (CHUNK_SIZE - CHUNK_OVERLAP)) {
|
|
443
580
|
const end = Math.min(start + CHUNK_SIZE, lines.length);
|
|
444
581
|
chunks.push({ start, end });
|
|
445
582
|
if (end >= lines.length) break;
|
|
446
583
|
}
|
|
447
584
|
|
|
448
|
-
// Process chunks through the adaptive concurrency pool [ds]
|
|
449
585
|
const chunkResults = await runWithConcurrency(chunks, async (chunk) => {
|
|
450
586
|
const chunkLines = lines.slice(chunk.start, chunk.end);
|
|
587
|
+
// Line numbers in the prompt are absolute (1-indexed) so returned comments need no offset translation [ds]
|
|
451
588
|
const startLineNum = chunk.start + 1;
|
|
452
589
|
const numberedCode = chunkLines.map((line, i) => `${startLineNum + i}: ${line}`).join('\n');
|
|
453
|
-
const
|
|
590
|
+
const contextForChunk = chunk.start > 0 ? contextSnippet : '';
|
|
591
|
+
const prompt = buildPrompt(numberedCode, language, mode, contextForChunk);
|
|
454
592
|
const textResponse = await fetchFromProvider(prompt, config);
|
|
455
|
-
|
|
593
|
+
const parsed = parseAndValidate(textResponse, mode);
|
|
594
|
+
// Discard hallucinated lines outside this chunk's range; overlap duplicates are resolved during dedup below [ds]
|
|
595
|
+
return parsed.filter(c => c.line >= startLineNum && c.line < startLineNum + chunkLines.length);
|
|
456
596
|
});
|
|
457
597
|
|
|
458
|
-
// Merge and deduplicate: first comment wins for overlapping line numbers [ds]
|
|
459
598
|
const seenLines = new Set();
|
|
460
599
|
const allComments = [];
|
|
461
600
|
for (const chunkComments of chunkResults) {
|
|
@@ -470,4 +609,14 @@ async function getComments(code, language, config, mode = 'default') {
|
|
|
470
609
|
return allComments;
|
|
471
610
|
}
|
|
472
611
|
|
|
473
|
-
module.exports = {
|
|
612
|
+
module.exports = {
|
|
613
|
+
getComments,
|
|
614
|
+
runWithConcurrency,
|
|
615
|
+
resetConcurrency,
|
|
616
|
+
getChunkConfig,
|
|
617
|
+
PROVIDER_PROFILES,
|
|
618
|
+
setDiscoveredTierLimit,
|
|
619
|
+
CHUNK_SIZE,
|
|
620
|
+
CHUNK_OVERLAP,
|
|
621
|
+
CHUNK_THRESHOLD
|
|
622
|
+
};
|
package/package.json
CHANGED