devsplain 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,10 @@
1
1
  # devsplain
2
- An agent-agnostic CLI tool that adds JSDoc and inline comments using state-of-the-art LLMs while preserving non-comment source lines byte-for-byte through deterministic verification.
3
2
 
4
- ![devsplain demo](sample.gif)
3
+ **devsplain never rewrites executable code**—it's a single-shot CLI that adds JSDoc and inline comments across 22 languages using LLMs, preserving non-comment source lines byte-for-byte through deterministic verification.
5
4
 
6
- devsplain never rewrites executable code.
7
- If the original source cannot be reproduced exactly after comment insertion, the operation aborts.
5
+ ![devsplain demo](sample.gif)
8
6
 
9
- Unlike interactive AI editors, `devsplain` is a single-shot CLI designed for batch documentation passes, CI pipelines, and git hook automation—no agent overhead, no per-file confirmation loops.
7
+ Unlike interactive AI editors, `devsplain` is designed for batch documentation passes, CI pipelines, and git hook automation—no agent overhead, no per-file confirmation loops.
10
8
 
11
9
  > [!TIP]
12
10
  > **Built with devsplain**
@@ -16,7 +14,7 @@ Unlike interactive AI editors, `devsplain` is a single-shot CLI designed for bat
16
14
  ## Key Features
17
15
 
18
16
  - **Deterministic Code Integrity Verification**: Uses an index-preserving splicing engine. Your non-comment source lines are guaranteed to remain byte-for-byte identical after comment insertion.
19
- - **Multi-Language support**: Natively parses JavaScript, JSX, TypeScript, TSX, HTML, CSS, SCSS, Vue, Svelte, Python, Java, C, C++, C#, Go, Ruby, PHP, Rust, Swift, Kotlin, Dart, and Shell scripts.
17
+ - **Multi-Language support**: Works across JavaScript, JSX, TypeScript, TSX, HTML, CSS, SCSS, Vue, Svelte, Python, Java, C, C++, C#, Go, Ruby, PHP, Rust, Swift, Kotlin, Dart, and Shell scripts.
20
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.
21
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.
22
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.
@@ -46,14 +44,14 @@ The engine tracks lexical state across template strings, single quotes, double q
46
44
 
47
45
  ### The `[ds]` AI Tag Guarantee
48
46
  Every single comment generated by the LLM is forcibly prefixed with a `[ds]` tag (e.g., `// [ds] This function handles...`).
49
- This guarantees that the local `devsplain` lexer can mathematically differentiate between your human-written manual comments and the AI-generated comments.
47
+ This guarantees that the local `devsplain` lexer can lexically distinguish between your human-written manual comments and the AI-generated comments.
50
48
  When you run the `--clean` command, the lexer looks specifically for the `[ds]` prefix and surgically removes only the AI-generated comments, safely preserving 100% of your manual documentation.
51
49
 
52
50
  ### Why Not AST Verification?
53
51
 
54
- AST verification would require language-specific parser dependencies for every supported language.
52
+ AST parsers are language-specific. Supporting 22 languages would require 22 native parser dependencies, which defeats the zero-dependency architecture entirely.
55
53
 
56
- `devsplain` instead uses deterministic source-preservation verification:
54
+ The line-splicing + round-trip diff approach achieves equivalent guarantees without any of that bloat. This is not a compromise—it's the right answer for this tool's constraints:
57
55
 
58
56
  1. Original source is loaded.
59
57
  2. Comments are inserted.
@@ -178,7 +176,9 @@ SKIP_DEVSPLAIN=1 git commit -m "my commit message"
178
176
  ```
179
177
 
180
178
  ### Forcing or Skipping Auto-Prune
181
- If you have configured `autoPrune` (aggressive overwrite) in your `~/.devsplainrc` but want to change the behavior for a specific commit:
179
+ (If `autoPrune: true` is set in your `~/.devsplainrc` via the `--config` wizard, the hook scrubs all existing comments before regenerating them.)
180
+
181
+ If you want to change this behavior for a specific commit:
182
182
  - To **force an overwrite** (destroying all human/AI comments before generating new ones) regardless of your config:
183
183
  ```bash
184
184
  DS_OVER=1 git commit -m "overwrite docs"
package/bin/cli.js CHANGED
@@ -10,7 +10,7 @@ const { execSync } = require('child_process');
10
10
  let rl;
11
11
  let askQuestion;
12
12
 
