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/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Unlike interactive AI editors, `devsplain` is designed for batch documentation p
|
|
|
18
18
|
- **Comment Preservation & Tagging**: AI-generated comments are tagged with `[ds]`. Your manually written comments are safe and will never be touched by the engine.
|
|
19
19
|
- **Local Deterministic Scrubber**: The `--clean` flag strips AI-generated `[ds]` comments locally using a deterministic lexical state machine—no LLM calls, API keys, or internet required.
|
|
20
20
|
- **Git Hook Automation**: Supports an automated two-commit Git hook workflow (`pre-commit` for quality, `post-commit` for auto-generated documentation commits) that prevents recursive commit loops.
|
|
21
|
-
- **Bring Your Own LLM**: Native setup wizard for Groq, Gemini, OpenAI, or any OpenAI-compatible API endpoint (like Ollama or LMStudio).
|
|
21
|
+
- **Bring Your Own LLM**: Native setup wizard for Groq, Gemini, OpenAI, Claude, DeepSeek, or any OpenAI-compatible API endpoint (like Ollama or LMStudio).
|
|
22
22
|
- **Exponential Backoff**: Resilient AI request handler that automatically retries rate-limited requests with exponential backoff.
|
|
23
23
|
- **Headless & Override Control**: Configure via environment variables or override global config settings dynamically on the fly with command-line flags.
|
|
24
24
|
|
|
@@ -109,10 +109,12 @@ devsplain <file-or-directory> [options]
|
|
|
109
109
|
| `--force` | Bypasses the safety block check that prevents running `devsplain` on a dirty Git working tree. |
|
|
110
110
|
| `--clean` | Scrubber mode. Deterministically removes only devsplain-generated comments tagged with `[ds]`, preserving your manual comments. |
|
|
111
111
|
| `--prune` | Destructive scrubber mode. Removes ALL comments and docstrings from source files, including your own manual comments. |
|
|
112
|
-
| `--provider <name>`| Temporary one-off override for the AI provider (`gemini`, `groq`, `openai`, `custom`) for this command run only (does not modify the saved config file). |
|
|
112
|
+
| `--provider <name>`| Temporary one-off override for the AI provider (`gemini`, `groq`, `openai`, `claude`, `deepseek`, `custom`) for this command run only (does not modify the saved config file). |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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));
|
|
@@ -766,10 +844,12 @@ Options:
|
|
|
766
844
|
--force Bypass the dirty Git tree safety check
|
|
767
845
|
--clean Scrub only devsplain-generated [ds] comments
|
|
768
846
|
--prune Destructively scrub ALL comments from files
|
|
769
|
-
--provider <name> Override AI provider (gemini, groq, openai, custom)
|
|
847
|
+
--provider <name> Override AI provider (gemini, groq, openai, claude, deepseek, custom)
|
|
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,13 +942,14 @@ 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) {
|
|
865
|
-
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : (cliProvider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile');
|
|
949
|
+
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : (cliProvider === 'claude' ? 'claude-3-5-sonnet-20240620' : (cliProvider === 'deepseek' ? 'deepseek-chat' : (cliProvider === 'openai' ? 'gpt-4o' : 'llama-3.3-70b-versatile')));
|
|
866
950
|
}
|
|
867
951
|
if (!cliBaseUrl) {
|
|
868
|
-
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : (cliProvider === 'claude' ? 'https://api.anthropic.com' : '')));
|
|
952
|
+
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : (cliProvider === 'claude' ? 'https://api.anthropic.com' : (cliProvider === 'deepseek' ? 'https://api.deepseek.com' : ''))));
|
|
869
953
|
}
|
|
870
954
|
}
|
|
871
955
|
if (cliModel) config.model = cliModel;
|
|
@@ -877,15 +961,25 @@ Options:
|
|
|
877
961
|
|
|
878
962
|
const isOverwrite = (hasOverwriteFlag || config.autoPrune) && !hasKeepFlag;
|
|
879
963
|
|
|
880
|
-
//
|
|
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
|
-
|
|
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
|
|
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);
|