devsplain 2.3.1 → 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 CHANGED
@@ -113,6 +113,8 @@ devsplain <file-or-directory> [options]
113
113
  | `--model <name>` | Temporary one-off override for the model name for this command run only. |
114
114
  | `--api-key <key>` | Temporary one-off override for the API key for this command run only. |
115
115
  | `--base-url <url>` | Temporary one-off override for the API base URL for this command run only. |
116
+ | `--concurrency <num>` | Temporary one-off override for file concurrency level (1–5, default: 2). |
117
+ | `--chunk-size <lines>` | Temporary one-off override for chunk line size (e.g. 200, 600, 1000). |
116
118
  | `--config` | Relaunches the configuration setup wizard. |
117
119
  | `--setup-hook` | Installs Git pre-commit and post-commit hooks in the repository. |
118
120
  | `--help, -h` | Displays the help menu. |
package/bin/cli.js CHANGED
@@ -10,7 +10,10 @@ const { execSync } = require('child_process');
10
10
  let rl;
11
11
  let askQuestion;
12
12
 
13
- // Checks if the Git repository is dirty [ds]
13
+ /** [ds]
14
+ * Determines if the git working tree has uncommitted changes.
15
+ * Silently returns false if not in a git repo or git is unavailable.
16
+ */
14
17
  function isGitDirty() {
15
18
  try {
16
19
  const gitDir = execSync('git rev-parse --is-inside-work-tree', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
@@ -23,13 +26,21 @@ function isGitDirty() {
23
26
  return false;
24
27
  }
25
28
 
26
- // Determines if a line is inside a string literal [ds]
29
+ /** [ds]
30
+ * Determines whether a given line index resides inside a string literal or comment
31
+ * block for a given file extension. This is a hand-written heuristic lexer used to
32
+ * avoid mis-attributing comment insertions that land inside multi-line strings or
33
+ * block comments. It intentionally supports a wide menu of languages (Python,
34
+ * HTML-family, Rust/Swift nested block comments, C++ raw strings, JS/TS regex literals).
35
+ */
27
36
  function isLineInsideString(lines, targetLineIndex, ext = '') {
37
+ // Language-family flags derived from file extension drive all downstream branching. [ds]
28
38
  const isPython = ext.toLowerCase() === '.py';
29
39
  const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
30
40
  const isRustOrSwift = ['.rs', '.swift'].includes(ext.toLowerCase());
31
41
  const isCpp = ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp'].includes(ext.toLowerCase());
32
42
  const isJS = ['.js', '.jsx', '.ts', '.tsx'].includes(ext.toLowerCase());
43
+ // Tracks lexical context across the entire prefix of the file up to targetLineIndex. [ds]
33
44
  let inBacktick = false;
34
45
  let inTripleDouble = false;
35
46
  let inTripleSingle = false;
@@ -41,10 +52,12 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
41
52
  let inCppRawString = false;
42
53
  let cppRawDelimiter = '';
43
54
  let inRegex = false;
55
+ // Scan every prior line character-by-character, carrying state forward. [ds]
44
56
  for (let i = 0; i < targetLineIndex; i++) {
45
57
  const line = lines[i];
46
58
  let j = 0;
47
59
  while (j < line.length) {
60
+ // Inside /* */ comments: Rust/Swift allow nesting, so we track depth. [ds]
48
61
  if (inBlockJS) {
49
62
  if (line.slice(j, j + 2) === '/*') {
50
63
  if (isRustOrSwift) blockDepthJS++;
@@ -64,6 +77,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
64
77
  j++;
65
78
  continue;
66
79
  }
80
+ // C++ raw strings (R"delim(...)delim") suppress all quote/comment parsing until terminator. [ds]
67
81
  if (inCppRawString) {
68
82
  if (line.slice(j, j + 2 + cppRawDelimiter.length) === ')' + cppRawDelimiter + '"') {
69
83
  inCppRawString = false;
@@ -73,6 +87,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
73
87
  j++;
74
88
  continue;
75
89
  }
90
+ // JS regex literal: must count preceding backslashes to detect escaped slashes. [ds]
76
91
  if (inRegex) {
77
92
  let escaped = false;
78
93
  let k = j - 1;
@@ -95,10 +110,12 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
95
110
  j++;
96
111
  continue;
97
112
  }
113
+ // Comment detection runs BEFORE quote toggling so comment markers inside strings are not misread. [ds]
98
114
  // Check if comment starts (skip processing quotes if we are entering a comment)
99
115
  if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
100
116
  if (isPython) {
101
117
  if (line[j] === '#') {
118
+ // Python comment: bail out of rest of the line entirely. [ds]
102
119
  break; // Ignore rest of line
103
120
  }
104
121
  } else if (isHTML) {
@@ -134,6 +151,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
134
151
  if (ext.toLowerCase() === '.php' && line[j] === '#') {
135
152
  break; // Ignore rest of line
136
153
  }
154
+ // Detect C++ raw string opener R"delim( — delimiter can be up to 16 chars, no whitespace/parens/backslash. [ds]
137
155
  if (isCpp && line[j] === 'R' && line[j+1] === '"') {
138
156
  const match = line.slice(j).match(/^R"([^()\\\s]{0,16})\(/);
139
157
  if (match) {
@@ -143,6 +161,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
143
161
  continue;
144
162
  }
145
163
  }
164
+ // Disambiguate '/' as regex-literal vs division by inspecting the prior non-whitespace token. [ds]
146
165
  if (isJS && line[j] === '/') {
147
166
  let k = j - 1;
148
167
  while (k >= 0 && /\s/.test(line[k])) k--;
@@ -151,6 +170,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
151
170
  isRegex = true;
152
171
  } else {
153
172
  const prevChar = line[k];
173
+ // Slash after these punctuators/keywords is a regex literal, not division. [ds]
154
174
  if (/[=({\[:,;!+*&|?<>-]/.test(prevChar)) {
155
175
  isRegex = true;
156
176
  } else {
@@ -169,6 +189,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
169
189
  }
170
190
  }
171
191
  }
192
+ // Python triple-quoted strings may span multiple lines and toggle on triple delimiters. [ds]
172
193
  if (isPython) {
173
194
  if (!inTripleSingle && !inSingle && !inDouble) {
174
195
  if (line.slice(j, j + 3) === '"""') {
@@ -185,12 +206,14 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
185
206
  }
186
207
  }
187
208
  }
209
+ // Backtick template literals are exclusive to non-Python languages; honor escape sequences. [ds]
188
210
  if (!inTripleSingle && !inTripleDouble) {
189
211
  if (!isPython) {
190
212
  if (!inSingle && !inDouble) {
191
213
  if (line[j] === '`') {
192
214
  let escaped = false;
193
215
  let k = j - 1;
216
+ // Count consecutive backslashes by toggling; odd count means escaped [ds]
194
217
  while (k >= 0 && line[k] === '\\') {
195
218
  escaped = !escaped;
196
219
  k--;
@@ -227,6 +250,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
227
250
  }
228
251
  j++;
229
252
  }
253
+ // C-family and similar languages reset string/quote state per line (unlike backtick templates or Python triple-quotes) [ds]
230
254
  const resetsAtLineEnd = ['.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.cs', '.go', '.swift', '.kt', '.dart'].includes(ext.toLowerCase());
231
255
  if (resetsAtLineEnd) {
232
256
  inSingle = false;
@@ -237,7 +261,18 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
237
261
  return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble || inCppRawString || inRegex;
238
262
  }
239
263
 
240
- // Analyzes comments in the given lines of code [ds]
264
+ /** [ds]
265
+ * Analyzes source lines to determine if the file ends inside an unterminated
266
+ * string, template literal, raw string, regex, or block comment.
267
+ *
268
+ * Handles language-specific quoting rules: Python triple-quotes, C++ raw
269
+ * strings, JS template literals, JS regex literals, HTML block comments,
270
+ * and Rust/Swift nested block comments.
271
+ *
272
+ * @param {string[]} lines - Source lines to scan
273
+ * @param {string} ext - File extension used to select lexer rules
274
+ * @returns {boolean} True if the file ends with an unterminated token
275
+ */
241
276
  function analyzeComments(lines, ext = '') {
242
277
  const isPython = ext.toLowerCase() === '.py';
243
278
  const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
@@ -264,6 +299,7 @@ function analyzeComments(lines, ext = '') {
264
299
  while (j < line.length) {
265
300
  if (inBlockJS) {
266
301
  if (line.slice(j, j + 2) === '/*') {
302
+ // Rust and Swift allow nested /* */ block comments; track depth instead of a boolean [ds]
267
303
  if (isRustOrSwift) blockDepthJS++;
268
304
  j += 2;
269
305
  continue;
@@ -282,6 +318,7 @@ function analyzeComments(lines, ext = '') {
282
318
  continue;
283
319
  }
284
320
  if (inCppRawString) {
321
+ // C++ raw string terminator is )"delimiter" — delimiter must be matched exactly [ds]
285
322
  if (line.slice(j, j + 2 + cppRawDelimiter.length) === ')' + cppRawDelimiter + '"') {
286
323
  inCppRawString = false;
287
324
  j += 2 + cppRawDelimiter.length;
@@ -336,6 +373,7 @@ function analyzeComments(lines, ext = '') {
336
373
  break;
337
374
  }
338
375
  } else {
376
+ // Shell and Ruby use '#' for line comments [ds]
339
377
  const isShellOrRuby = ['.sh', '.rb'].includes(ext.toLowerCase());
340
378
  if (isShellOrRuby) {
341
379
  if (line[j] === '#') {
@@ -358,6 +396,7 @@ function analyzeComments(lines, ext = '') {
358
396
  commentStartIndex = j;
359
397
  break;
360
398
  }
399
+ // C++11 raw string literal: R"delim(...)delim" where delim is up to 16 non-special chars [ds]
361
400
  if (isCpp && line[j] === 'R' && line[j+1] === '"') {
362
401
  const match = line.slice(j).match(/^R"([^()\\\s]{0,16})\(/);
363
402
  if (match) {
@@ -367,6 +406,7 @@ function analyzeComments(lines, ext = '') {
367
406
  continue;
368
407
  }
369
408
  }
409
+ // Distinguish regex literal /.../ from division operator by inspecting the preceding non-whitespace token [ds]
370
410
  if (isJS && line[j] === '/') {
371
411
  let k = j - 1;
372
412
  while (k >= 0 && /\s/.test(line[k])) k--;
@@ -384,6 +424,7 @@ function analyzeComments(lines, ext = '') {
384
424
  }
385
425
  }
386
426
  }
427
+ // Enter regex mode; the closing '/' will be matched during subsequent char scanning. [ds]
387
428
  if (isRegex) {
388
429
  inRegex = true;
389
430
  j++;
@@ -395,6 +436,7 @@ function analyzeComments(lines, ext = '') {
395
436
  }
396
437
  if (isPython) {
397
438
  if (!inTripleSingle && !inSingle && !inDouble) {
439
+ // Triple-quoted Python strings can span lines; only enter/exit when not already inside another quote type to avoid premature termination. [ds]
398
440
  if (line.slice(j, j + 3) === '"""') {
399
441
  inTripleDouble = !inTripleDouble;
400
442
  j += 3;
@@ -409,6 +451,7 @@ function analyzeComments(lines, ext = '') {
409
451
  }
410
452
  }
411
453
  }
454
+ // Attempts to detect escaped quote characters (backslash-balanced, ignoring double escaping). [ds]
412
455
  if (!inTripleSingle && !inTripleDouble) {
413
456
  if (!isPython) {
414
457
  if (!inSingle && !inDouble) {
@@ -452,12 +495,14 @@ function analyzeComments(lines, ext = '') {
452
495
  }
453
496
  j++;
454
497
  }
498
+ // C-family languages reset string/comment state at each newline (no line-spanning single/double quotes or regex). Python and JS template literals are excluded to allow multi-line constructs. [ds]
455
499
  const resetsAtLineEnd = ['.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.cs', '.go', '.swift', '.kt', '.dart'].includes(ext.toLowerCase());
456
500
  if (resetsAtLineEnd) {
457
501
  inSingle = false;
458
502
  inDouble = false;
459
503
  inRegex = false;
460
504
  }
505
+ // A line is 'entirely inside a block' if we're at a block start with no trailing code, or fully wrapped by a block comment with no code before/after. [ds]
461
506
  const isEntirelyInsideBlock = isInsideBlockStart && (inBlockJS || inBlockHTML || (commentStartIndex === -1));
462
507
  let isPureComment = false;
463
508
  if (isEntirelyInsideBlock) {
@@ -479,11 +524,17 @@ function analyzeComments(lines, ext = '') {
479
524
  return analysis;
480
525
  }
481
526
 
482
- // Splices comments into the provided data [ds]
527
+ /** [ds]
528
+ * Rewrites source by applying user-supplied comments.
529
+ * Handles 'default' (insert annotations), 'prune' (strip inline comments only),
530
+ * and 'clean' (remove auto-generated [ds] doc blocks).
531
+ * Preserves the original line-ending style (CRLF vs LF).
532
+ */
483
533
  function spliceComments(data, comments, mode = 'default', ext = '') {
484
534
  const hasCRLF = data.includes('\r\n');
485
535
  const lineEnding = hasCRLF ? '\r\n' : '\n';
486
536
  const originalLines = data.split(/\r?\n/);
537
+ // Process in descending line order so earlier insertions don't invalidate line numbers of subsequent edits. [ds]
487
538
  const sortedComments = [...comments].sort((a, b) => b.line - a.line);
488
539
  const validComments = sortedComments.filter(c => c.line >= 1 && c.line <= originalLines.length + 1);
489
540
 
@@ -491,9 +542,11 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
491
542
  let analysis = null;
492
543
  let dsBlocks = new Set();
493
544
 
545
+ // 'clean' and 'prune' modes both require lexical analysis to know which lines/regions consist solely of comments. [ds]
494
546
  if (mode === 'clean' || mode === 'prune') {
495
547
  analysis = analyzeComments(originalLines, ext);
496
548
  const finalDeletions = new Set();
549
+ // Pre-scan for [ds]-tagged comment blocks (including the line preceding the block) so entire generated docs can be removed atomically in 'clean' mode. [ds]
497
550
  if (mode === 'clean') {
498
551
  let i = 0;
499
552
  while (i < originalLines.length) {
@@ -524,6 +577,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
524
577
  const lineStr = originalLines[i];
525
578
  const lineAnalysis = analysis[i];
526
579
 
580
+ // Preserve shebang lines unconditionally across all modes. [ds]
527
581
  if (lineStr.trim().startsWith('#!')) {
528
582
  continue;
529
583
  }
@@ -542,6 +596,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
542
596
  newText = lineStr.slice(0, idx) + remainder.slice(endIdx + 2);
543
597
  }
544
598
  } else if (remainder.startsWith('<!--')) {
599
+ // Handle closing of inline block comment: slice out '-->' terminator to preserve any trailing code after the comment [ds]
545
600
  const endIdx = remainder.indexOf('-->');
546
601
  if (endIdx !== -1) {
547
602
  newText = lineStr.slice(0, idx) + remainder.slice(endIdx + 3);
@@ -550,10 +605,12 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
550
605
 
551
606
  annotated[i].text = newText.trimEnd();
552
607
  }
608
+ // Clean mode removes comments only when tagged with [ds] marker (inline or inside a [ds] block), unlike prune which removes all comments [ds]
553
609
  } else if (mode === 'clean') {
554
610
  const isDsBlockLine = dsBlocks.has(lineNum);
555
611
  const hasDsInline = lineStr.includes('[ds]');
556
612
 
613
+ // Pure-comment lines are dropped entirely; partial (inline) comments require surgical extraction of the surrounding code [ds]
557
614
  if (lineAnalysis.isPureComment) {
558
615
  if (isDsBlockLine || hasDsInline) {
559
616
  finalDeletions.add(lineNum);
@@ -564,6 +621,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
564
621
  const remainder = lineStr.slice(idx);
565
622
  let newText = lineStr.slice(0, idx).trimEnd();
566
623
 
624
+ // Block comment: reattach any content following the closing delimiter since it may be live code on the same line [ds]
567
625
  if (remainder.startsWith('/*')) {
568
626
  const endIdx = remainder.indexOf('*/');
569
627
  if (endIdx !== -1) {
@@ -583,6 +641,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
583
641
  }
584
642
 
585
643
 
644
+ // Deletions are collected into a Set to dedupe (a line may be flagged by multiple passes), then sorted descending so splice offsets remain valid [ds]
586
645
  for (const c of validComments) {
587
646
  const lineIdx = c.line - 1;
588
647
  if (lineIdx >= 0 && lineIdx < originalLines.length) {
@@ -598,10 +657,12 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
598
657
  const trimmedLine = targetLine.trim();
599
658
 
600
659
  const lineAnalysis = analysis[lineNum - 1];
660
+ // Never strip shebang lines - removing them would break the executable script [ds]
601
661
  if (trimmedLine.startsWith('#!')) {
602
662
  continue;
603
663
  }
604
664
 
665
+ // Defense-in-depth guard: before deleting, re-verify the line actually looks like a comment or blank. Prevents catastrophic data loss if upstream analysis is buggy [ds]
605
666
  const isCommentLine =
606
667
  lineAnalysis.isInsideBlock ||
607
668
  lineAnalysis.isPureComment ||
@@ -622,8 +683,10 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
622
683
 
623
684
  annotated.splice(lineNum - 1, 1);
624
685
  }
686
+ // Insertion mode: for prune/annotate-style operations we splice new comment blocks into the source [ds]
625
687
  } else {
626
688
  for (const c of validComments) {
689
+ // Skip insertion if the target line offset falls inside a string literal, which would corrupt the string [ds]
627
690
  if (isLineInsideString(originalLines, c.line - 1, ext)) {
628
691
  console.warn(`[devsplain] Skipping comment insertion at line ${c.line} to avoid string literal corruption.`);
629
692
  continue;
@@ -637,6 +700,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
637
700
  let trimmed = line.trimStart();
638
701
  if (!trimmed) return '';
639
702
 
703
+ // Classify comment style to know where to append the [ds] marker: line comments get it at EOL, block comments get it before the closing delimiter [ds]
640
704
  const isSingleLine = trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('--');
641
705
  const isBlockEnd = trimmed.endsWith('*/') || trimmed.endsWith('-->');
642
706
 
@@ -644,12 +708,14 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
644
708
  trimmed = trimmed + ' [ds]';
645
709
  } else if (idx === 0) {
646
710
  if (isBlockEnd) {
711
+ // Place [ds] marker just before the block-close delimiter so the comment remains syntactically valid [ds]
647
712
  trimmed = trimmed.replace(/(\*\/|-->)$/, '[ds] $1');
648
713
  } else {
649
714
  trimmed = trimmed + ' [ds]';
650
715
  }
651
716
  }
652
717
 
718
+ // JSDoc-style continuation lines (' * ...') need an extra leading space to align the asterisk under the opening '/**' [ds]
653
719
  if (trimmed.startsWith('*') && !trimmed.startsWith('*/') && !trimmed.startsWith('/*')) {
654
720
  return indentation + ' ' + trimmed;
655
721
  }
@@ -661,16 +727,18 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
661
727
  }
662
728
  }
663
729
 
730
+ // Split annotated stream back into original-indexed lines vs newly-inserted lines so we can validate both independently [ds]
664
731
  const filtered = annotated.filter(line => line.originalIndex !== -1);
665
732
  const filteredText = filtered.map(line => line.text);
666
733
  const filteredIndices = filtered.map(line => line.originalIndex);
667
734
 
668
- // Validate that all inserted lines (originalIndex === -1) are valid comments or empty lines [ds]
735
+ // Validate that every inserted line is a genuine comment (or continuation of a block comment), never stray executable code [ds]
669
736
  const insertedLines = annotated.filter(line => line.originalIndex === -1);
670
737
  let inInsertedBlock = false;
671
738
  for (const item of insertedLines) {
672
739
  const trimmed = item.text.trim();
673
740
  if (!trimmed) continue;
741
+ // Track whether we are inside a multi-line block comment so inner lines are not individually required to start with a comment token [ds]
674
742
  if (inInsertedBlock) {
675
743
  if (trimmed.includes('*/') || trimmed.includes('-->')) {
676
744
  inInsertedBlock = false;
@@ -687,22 +755,26 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
687
755
  if (!isValidComment) {
688
756
  throw new Error(`Safety Assertion Failed: Refused to insert non-comment code: "${trimmed}"`);
689
757
  }
758
+ // An opening block-comment delimiter without a matching close on the same line flips us into 'inside block' mode [ds]
690
759
  if ((trimmed.startsWith('/*') && !trimmed.includes('*/')) || (trimmed.startsWith('<!--') && !trimmed.includes('-->'))) {
691
760
  inInsertedBlock = true;
692
761
  }
693
762
  }
694
763
 
764
+ // Safety assertion: verify the spliced output is equivalent to the original minus the removed comments. Prevents silent corruption of source code. [ds]
695
765
  const textEqual = filteredText.every((text, idx) => {
696
766
  const origIdx = filteredIndices[idx];
697
767
  const originalLine = originalLines[origIdx];
698
768
  if (text === originalLine) {
699
769
  return true;
700
770
  }
771
+ // In clean/prune modes, allow text to differ from the original where a [ds] comment was deliberately stripped from an otherwise-live line [ds]
701
772
  if ((mode === 'clean' || mode === 'prune') && analysis) {
702
773
  const lineAnalysis = analysis[origIdx];
703
774
  if (lineAnalysis && lineAnalysis.commentStartIndex !== -1 && !lineAnalysis.isPureComment) {
704
775
  const isDsBlockLine = dsBlocks.has(origIdx + 1);
705
776
  const hasDsInline = originalLine.includes('[ds]');
777
+ // Reconstruct expected output after removing the tagged comment and compare - any drift indicates the splice damaged real code [ds]
706
778
  if (mode === 'prune' || (mode === 'clean' && (hasDsInline || isDsBlockLine))) {
707
779
  const idx = lineAnalysis.commentStartIndex;
708
780
  const remainder = originalLine.slice(idx);
@@ -730,6 +802,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
730
802
  return false;
731
803
  });
732
804
 
805
+ // Ensure original indices remain strictly increasing; a duplicate or reorder would signal that splice offsets were miscomputed [ds]
733
806
  let indicesSequential = true;
734
807
  for (let i = 1; i < filteredIndices.length; i++) {
735
808
  if (filteredIndices[i] <= filteredIndices[i - 1]) {
@@ -738,6 +811,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
738
811
  }
739
812
  }
740
813
 
814
+ // Hard failure if either content drifted or ordering broke - better to abort than to write corrupted files [ds]
741
815
  if (!textEqual || !indicesSequential) {
742
816
  throw new Error("Safety Assertion Failed: Spliced code does not match original code minus comments!");
743
817
  }
@@ -745,7 +819,11 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
745
819
  return annotated.map(line => line.text).join(lineEnding);
746
820
  }
747
821
 
748
- // Runs the CLI interface for the application [ds]
822
+ /** [ds]
823
+ * Entry point for the CLI. Parses argv, merges CLI overrides with saved config, and dispatches to the appropriate
824
+ * command (help/version/config/hook install/remove) or the main comment-generation pipeline.
825
+ * Sets up a shared readline interface used by `askQuestion` for interactive prompts.
826
+ */
749
827
  async function runCLI() {
750
828
  rl = readline.createInterface({ input: process.stdin, output: process.stdout });
751
829
  askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
@@ -770,6 +848,8 @@ Options:
770
848
  --model <name> Override AI model name
771
849
  --api-key <key> Override API key for the provider
772
850
  --base-url <url> Override base URL for custom APIs
851
+ --concurrency <num> Override file concurrency level (1-5, default: 2)
852
+ --chunk-size <num> Override chunk line size (e.g. 200, 600, 1000)
773
853
  --config Force run the configuration setup wizard
774
854
  --setup-hook Install Git pre-commit and post-commit hooks in repository
775
855
  --remove-hook Remove devsplain-installed Git hooks from repository
@@ -808,6 +888,7 @@ Options:
808
888
  return;
809
889
  }
810
890
 
891
+ /** Reads the value immediately following a `--flag` in argv, or null if absent/trailing. [ds] */
811
892
  const getArgValue = (flag) => {
812
893
  const index = args.indexOf(flag);
813
894
  if (index !== -1 && index + 1 < args.length) {
@@ -816,8 +897,9 @@ Options:
816
897
  return null;
817
898
  };
818
899
 
900
+ // Identify the positional file path by skipping over flags and their argument values (flagKeys consume the next token). [ds]
819
901
  let filepath = '.';
820
- const flagKeys = ['--provider', '--model', '--api-key', '--base-url', '--concurrency'];
902
+ const flagKeys = ['--provider', '--model', '--api-key', '--base-url', '--concurrency', '--chunk-size'];
821
903
  for (let i = 0; i < args.length; i++) {
822
904
  const arg = args[i];
823
905
  if (arg.startsWith('--')) {
@@ -846,6 +928,7 @@ Options:
846
928
  const hasOverwriteFlag = args.includes('--overwrite');
847
929
  const hasKeepFlag = args.includes('--keep');
848
930
 
931
+ // Guard against modifying an uncommitted working tree. Skipped in tests and during dry-runs since nothing is written. [ds]
849
932
  if (process.env.NODE_ENV !== 'test' && isGitDirty() && !isForce && !isDryRun) {
850
933
  console.error("Error: Git working tree is dirty. Please commit or stash your changes, or use --force to bypass this check.");
851
934
  rl.close();
@@ -859,6 +942,7 @@ Options:
859
942
  const cliApiKey = getArgValue('--api-key');
860
943
  const cliBaseUrl = getArgValue('--base-url');
861
944
 
945
+ // When the user overrides the provider via CLI, backfill sensible default model and base URL so partial overrides still work. [ds]
862
946
  if (cliProvider) {
863
947
  config.provider = cliProvider;
864
948
  if (!cliModel) {
@@ -877,15 +961,25 @@ Options:
877
961
 
878
962
  const isOverwrite = (hasOverwriteFlag || config.autoPrune) && !hasKeepFlag;
879
963
 
880
- // Parse --concurrency flag (default: 2, max: 5, min: 1) [ds]
964
+ // Clamp concurrency to the 1-5 range; default to 2 to avoid hammering provider rate limits. [ds]
881
965
  const cliConcurrency = parseInt(getArgValue('--concurrency'), 10);
882
966
  const concurrencyLevel = (cliConcurrency && cliConcurrency >= 1 && cliConcurrency <= 5) ? cliConcurrency : 2;
883
967
 
968
+ // Derive chunk threshold/overlap from the requested chunk size so large files split predictably without cutting mid-function. [ds]
969
+ const cliChunkSize = parseInt(getArgValue('--chunk-size'), 10);
970
+ if (cliChunkSize && cliChunkSize >= 50 && cliChunkSize <= 2000) {
971
+ config.chunkSize = cliChunkSize;
972
+ config.chunkThreshold = Math.round(cliChunkSize * 1.25);
973
+ config.chunkOverlap = Math.max(10, Math.round(cliChunkSize * 0.1));
974
+ }
975
+
976
+ // Load user-defined ignore patterns from `.devsplainignore`; silently ignore read/parse errors since the file is optional. [ds]
884
977
  let userIgnorePatterns = [];
885
978
  try {
886
979
  const ignorePath = path.join(process.cwd(), '.devsplainignore');
887
980
  if (fs.existsSync(ignorePath)) {
888
981
  const ignoreContent = fs.readFileSync(ignorePath, 'utf8');
982
+ // Strip trailing whitespace, drop blank lines and `#` comments to mirror .gitignore semantics. [ds]
889
983
  userIgnorePatterns = ignoreContent.split(/\r?\n/)
890
984
  .map(line => line.trim())
891
985
  .filter(line => line && !line.startsWith('#'));
@@ -901,10 +995,17 @@ Options:
901
995
  ];
902
996
  const allIgnored = [...defaultIgnoredFolders, ...userIgnorePatterns];
903
997
 
998
+ /** [ds]
999
+ * Returns true if `targetPath` matches any ignored folder name, glob suffix, or is nested under an ignored directory.
1000
+ * Compares both the basename and the cwd-relative path so patterns work whether matched at any depth or rooted.
1001
+ */
904
1002
  function isPathIgnored(targetPath) {
905
1003
  const filename = path.basename(targetPath);
1004
+ // Normalize Windows backslashes to forward slashes for consistent pattern matching across platforms. [ds]
906
1005
  const relPath = path.relative(process.cwd(), targetPath).replace(/\\/g, '/');
1006
+ // Trim trailing path separators so `node_modules/` also matches the bare directory name. [ds]
907
1007
  for (const pattern of allIgnored) {
1008
+ // Normalize trailing separators so "foo/" and "foo" compare equal [ds]
908
1009
  const cleanPattern = pattern.replace(/\/$/, '').replace(/\\$/, '');
909
1010
  if (filename === cleanPattern || relPath === cleanPattern || relPath.startsWith(cleanPattern + '/')) return true;
910
1011
  if (pattern.startsWith('*') && filename.endsWith(pattern.slice(1))) return true;
@@ -918,7 +1019,11 @@ Options:
918
1019
  '.swift', '.kt', '.dart', '.sh', '.sql'
919
1020
  ];
920
1021
 
921
- // Separate file discovery from processing for concurrency support [ds]
1022
+ /** [ds]
1023
+ * Recursively walks the filesystem from targetPath, returning a flat list of
1024
+ * source files whose extensions are in validExtensions and that are non-empty.
1025
+ * Empty files are skipped because there is nothing to comment on.
1026
+ */
922
1027
  function collectFiles(targetPath) {
923
1028
  const collected = [];
924
1029
  const stats = fs.statSync(targetPath);
@@ -932,6 +1037,7 @@ Options:
932
1037
  collected.push(...collectFiles(path.join(targetPath, item)));
933
1038
  }
934
1039
  } else if (stats.isFile()) {
1040
+ // Extensions are compared case-insensitively (e.g. .JS matches .js) [ds]
935
1041
  const ext = path.extname(targetPath).toLowerCase();
936
1042
  if (!validExtensions.includes(ext)) return collected;
937
1043
 
@@ -945,6 +1051,16 @@ Options:
945
1051
  return collected;
946
1052
  }
947
1053
 
1054
+ /** [ds]
1055
+ * Runs the full pipeline on a single file: extract existing comments, generate
1056
+ * new comments via the AI, splice them back into the source, and persist atomically.
1057
+ *
1058
+ * In overwrite mode, an initial "prune" pass strips prior comments before they
1059
+ * are regenerated, otherwise previously inserted comments would be duplicated.
1060
+ *
1061
+ * Writes are atomic (temp file + rename) to avoid corrupting the source if the
1062
+ * process is interrupted mid-write.
1063
+ */
948
1064
  async function processSingleFile(targetPath) {
949
1065
  const filename = path.basename(targetPath);
950
1066
  const ext = path.extname(targetPath).toLowerCase();
@@ -954,7 +1070,9 @@ Options:
954
1070
  try {
955
1071
  let comments = [];
956
1072
  let commentedCode;
1073
+ // 'clean'/'prune' modes skip comment extraction since the output is comment-stripped [ds]
957
1074
  if (mode !== 'clean' && mode !== 'prune') {
1075
+ // Overwrite needs prune (not clean) so existing non-generated comments are preserved as context [ds]
958
1076
  const preProcessMode = isOverwrite ? 'prune' : 'clean';
959
1077
  const cleanData = spliceComments(data, [], preProcessMode, ext);
960
1078
  comments = await getComments(cleanData, filename, config, mode);
@@ -988,10 +1106,9 @@ Options:
988
1106
  }
989
1107
  }
990
1108
 
991
- // Collect all eligible files, then process with adaptive concurrency [ds]
992
1109
  const filesToProcess = collectFiles(filepath);
993
1110
 
994
- // Dry-run mode processes files serially to allow interactive prompts [ds]
1111
+ // Dry-run and clean operations must stay sequential because they prompt the user per file [ds]
995
1112
  if (isDryRun || mode === 'clean' || mode === 'prune') {
996
1113
  for (const file of filesToProcess) {
997
1114
  await processSingleFile(file);