13
- /** Checks if the Git working tree is dirty [ds] */
13
+ /** Checks if the Git repository has uncommitted changes [ds] */
14
14
  function isGitDirty() {
15
15
  try {
16
16
  const gitDir = execSync('git rev-parse --is-inside-work-tree', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
@@ -23,46 +23,178 @@ function isGitDirty() {
23
23
  return false;
24
24
  }
25
25
 
26
- /** Checks if a line is inside a string literal [ds] */
26
+ /** Checks if a line of code is inside a string literal [ds] */
27
27
  function isLineInsideString(lines, targetLineIndex, ext = '') {
28
28
  const isPython = ext.toLowerCase() === '.py';
29
+ const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
30
+ const isRustOrSwift = ['.rs', '.swift'].includes(ext.toLowerCase());
31
+ const isCpp = ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp'].includes(ext.toLowerCase());
32
+ const isJS = ['.js', '.jsx', '.ts', '.tsx'].includes(ext.toLowerCase());
29
33
  let inBacktick = false;
30
34
  let inTripleDouble = false;
31
35
  let inTripleSingle = false;
32
36
  let inSingle = false;
33
37
  let inDouble = false;
34
-
38
+ let inBlockJS = false;
39
+ let inBlockHTML = false;
40
+ let blockDepthJS = 0;
41
+ let inCppRawString = false;
42
+ let cppRawDelimiter = '';
43
+ let inRegex = false;
35
44
  for (let i = 0; i < targetLineIndex; i++) {
36
45
  const line = lines[i];
37
46
  let j = 0;
38
47
  while (j < line.length) {
48
+ if (inBlockJS) {
49
+ if (line.slice(j, j + 2) === '/*') {
50
+ if (isRustOrSwift) blockDepthJS++;
51
+ j += 2;
52
+ continue;
53
+ }
54
+ if (line.slice(j, j + 2) === '*/') {
55
+ if (isRustOrSwift && blockDepthJS > 1) {
56
+ blockDepthJS--;
57
+ } else {
58
+ inBlockJS = false;
59
+ blockDepthJS = 0;
60
+ }
61
+ j += 2;
62
+ continue;
63
+ }
64
+ j++;
65
+ continue;
66
+ }
67
+ if (inCppRawString) {
68
+ if (line.slice(j, j + 2 + cppRawDelimiter.length) === ')' + cppRawDelimiter + '"') {
69
+ inCppRawString = false;
70
+ j += 2 + cppRawDelimiter.length;
71
+ continue;
72
+ }
73
+ j++;
74
+ continue;
75
+ }
76
+ if (inRegex) {
77
+ let escaped = false;
78
+ let k = j - 1;
79
+ while (k >= 0 && line[k] === '\\') {
80
+ escaped = !escaped;
81
+ k--;
82
+ }
83
+ if (line[j] === '/' && !escaped) {
84
+ inRegex = false;
85
+ }
86
+ j++;
87
+ continue;
88
+ }
89
+ if (inBlockHTML) {
90
+ if (line.slice(j, j + 3) === '-->') {
91
+ inBlockHTML = false;
92
+ j += 3;
93
+ continue;
94
+ }
95
+ j++;
96
+ continue;
97
+ }
98
+ // Check if comment starts (skip processing quotes if we are entering a comment)
99
+ if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
100
+ if (isPython) {
101
+ if (line[j] === '#') {
102
+ break; // Ignore rest of line
103
+ }
104
+ } else if (isHTML) {
105
+ if (line.slice(j, j + 4) === '<!--') {
106
+ inBlockHTML = true;
107
+ j += 4;
108
+ continue;
109
+ }
110
+ if (line.slice(j, j + 2) === '/*') {
111
+ inBlockJS = true;
112
+ j += 2;
113
+ continue;
114
+ }
115
+ if (line.slice(j, j + 2) === '//') {
116
+ break; // Ignore rest of line
117
+ }
118
+ } else {
119
+ if (line.slice(j, j + 2) === '//') {
120
+ break; // Ignore rest of line
121
+ }
122
+ if (line.slice(j, j + 2) === '/*') {
123
+ inBlockJS = true;
124
+ blockDepthJS = 1;
125
+ j += 2;
126
+ continue;
127
+ }
128
+ const isShellOrRuby = ['.sh', '.rb', '.php'].includes(ext.toLowerCase());
129
+ if (isShellOrRuby && line[j] === '#') {
130
+ break; // Ignore rest of line
131
+ }
132
+ if (isCpp && line[j] === 'R' && line[j+1] === '"') {
133
+ const match = line.slice(j).match(/^R"([^()\\\s]{0,16})\(/);
134
+ if (match) {
135
+ cppRawDelimiter = match[1];
136
+ inCppRawString = true;
137
+ j += match[0].length;
138
+ continue;
139
+ }
140
+ }
141
+ if (isJS && line[j] === '/') {
142
+ let k = j - 1;
143
+ while (k >= 0 && /\s/.test(line[k])) k--;
144
+ let isRegex = false;
145
+ if (k < 0) {
146
+ isRegex = true;
147
+ } else {
148
+ const prevChar = line[k];
149
+ if (/[=({\[:,;!+*&|?<>-]/.test(prevChar)) {
150
+ isRegex = true;
151
+ } else {
152
+ const prefix = line.slice(0, k + 1);
153
+ if (/(?:return|typeof|yield|await|throw)\s*$/.test(prefix)) {
154
+ isRegex = true;
155
+ }
156
+ }
157
+ }
158
+ if (isRegex) {
159
+ inRegex = true;
160
+ j++;
161
+ continue;
162
+ }
163
+ }
164
+ }
165
+ }
39
166
  if (isPython) {
40
- if (!inTripleSingle) {
167
+ if (!inTripleSingle && !inSingle && !inDouble) {
41
168
  if (line.slice(j, j + 3) === '"""') {
42
169
  inTripleDouble = !inTripleDouble;
43
170
  j += 3;
44
171
  continue;
45
172
  }
46
173
  }
47
- if (!inTripleDouble) {
174
+ if (!inTripleDouble && !inSingle && !inDouble) {
48
175
  if (line.slice(j, j + 3) === "'''") {
49
176
  inTripleSingle = !inTripleSingle;
50
177
  j += 3;
51
178
  continue;
52
179
  }
53
180
  }
54
- } else {
55
- if (!inSingle && !inDouble && line[j] === '`') {
56
- let escaped = false;
57
- let k = j - 1;
58
- while (k >= 0 && line[k] === '\\') {
59
- escaped = !escaped;
60
- k--;
61
- }
62
- if (!escaped) {
63
- inBacktick = !inBacktick;
181
+ }
182
+ if (!inTripleSingle && !inTripleDouble) {
183
+ if (!isPython) {
184
+ if (!inSingle && !inDouble) {
185
+ if (line[j] === '`') {
186
+ let escaped = false;
187
+ let k = j - 1;
188
+ while (k >= 0 && line[k] === '\\') {
189
+ escaped = !escaped;
190
+ k--;
191
+ }
192
+ if (!escaped) {
193
+ inBacktick = !inBacktick;
194
+ }
64
195
  }
65
196
  }
197
+ }
66
198
  if (!inBacktick) {
67
199
  if (line[j] === '"' && !inSingle) {
68
200
  let escaped = false;
@@ -89,18 +221,22 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
89
221
  }
90
222
  j++;
91
223
  }
92
- if (!isPython) {
224
+ const resetsAtLineEnd = ['.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.cs', '.go', '.swift', '.kt', '.dart'].includes(ext.toLowerCase());
225
+ if (resetsAtLineEnd) {
93
226
  inSingle = false;
94
227
  inDouble = false;
95
228
  }
96
229
  }
97
- return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
230
+ return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble || inCppRawString || inRegex;
98
231
  }
99
232
 
100
- /** Analyzes comments in a list of lines [ds] */
233
+ /** Analyzes the comments in a given set of code lines [ds] */
101
234
  function analyzeComments(lines, ext = '') {
102
235
  const isPython = ext.toLowerCase() === '.py';
103
236
  const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
237
+ const isRustOrSwift = ['.rs', '.swift'].includes(ext.toLowerCase());
238
+ const isCpp = ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp'].includes(ext.toLowerCase());
239
+ const isJS = ['.js', '.jsx', '.ts', '.tsx'].includes(ext.toLowerCase());
104
240
  const analysis = [];
105
241
  let inBacktick = false;
106
242
  let inTripleDouble = false;
@@ -109,6 +245,10 @@ function analyzeComments(lines, ext = '') {
109
245
  let inDouble = false;
110
246
  let inBlockJS = false;
111
247
  let inBlockHTML = false;
248
+ let blockDepthJS = 0;
249
+ let inCppRawString = false;
250
+ let cppRawDelimiter = '';
251
+ let inRegex = false;
112
252
  for (let i = 0; i < lines.length; i++) {
113
253
  const line = lines[i];
114
254
  let commentStartIndex = -1;
@@ -116,14 +256,46 @@ function analyzeComments(lines, ext = '') {
116
256
  let j = 0;
117
257
  while (j < line.length) {
118
258
  if (inBlockJS) {
259
+ if (line.slice(j, j + 2) === '/*') {
260
+ if (isRustOrSwift) blockDepthJS++;
261
+ j += 2;
262
+ continue;
263
+ }
119
264
  if (line.slice(j, j + 2) === '*/') {
120
- inBlockJS = false;
265
+ if (isRustOrSwift && blockDepthJS > 1) {
266
+ blockDepthJS--;
267
+ } else {
268
+ inBlockJS = false;
269
+ blockDepthJS = 0;
270
+ }
121
271
  j += 2;
122
272
  continue;
123
273
  }
124
274
  j++;
125
275
  continue;
126
276
  }
277
+ if (inCppRawString) {
278
+ if (line.slice(j, j + 2 + cppRawDelimiter.length) === ')' + cppRawDelimiter + '"') {
279
+ inCppRawString = false;
280
+ j += 2 + cppRawDelimiter.length;
281
+ continue;
282
+ }
283
+ j++;
284
+ continue;
285
+ }
286
+ if (inRegex) {
287
+ let escaped = false;
288
+ let k = j - 1;
289
+ while (k >= 0 && line[k] === '\\') {
290
+ escaped = !escaped;
291
+ k--;
292
+ }
293
+ if (line[j] === '/' && !escaped) {
294
+ inRegex = false;
295
+ }
296
+ j++;
297
+ continue;
298
+ }
127
299
  if (inBlockHTML) {
128
300
  if (line.slice(j, j + 3) === '-->') {
129
301
  inBlockHTML = false;
@@ -139,16 +311,6 @@ function analyzeComments(lines, ext = '') {
139
311
  commentStartIndex = j;
140
312
  break;
141
313
  }
142
- if (line.slice(j, j + 2) === '/*') {
143
- commentStartIndex = j;
144
- inBlockJS = true;
145
- j += 2;
146
- continue;
147
- }
148
- if (line.slice(j, j + 2) === '//') {
149
- commentStartIndex = j;
150
- break;
151
- }
152
314
  } else if (isHTML) {
153
315
  if (line.slice(j, j + 4) === '<!--') {
154
316
  commentStartIndex = j;
@@ -174,33 +336,69 @@ function analyzeComments(lines, ext = '') {
174
336
  if (line.slice(j, j + 2) === '/*') {
175
337
  commentStartIndex = j;
176
338
  inBlockJS = true;
339
+ blockDepthJS = 1;
177
340
  j += 2;
178
341
  continue;
179
342
  }
180
- if (line[j] === '#') {
343
+ const isShellOrRuby = ['.sh', '.rb', '.php'].includes(ext.toLowerCase());
344
+ if (isShellOrRuby && line[j] === '#') {
181
345
  commentStartIndex = j;
182
346
  break;
183
347
  }
348
+ if (isCpp && line[j] === 'R' && line[j+1] === '"') {
349
+ const match = line.slice(j).match(/^R"([^()\\\s]{0,16})\(/);
350
+ if (match) {
351
+ cppRawDelimiter = match[1];
352
+ inCppRawString = true;
353
+ j += match[0].length;
354
+ continue;
355
+ }
356
+ }
357
+ if (isJS && line[j] === '/') {
358
+ let k = j - 1;
359
+ while (k >= 0 && /\s/.test(line[k])) k--;
360
+ let isRegex = false;
361
+ if (k < 0) {
362
+ isRegex = true;
363
+ } else {
364
+ const prevChar = line[k];
365
+ if (/[=({\[:,;!+*&|?<>-]/.test(prevChar)) {
366
+ isRegex = true;
367
+ } else {
368
+ const prefix = line.slice(0, k + 1);
369
+ if (/(?:return|typeof|yield|await|throw)\s*$/.test(prefix)) {
370
+ isRegex = true;
371
+ }
372
+ }
373
+ }
374
+ if (isRegex) {
375
+ inRegex = true;
376
+ j++;
377
+ continue;
378
+ }
379
+ }
184
380
  }
185
381
  }
186
382
  if (isPython) {
187
- if (!inTripleSingle) {
383
+ if (!inTripleSingle && !inSingle && !inDouble) {
188
384
  if (line.slice(j, j + 3) === '"""') {
189
385
  inTripleDouble = !inTripleDouble;
190
386
  j += 3;
191
387
  continue;
192
388
  }
193
389
  }
194
- if (!inTripleDouble) {
390
+ if (!inTripleDouble && !inSingle && !inDouble) {
195
391
  if (line.slice(j, j + 3) === "'''") {
196
392
  inTripleSingle = !inTripleSingle;
197
393
  j += 3;
198
394
  continue;
199
395
  }
200
396
  }
201
- } else {
202
- if (!inSingle && !inDouble) {
203
- if (line[j] === '`') {
397
+ }
398
+ if (!inTripleSingle && !inTripleDouble) {
399
+ if (!isPython) {
400
+ if (!inSingle && !inDouble) {
401
+ if (line[j] === '`') {
204
402
  let escaped = false;
205
403
  let k = j - 1;
206
404
  while (k >= 0 && line[k] === '\\') {
@@ -212,6 +410,7 @@ function analyzeComments(lines, ext = '') {
212
410
  }
213
411
  }
214
412
  }
413
+ }
215
414
  if (!inBacktick) {
216
415
  if (line[j] === '"' && !inSingle) {
217
416
  let escaped = false;
@@ -239,7 +438,8 @@ function analyzeComments(lines, ext = '') {
239
438
  }
240
439
  j++;
241
440
  }
242
- if (!isPython) {
441
+ const resetsAtLineEnd = ['.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.cs', '.go', '.swift', '.kt', '.dart'].includes(ext.toLowerCase());
442
+ if (resetsAtLineEnd) {
243
443
  inSingle = false;
244
444
  inDouble = false;
245
445
  }
@@ -264,7 +464,7 @@ function analyzeComments(lines, ext = '') {
264
464
  return analysis;
265
465
  }
266
466
 
267
- /** Splices comments into a list of lines [ds] */
467
+ /** Splices comments into the provided code data [ds] */
268
468
  function spliceComments(data, comments, mode = 'default', ext = '') {
269
469
  const hasCRLF = data.includes('\r\n');
270
470
  const lineEnding = hasCRLF ? '\r\n' : '\n';
@@ -286,7 +486,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
286
486
  let start = i;
287
487
  let end = i;
288
488
  while (end < originalLines.length && analysis[end].isInsideBlock) end++;
289
- let blockStart = start - 1;
489
+ let blockStart = Math.max(0, start - 1);
290
490
  let blockEnd = end - 1;
291
491
  let hasDs = false;
292
492
  for (let k = blockStart; k <= blockEnd; k++) {
@@ -600,8 +800,8 @@ Options:
600
800
  ];
601
801
  const allIgnored = [...defaultIgnoredFolders, ...userIgnorePatterns];
602
802
 
803
+ /** Checks if a path is ignored based on the ignore patterns [ds] */
603
804
  function isPathIgnored(targetPath) {
604
- // Checks if a path is ignored [ds]
605
805
  const filename = path.basename(targetPath);
606
806
  for (const pattern of allIgnored) {
607
807
  const cleanPattern = pattern.replace(/\/$/, '').replace(/\\$/, '');
@@ -611,7 +811,7 @@ Options:
611
811
  return false;
612
812
  }
613
813
 
614
- /** Processes a path, scanning directories or analyzing files [ds] */
814
+ /** Processes a given path, which can be a file or directory [ds] */
615
815
  async function processPath(targetPath) {
616
816
  const stats = fs.statSync(targetPath);
617
817
 
package/bin/setup-hook.js CHANGED
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const { execSync } = require('child_process');
4
4
  const readline = require('readline');
5
5
 
6
- /** Installs Git hooks for auto-generating comments [ds] */
6
+ /** Installs Git hooks and configures commit settings [ds] */
7
7
  async function installHooks() {
8
8
  try {
9
9
  const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
@@ -13,9 +13,8 @@ async function installHooks() {
13
13
  fs.mkdirSync(hooksDir, { recursive: true });
14
14
  }
15
15
 
16
- // Default commenting mode for Git commits [ds]
17
16
  let modeChoice = '1';
18
- // Check if running in a TTY to prompt user for commenting mode [ds]
17
+ // Check if process is run in a TTY environment [ds]
19
18
  if (process.stdout.isTTY) {
20
19
  const rl = readline.createInterface({
21
20
  input: process.stdin,
@@ -23,15 +22,25 @@ async function installHooks() {
23
22
  });
24
23
  const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
25
24
 
25
+ // Prompt user to select the default commenting mode [ds]
26
26
  console.log('\nSelect default commenting mode for Git commits:');
27
27
  console.log('1. Balanced (mix of JSDoc and sparse inline comments)');
28
28
  console.log('2. Light (JSDoc block comments above functions only)');
29
29
  console.log('3. Full (aggressive inline commenting)');
30
- const answer = await askQuestion('Select (1-3, default: 1): ');
31
- modeChoice = answer.trim() || '1';
30
+
31
+ // Validate user input for commenting mode [ds]
32
+ while (true) {
33
+ const answer = (await askQuestion('Select (1-3, default: 1): ')).trim();
34
+ if (answer === '' || ['1', '2', '3'].includes(answer)) {
35
+ modeChoice = answer || '1';
36
+ break;
37
+ }
38
+ console.log('Invalid choice. Please select 1, 2, or 3.');
39
+ }
32
40
  rl.close();
33
41
  }
34
42
 
43
+ // Determine mode arguments based on user choice [ds]
35
44
  let modeArgs = '';
36
45
  if (modeChoice === '2') {
37
46
  modeArgs = ' --light';
@@ -39,7 +48,7 @@ async function installHooks() {
39
48
  modeArgs = ' --full';
40
49
  }
41
50
 
42
- // Path to pre-commit hook script [ds]
51
+ // Define the pre-commit hook script [ds]
43
52
  const preCommitHookPath = path.join(hooksDir, 'pre-commit');
44
53
  const preCommitContent = `#!/bin/sh
45
54
  # devsplain native pre-commit hook
@@ -48,16 +57,16 @@ if [ -f package.json ] && grep -q '"test"' package.json 2>/dev/null; then
48
57
  npm test || exit 1
49
58
  fi
50
59
  `;
51
- // Write pre-commit hook content to file [ds]
60
+ // Write the pre-commit hook to the Git hooks directory [ds]
52
61
  fs.writeFileSync(preCommitHookPath, preCommitContent);
53
62
  try {
54
63
  fs.chmodSync(preCommitHookPath, 0o755);
55
64
  } catch (err) {}
56
65
 
57
- // Path to post-commit script [ds]
66
+ // Define the post-commit script path [ds]
58
67
  const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
59
68
 
60
- // Path to post-commit hook script [ds]
69
+ // Define the post-commit hook script [ds]
61
70
  const postCommitHookPath = path.join(hooksDir, 'post-commit');
62
71
  const postCommitContent = `#!/bin/sh
63
72
  # devsplain native post-commit hook
@@ -69,11 +78,11 @@ node "${postCommitScript}"${modeArgs} || exit 1
69
78
  fs.chmodSync(postCommitHookPath, 0o755);
70
79
  } catch (err) {}
71
80
 
81
+ // Inform the user about the successful installation of the post-commit hook [ds]
72
82
  console.log(`[devsplain] Git post-commit hook successfully installed at: ${postCommitHookPath}`);
73
83
 
74
- // Log successful installation of post-commit hook [ds]
84
+ // Check if a .devsplainignore file exists in the Git root directory [ds]
75
85
  const ignorePath = path.join(gitRoot, '.devsplainignore');
76
- // Check if .devsplainignore file exists [ds]
77
86
  if (!fs.existsSync(ignorePath)) {
78
87
  const defaultIgnore = `node_modules/
79
88
  .git/
@@ -102,9 +111,9 @@ fixtures/
102
111
  }
103
112
  }
104
113
 
105
- // Run installHooks if this script is the main module [ds]
114
+ // Check if the script is run as the main module [ds]
106
115
  if (require.main === module) {
107
116
  installHooks();
108
117
  }
109
- // Export installHooks function [ds]
118
+ // Export the installHooks function for external use [ds]
110
119
  module.exports = installHooks;
package/lib/config.js CHANGED
@@ -4,7 +4,7 @@ const os = require('os');
4
4
  const readline = require('readline');
5
5
  const configPath = path.join(os.homedir(), '.devsplainrc');
6
6
 
7
- /** Asks the user for a secret input, such as an API key, without echoing the input to the console. [ds] */
7
+ /** Asks the user for a secret input (e.g., an API key) without echoing the input to the console. [ds] */
8
8
  function askSecret(query) {
9
9
  return new Promise((resolve) => {
10
10
  const rl = readline.createInterface({
@@ -14,7 +14,6 @@ function askSecret(query) {
14
14
  process.stdout.write(query);
15
15
  process.stdin.setRawMode(true);
16
16
  let key = '';
17
- // Handle key presses, including enter, backspace, and Ctrl+C [ds]
18
17
  const onData = (char) => {
19
18
  char = char.toString();
20
19
  if (char === '\n' || char === '\r') {
@@ -34,7 +33,7 @@ function askSecret(query) {
34
33
  });
35
34
  }
36
35
 
37
- /** Retrieves the configuration, either from environment variables or by prompting the user to set up a configuration file. [ds] */
36
+ /** Attempts to fetch the configuration settings, either from environment variables or by prompting the user. [ds] */
38
37
  async function getConfig(forceWizard = false) {
39
38
  if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
40
39
  const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
@@ -47,8 +46,8 @@ async function getConfig(forceWizard = false) {
47
46
  baseUrl
48
47
  };
49
48
  }
49
+ // If the configuration file doesn't exist, or if we're forcing the wizard, let's prompt the user for configuration. [ds]
50
50
 
51
- // If no configuration file exists or the forceWizard flag is set, prompt the user to set up a configuration [ds]
52
51
  if (!fs.existsSync(configPath) || forceWizard) {
53
52
  let rl = readline.createInterface({
54
53
  input: process.stdin,
@@ -59,12 +58,13 @@ async function getConfig(forceWizard = false) {
59
58
  let config = null;
60
59
  let confirmed = false;
61
60
 
61
+ // Keep prompting the user until they confirm their configuration settings. [ds]
62
62
  while (!confirmed) {
63
63
  let baseUrl = "";
64
64
  let model = "";
65
65
  let provider = "";
66
66
 
67
- // Display a menu of available AI providers [ds]
67
+ // Display the list of available AI providers to the user. [ds]
68
68
  console.log("\nWhich AI Provider Do You want to use?");
69
69
  console.log("1. Groq (Free, Fast, Llama-3)");
70
70
  console.log("2. Gemini (Free Tier)");
@@ -94,8 +94,16 @@ async function getConfig(forceWizard = false) {
94
94
  model = customModel.trim() || 'gpt-4o';
95
95
  } else if (choice === '4') {
96
96
  provider = 'custom';
97
- model = await askQuestion("Model name (e.g., llama3): ");
98
- baseUrl = await askQuestion("Base URL (e.g., http://localhost:11434): ");
97
+ while (true) {
98
+ model = (await askQuestion("Model name (e.g., llama3): ")).trim();
99
+ if (model) break;
100
+ console.log("Model name cannot be empty.");
101
+ }
102
+ while (true) {
103
+ baseUrl = (await askQuestion("Base URL (e.g., http://localhost:11434): ")).trim();
104
+ if (baseUrl) break;
105
+ console.log("Base URL cannot be empty.");
106
+ }
99
107
  } else if (choice === '5') {
100
108
  provider = 'claude';
101
109
  baseUrl = 'https://api.anthropic.com';
@@ -107,26 +115,48 @@ async function getConfig(forceWizard = false) {
107
115
  continue;
108
116
  }
109
117
 
118
+ // Get the API key from the user. [ds]
110
119
  let apiKey = '';
111
- // If running in a TTY, use a secure method to prompt for the API key [ds]
112
- if (process.stdin.isTTY) {
113
- rl.close();
114
- apiKey = await askSecret("Paste your API key (leave blank for local models): ");
115
-
116
- rl = readline.createInterface({
117
- input: process.stdin,
118
- output: process.stdout
119
- });
120
- askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
121
- } else {
122
- apiKey = await askQuestion("Paste your API key (leave blank for local models): ");
120
+ while (true) {
121
+ const promptMsg = provider === 'custom'
122
+ ? "Paste your API key (leave blank for local models): "
123
+ : "Paste your API key: ";
124
+
125
+ if (process.stdin.isTTY) {
126
+ rl.close();
127
+ apiKey = await askSecret(promptMsg);
128
+
129
+ rl = readline.createInterface({
130
+ input: process.stdin,
131
+ output: process.stdout
132
+ });
133
+ askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
134
+ } else {
135
+ apiKey = await askQuestion(promptMsg);
136
+ }
137
+
138
+ apiKey = apiKey.trim();
139
+ if (provider === 'custom' || apiKey) {
140
+ break;
141
+ }
142
+ console.log(`API key is required for provider '${provider}'.`);
123
143
  }
124
144
 
125
- // Ask the user if they want to prune existing comments [ds]
126
- const pruneAns = await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ");
127
- const autoPrune = pruneAns.toLowerCase() === 'y';
145
+ // Ask the user if they want to enable auto-pruning of existing human/AI comments. [ds]
146
+ let autoPrune = false;
147
+ while (true) {
148
+ const pruneAns = (await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ")).trim().toLowerCase();
149
+ if (pruneAns === '' || pruneAns === 'n' || pruneAns === 'no') {
150
+ autoPrune = false;
151
+ break;
152
+ } else if (pruneAns === 'y' || pruneAns === 'yes') {
153
+ autoPrune = true;
154
+ break;
155
+ }
156
+ console.log("Invalid choice. Please enter 'y' or 'n'.");
157
+ }
128
158
 
129
- // Display a summary of the configuration [ds]
159
+ // Display a summary of the user's configuration settings. [ds]
130
160
  console.log("\n--- Configuration Summary ---");
131
161
  console.log(`Provider: ${provider}`);
132
162
  console.log(`Model: ${model}`);
@@ -135,28 +165,31 @@ async function getConfig(forceWizard = false) {
135
165
  console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
136
166
  console.log("-----------------------------\n");
137
167
 
138
- // Confirm the configuration with the user [ds]
139
- const confirm = await askQuestion("Does this look correct? (y/n, default: y): ");
140
- if (confirm.toLowerCase() === 'y' || confirm.trim() === '') {
141
- config = {
142
- provider,
143
- apiKey,
144
- model,
145
- baseUrl,
146
- autoPrune
147
- };
148
- confirmed = true;
149
- } else {
150
- console.log("Let's restart the configuration setup.");
168
+ // Confirm the configuration settings with the user. [ds]
169
+ while (true) {
170
+ const confirm = (await askQuestion("Does this look correct? (y/n, default: y): ")).trim().toLowerCase();
171
+ if (confirm === '' || confirm === 'y' || confirm === 'yes') {
172
+ config = {
173
+ provider,
174
+ apiKey,
175
+ model,
176
+ baseUrl,
177
+ autoPrune
178
+ };
179
+ confirmed = true;
180
+ break;
181
+ } else if (confirm === 'n' || confirm === 'no') {
182
+ break;
183
+ }
184
+ console.log("Invalid choice. Please enter 'y' or 'n'.");
151
185
  }
152
186
  }
153
187
 
154
188
  rl.close();
155
189
 
156
- // Write the configuration to a file [ds]
190
+ // Write the configuration settings to the config file. [ds]
157
191
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
158
192
  try {
159
- // Set permissions on the configuration file to prevent other users from reading it [ds]
160
193
  if (process.platform !== 'win32') {
161
194
  fs.chmodSync(configPath, 0o600);
162
195
  }
@@ -170,4 +203,5 @@ async function getConfig(forceWizard = false) {
170
203
  }
171
204
  }
172
205
 
173
- module.exports = { getConfig };
206
+ // Export the getConfig function for use in other modules. [ds]
207
+ module.exports = { getConfig };
package/lib/llm.js CHANGED
@@ -41,37 +41,14 @@ async function getComments(code, language, config, mode = 'default') {
41
41
  const lines = code.split(/\r?\n/);
42
42
  const numberedCode = lines.map((line, index) => `${index + 1}: ${line}`).join('\n');
43
43
 
44
- let prompt = "";
45
- if (mode === 'clean') {
46
- prompt = `
47
- You are a code documentation scrubber. Analyze the following ${language} code which has line numbers prepended to it.
48
- Your goal is to identify all lines containing comments (both block/JSDoc and inline comments) and return their line numbers to delete them.
49
-
50
- CRITICAL RULES:
51
- 1. You MUST respond with ONLY a raw, valid JSON array of objects. NO markdown formatting, NO backticks, NO explanations, NO text before or after the JSON.
52
- 2. Each object must have exactly two properties: "line" (the integer line number of the comment line to delete) and "action" (which must be the string "delete").
53
- 3. Do NOT include the original code in your response.
54
- 4. If no comments are found, return an empty array: [].
55
- 5. For block or JSDoc comments (e.g., starting with /* and ending with */), you MUST identify and return the line numbers of ALL lines in that block, including the opening /*, all intermediate lines, and the closing */. Do NOT leave trailing comment delimiters behind.
56
-
57
- Example Output:
58
- [
59
- { "line": 4, "action": "delete" },
60
- { "line": 5, "action": "delete" }
61
- ]
62
-
63
- Here is the source code:
64
- ${numberedCode}
65
- `.trim();
66
- } else {
67
- let instruction = "Provide JSDoc/docstrings block comments above functions and sparse inline comments for complex logic.";
68
- if (mode === 'light') {
69
- instruction = "Provide ONLY JSDoc/docstrings above functions. Keep it minimal.";
70
- } else if (mode === 'full') {
71
- instruction = "Provide highly detailed JSDoc/docstrings above functions, and exhaustive step-by-step inline comments (using standard comment syntax like // or #) 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.";
72
- }
44
+ let instruction = "Provide JSDoc/docstrings block comments above functions and sparse inline comments for complex logic.";
45
+ if (mode === 'light') {
46
+ instruction = "Provide ONLY JSDoc/docstrings above functions. Keep it minimal.";
47
+ } else if (mode === 'full') {
48
+ instruction = "Provide highly detailed JSDoc/docstrings above functions, and exhaustive step-by-step inline comments (using standard comment syntax like // or #) 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.";
49
+ }
73
50
 
74
- prompt = `
51
+ let prompt = `
75
52
  You are a code documentation engine. Analyze the following ${language} code which has line numbers prepended to it.
76
53
  ${instruction}
77
54
 
@@ -89,8 +66,7 @@ Example Output:
89
66
 
90
67
  Here is the source code:
91
68
  ${numberedCode}
92
- `.trim();
93
- }
69
+ `.trim();
94
70
 
95
71
  // Initialize text response [ds]
96
72
  let textResponse = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "An agent-agnostic CLI tool that automatically adds JSDoc and inline comments to your code using free LLMs.",
5
5
  "author": "mwahaj36",
6
6
  "license": "MIT",