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/lib/llm.js CHANGED
@@ -1,15 +1,14 @@
1
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.
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
- // Surface 429 rate limits immediately so the adaptive controller can react [ds]
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 Constants ───────────────────────────────────────────────────────
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
- // Shared mutable state: starts at the requested concurrency and drops to 1 on 429 [ds]
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
- 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));
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
- const tracked = task.then(
116
- val => { executing.delete(tracked); return val; },
117
- err => { executing.delete(tracked); throw err; }
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
- function buildPrompt(numberedCode, language, mode) {
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
- // Re-throw rate limit errors so the concurrency controller can catch them [ds]
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
- if (config.provider === 'groq') {
301
- reqBody.max_tokens = 1000;
302
- } else {
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 fetchFromProvider(prompt, config);
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 prompt = buildPrompt(numberedCode, language, mode);
590
+ const contextForChunk = chunk.start > 0 ? contextSnippet : '';
591
+ const prompt = buildPrompt(numberedCode, language, mode, contextForChunk);
454
592
  const textResponse = await fetchFromProvider(prompt, config);
455
- return parseAndValidate(textResponse, mode);
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 = { getComments, runWithConcurrency, resetConcurrency, CHUNK_SIZE, CHUNK_OVERLAP, CHUNK_THRESHOLD };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
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",