devsplain 1.8.0 → 2.0.1

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,16 +1,20 @@
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.
2
+
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.
3
4
 
4
5
  ![devsplain demo](sample.gif)
5
6
 
6
- devsplain never rewrites executable code.
7
- If the original source cannot be reproduced exactly after comment insertion, the operation aborts.
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.
8
+
9
+ > [!TIP]
10
+ > **Built with devsplain**
11
+ > This entire repository is heavily "dogfooded". Every single AI-generated `[ds]` comment you see in the source code of this tool was automatically generated and committed by `devsplain` itself using its native git hooks!
8
12
  ---
9
13
 
10
14
  ## Key Features
11
15
 
12
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.
13
- - **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.
14
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.
15
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.
16
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.
@@ -40,14 +44,14 @@ The engine tracks lexical state across template strings, single quotes, double q
40
44
 
41
45
  ### The `[ds]` AI Tag Guarantee
42
46
  Every single comment generated by the LLM is forcibly prefixed with a `[ds]` tag (e.g., `// [ds] This function handles...`).
43
- 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.
44
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.
45
49
 
46
50
  ### Why Not AST Verification?
47
51
 
48
- 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.
49
53
 
50
- `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:
51
55
 
52
56
  1. Original source is loaded.
53
57
  2. Comments are inserted.
@@ -81,10 +85,10 @@ To force re-run the configuration wizard at any time, execute:
81
85
  devsplain --config
82
86
  ```
83
87
 
84
- Your settings are stored securely in `~/.devsplainrc` (configured with `chmod 600` on POSIX systems to restrict read access).
88
+ Your settings are stored securely in `~/.devsplainrc` (configured with `chmod 600` on POSIX systems to restrict read access, and keystrokes are masked during input).
85
89
 
86
- > [!NOTE]
87
- > **API Testing Notice:** `devsplain` supports OpenAI, Anthropic, Ollama, Groq, and Gemini endpoints, but the E2E test suite has currently only been aggressively verified against the **Groq** and **Gemini** APIs. If you encounter any unexpected parsing issues or edge-case errors with other providers, please open an issue or submit a PR!
90
+ > [!CAUTION]
91
+ > **Security Note:** Prefer configuring the `DEVSPLAIN_API_KEY` environment variable over using the `--api-key` CLI flag for recurring use. CLI flags may be exposed in your shell history (`~/.bash_history`) and process lists.
88
92
 
89
93
  ---
90
94
 
@@ -133,13 +137,10 @@ devsplain lib/ --prune
133
137
  devsplain src/utils.ts --provider gemini --model gemini-2.0-flash --api-key YOUR_KEY
134
138
  ```
135
139
 
136
- > [!WARNING]
137
- > **Directory Traversal Caution**
138
- > The built-in list of ignored folders (like `node_modules`, `.git`, `dist`, etc.) is not exhaustive. If you run `devsplain` on a broad directory that contains unignored directories (such as local caches or build directories), it may start commenting unwanted files in random folders.
139
- >
140
- > To prevent this, it is highly recommended to either:
141
- > 1. Use the **Automated Git Hooks** to comment only on files modified in your commits.
142
- > 2. Pass specific files or selective subfolders manually (e.g., `devsplain src/utils.ts`) instead of targeting broad directories.
140
+ > [!TIP]
141
+ > **Directory Traversal Protection**
142
+ > When you run `devsplain` on a directory, it automatically ignores common build/dependency folders (`node_modules`, `.git`, `dist`, etc.).
143
+ > To ignore custom directories, simply create a `.devsplainignore` file in your project root using the standard `.gitignore` syntax. (A default `.devsplainignore` is automatically generated when you run `--setup-hook`).
143
144
 
144
145
  ---
145
146
 
@@ -168,6 +169,25 @@ Run the hook setup command inside your Git repository:
168
169
  devsplain --setup-hook
169
170
  ```
170
171
 
172
+ ### Bypassing the Hook (Manual Override)
173
+ If you ever want to commit code without triggering the AI (for example, if you just ran `devsplain index.js --full` manually and want to freeze those specific comments without the background hook overwriting them), you can bypass the hook entirely:
174
+ ```bash
175
+ SKIP_DEVSPLAIN=1 git commit -m "my commit message"
176
+ ```
177
+
178
+ ### Forcing or Skipping Auto-Prune
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
+ - To **force an overwrite** (destroying all human/AI comments before generating new ones) regardless of your config:
183
+ ```bash
184
+ DS_OVER=1 git commit -m "overwrite docs"
185
+ ```
186
+ - To **force preservation** of existing comments (overriding a global prune preference):
187
+ ```bash
188
+ DS_KEEP=1 git commit -m "keep docs"
189
+ ```
190
+
171
191
  ### Pipeline Architecture
172
192
  1. **Pre-commit Hook**: Runs your project test suite (`npm test`). If the tests fail, the commit is blocked.
173
193
  2. **Post-commit Hook**:
@@ -179,6 +199,13 @@ devsplain --setup-hook
179
199
 
180
200
  ---
181
201
 
202
+ ## Known Limitations & Contributing
203
+
204
+ > [!NOTE]
205
+ > **API Testing Notice:** `devsplain` natively supports OpenAI, Anthropic, Ollama, Groq, and Gemini endpoints, but the E2E test suite has currently only been aggressively verified against the **Groq** and **Gemini** APIs. If you encounter any unexpected parsing issues or edge-case errors with other providers, please open an issue or submit a PR!
206
+
207
+ ---
208
+
182
209
  ## License
183
210
 
184
211
  This project is licensed under the MIT License.
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 current Git repository has uncommitted changes [ds] */
13
+ /** Checks if the Git repository is dirty [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,47 +23,109 @@ function isGitDirty() {
23
23
  return false;
24
24
  }
25
25
 
26
- /** Checks if a given line index is inside a string in a list of code lines [ds] */
26
+ /** Checks if a line of code is inside a string [ds] */
27
27
  function isLineInsideString(lines, targetLineIndex, ext = '') {
28
28
  const isPython = ext.toLowerCase() === '.py';
29
+ const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
29
30
  let inBacktick = false;
30
31
  let inTripleDouble = false;
31
32
  let inTripleSingle = false;
32
33
  let inSingle = false;
33
34
  let inDouble = false;
34
-
35
+ let inBlockJS = false;
36
+ let inBlockHTML = false;
35
37
  for (let i = 0; i < targetLineIndex; i++) {
36
38
  const line = lines[i];
37
39
  let j = 0;
38
40
  while (j < line.length) {
41
+ if (inBlockJS) {
42
+ if (line.slice(j, j + 2) === '*/') {
43
+ inBlockJS = false;
44
+ j += 2;
45
+ continue;
46
+ }
47
+ j++;
48
+ continue;
49
+ }
50
+ if (inBlockHTML) {
51
+ if (line.slice(j, j + 3) === '-->') {
52
+ inBlockHTML = false;
53
+ j += 3;
54
+ continue;
55
+ }
56
+ j++;
57
+ continue;
58
+ }
59
+ // Check if comment starts (skip processing quotes if we are entering a comment)
60
+ if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
61
+ if (isPython) {
62
+ if (line[j] === '#') {
63
+ break; // Ignore rest of line
64
+ }
65
+ } else if (isHTML) {
66
+ if (line.slice(j, j + 4) === '<!--') {
67
+ inBlockHTML = true;
68
+ // Check if current character is a backtick [ds]
69
+ j += 4;
70
+ continue;
71
+ }
72
+ if (line.slice(j, j + 2) === '/*') {
73
+ inBlockJS = true;
74
+ j += 2;
75
+ continue;
76
+ }
77
+ if (line.slice(j, j + 2) === '//') {
78
+ break; // Ignore rest of line
79
+ }
80
+ } else {
81
+ if (line.slice(j, j + 2) === '//') {
82
+ break; // Ignore rest of line
83
+ }
84
+ if (line.slice(j, j + 2) === '/*') {
85
+ inBlockJS = true;
86
+ j += 2;
87
+ continue;
88
+ }
89
+ const isShellOrRuby = ['.sh', '.rb', '.php'].includes(ext.toLowerCase());
90
+ if (isShellOrRuby && line[j] === '#') {
91
+ break; // Ignore rest of line
92
+ }
93
+ }
94
+ }
39
95
  if (isPython) {
40
- if (!inTripleSingle) {
96
+ if (!inTripleSingle && !inSingle && !inDouble) {
41
97
  if (line.slice(j, j + 3) === '"""') {
42
98
  inTripleDouble = !inTripleDouble;
43
99
  j += 3;
44
100
  continue;
45
101
  }
46
102
  }
47
- if (!inTripleDouble) {
103
+ if (!inTripleDouble && !inSingle && !inDouble) {
48
104
  if (line.slice(j, j + 3) === "'''") {
49
105
  inTripleSingle = !inTripleSingle;
50
106
  j += 3;
51
107
  continue;
52
108
  }
53
109
  }
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;
110
+ }
111
+ if (!inTripleSingle && !inTripleDouble) {
112
+ if (!isPython) {
113
+ if (!inSingle && !inDouble) {
114
+ if (line[j] === '`') {
115
+ let escaped = false;
116
+ let k = j - 1;
117
+ while (k >= 0 && line[k] === '\\') {
118
+ escaped = !escaped;
119
+ k--;
120
+ }
121
+ if (!escaped) {
122
+ inBacktick = !inBacktick;
123
+ }
64
124
  }
65
125
  }
126
+ }
66
127
  if (!inBacktick) {
128
+ // Check if current character is a double quote [ds]
67
129
  if (line[j] === '"' && !inSingle) {
68
130
  let escaped = false;
69
131
  let k = j - 1;
@@ -89,7 +151,8 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
89
151
  }
90
152
  j++;
91
153
  }
92
- if (!isPython) {
154
+ const resetsAtLineEnd = ['.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.cs', '.go', '.swift', '.kt', '.dart'].includes(ext.toLowerCase());
155
+ if (resetsAtLineEnd) {
93
156
  inSingle = false;
94
157
  inDouble = false;
95
158
  }
@@ -97,7 +160,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
97
160
  return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
98
161
  }
99
162
 
100
- /** Analyzes comments in a list of code lines [ds] */
163
+ /** Analyzes comments in a given list of lines [ds] */
101
164
  function analyzeComments(lines, ext = '') {
102
165
  const isPython = ext.toLowerCase() === '.py';
103
166
  const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
@@ -133,23 +196,12 @@ function analyzeComments(lines, ext = '') {
133
196
  j++;
134
197
  continue;
135
198
  }
136
- // Check for comment start index in non-Python files [ds]
137
199
  if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
138
200
  if (isPython) {
139
201
  if (line[j] === '#') {
140
202
  commentStartIndex = j;
141
203
  break;
142
204
  }
143
- if (line.slice(j, j + 2) === '/*') {
144
- commentStartIndex = j;
145
- inBlockJS = true;
146
- j += 2;
147
- continue;
148
- }
149
- if (line.slice(j, j + 2) === '//') {
150
- commentStartIndex = j;
151
- break;
152
- }
153
205
  } else if (isHTML) {
154
206
  if (line.slice(j, j + 4) === '<!--') {
155
207
  commentStartIndex = j;
@@ -178,31 +230,33 @@ function analyzeComments(lines, ext = '') {
178
230
  j += 2;
179
231
  continue;
180
232
  }
181
- if (line[j] === '#') {
233
+ const isShellOrRuby = ['.sh', '.rb', '.php'].includes(ext.toLowerCase());
234
+ if (isShellOrRuby && line[j] === '#') {
182
235
  commentStartIndex = j;
183
236
  break;
184
237
  }
185
238
  }
186
239
  }
187
240
  if (isPython) {
188
- if (!inTripleSingle) {
241
+ if (!inTripleSingle && !inSingle && !inDouble) {
189
242
  if (line.slice(j, j + 3) === '"""') {
190
243
  inTripleDouble = !inTripleDouble;
191
244
  j += 3;
192
245
  continue;
193
246
  }
194
247
  }
195
- if (!inTripleDouble) {
248
+ if (!inTripleDouble && !inSingle && !inDouble) {
196
249
  if (line.slice(j, j + 3) === "'''") {
197
250
  inTripleSingle = !inTripleSingle;
198
251
  j += 3;
199
252
  continue;
200
253
  }
201
254
  }
202
- // Check for string literals in non-Python files [ds]
203
- } else {
204
- if (!inSingle && !inDouble) {
205
- if (line[j] === '`') {
255
+ }
256
+ if (!inTripleSingle && !inTripleDouble) {
257
+ if (!isPython) {
258
+ if (!inSingle && !inDouble) {
259
+ if (line[j] === '`') {
206
260
  let escaped = false;
207
261
  let k = j - 1;
208
262
  while (k >= 0 && line[k] === '\\') {
@@ -214,6 +268,7 @@ function analyzeComments(lines, ext = '') {
214
268
  }
215
269
  }
216
270
  }
271
+ }
217
272
  if (!inBacktick) {
218
273
  if (line[j] === '"' && !inSingle) {
219
274
  let escaped = false;
@@ -226,6 +281,7 @@ function analyzeComments(lines, ext = '') {
226
281
  inDouble = !inDouble;
227
282
  }
228
283
  }
284
+ // Check if current character is a single quote [ds]
229
285
  else if (line[j] === "'" && !inDouble) {
230
286
  let escaped = false;
231
287
  let k = j - 1;
@@ -239,10 +295,10 @@ function analyzeComments(lines, ext = '') {
239
295
  }
240
296
  }
241
297
  }
242
- // Increment character index [ds]
243
298
  j++;
244
299
  }
245
- if (!isPython) {
300
+ const resetsAtLineEnd = ['.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.cs', '.go', '.swift', '.kt', '.dart'].includes(ext.toLowerCase());
301
+ if (resetsAtLineEnd) {
246
302
  inSingle = false;
247
303
  inDouble = false;
248
304
  }
@@ -267,7 +323,7 @@ function analyzeComments(lines, ext = '') {
267
323
  return analysis;
268
324
  }
269
325
 
270
- /** Splices comments into a list of code lines [ds] */
326
+ /** Splices comments into the given data [ds] */
271
327
  function spliceComments(data, comments, mode = 'default', ext = '') {
272
328
  const hasCRLF = data.includes('\r\n');
273
329
  const lineEnding = hasCRLF ? '\r\n' : '\n';
@@ -289,7 +345,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
289
345
  let start = i;
290
346
  let end = i;
291
347
  while (end < originalLines.length && analysis[end].isInsideBlock) end++;
292
- let blockStart = start - 1;
348
+ let blockStart = Math.max(0, start - 1);
293
349
  let blockEnd = end - 1;
294
350
  let hasDs = false;
295
351
  for (let k = blockStart; k <= blockEnd; k++) {
@@ -316,6 +372,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
316
372
  continue;
317
373
  }
318
374
 
375
+ // Prune mode: remove all comments from the line [ds]
319
376
  if (mode === 'prune') {
320
377
  if (lineAnalysis.isPureComment) {
321
378
  finalDeletions.add(lineNum);
@@ -390,6 +447,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
390
447
  const indentation = indentMatch ? indentMatch[1] : '';
391
448
 
392
449
  const commentLines = c.comment.split(/\r?\n/).map((line, idx) => {
450
+ // Add the comment to the list of comments [ds]
393
451
  let trimmed = line.trimStart();
394
452
  if (!trimmed) return '';
395
453
 
@@ -421,6 +479,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
421
479
  const filteredText = filtered.map(line => line.text);
422
480
  const filteredIndices = filtered.map(line => line.originalIndex);
423
481
 
482
+ // Check if the filtered text is equal to the original text [ds]
424
483
  const textEqual = filteredText.every((text, idx) => {
425
484
  const origIdx = filteredIndices[idx];
426
485
  const originalLine = originalLines[origIdx];
@@ -459,7 +518,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
459
518
  return annotated.map(line => line.text).join(lineEnding);
460
519
  }
461
520
 
462
- /** Runs the CLI interface for the commenting tool [ds] */
521
+ /** Runs the CLI interface [ds] */
463
522
  async function runCLI() {
464
523
  rl = readline.createInterface({ input: process.stdin, output: process.stdout });
465
524
  askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
@@ -549,6 +608,8 @@ Options:
549
608
  if (args.includes('--prune')) mode = 'prune';
550
609
  const isDryRun = args.includes('--dry-run');
551
610
  const isForce = args.includes('--force');
611
+ const hasOverwriteFlag = args.includes('--overwrite');
612
+ const hasKeepFlag = args.includes('--keep');
552
613
 
553
614
  if (process.env.NODE_ENV !== 'test' && isGitDirty() && !isForce) {
554
615
  console.error("Error: Git working tree is dirty. Please commit or stash your changes, or use --force to bypass this check.");
@@ -579,24 +640,48 @@ Options:
579
640
  let successCount = 0;
580
641
  let failCount = 0;
581
642
 
643
+ const isOverwrite = (hasOverwriteFlag || config.autoPrune) && !hasKeepFlag;
644
+
645
+ let userIgnorePatterns = [];
646
+ try {
647
+ const ignorePath = path.join(process.cwd(), '.devsplainignore');
648
+ if (fs.existsSync(ignorePath)) {
649
+ const ignoreContent = fs.readFileSync(ignorePath, 'utf8');
650
+ userIgnorePatterns = ignoreContent.split(/\r?\n/)
651
+ .map(line => line.trim())
652
+ .filter(line => line && !line.startsWith('#'));
653
+ }
654
+ } catch(e) {}
655
+
656
+ const defaultIgnoredFolders = [
657
+ 'node_modules', '.git', 'dist', 'build', 'out',
658
+ '.next', '.nuxt', '.svelte-kit',
659
+ 'venv', 'env', '.venv',
660
+ '.vscode', '.idea', 'coverage',
661
+ 'tests', '__tests__', 'fixtures'
662
+ ];
663
+ const allIgnored = [...defaultIgnoredFolders, ...userIgnorePatterns];
664
+
665
+ /** Checks if a given path is ignored [ds] */
666
+ function isPathIgnored(targetPath) {
667
+ const filename = path.basename(targetPath);
668
+ for (const pattern of allIgnored) {
669
+ const cleanPattern = pattern.replace(/\/$/, '').replace(/\\$/, '');
670
+ if (filename === cleanPattern) return true;
671
+ if (pattern.startsWith('*') && filename.endsWith(pattern.slice(1))) return true;
672
+ }
673
+ return false;
674
+ }
675
+
676
+ /** Processes a given path [ds] */
582
677
  async function processPath(targetPath) {
583
- // Process a directory or file path [ds]
584
678
  const stats = fs.statSync(targetPath);
585
679
 
586
- if (stats.isDirectory()) {
587
- const folderName = path.basename(targetPath);
588
- const ignoredFolders = [
589
- 'node_modules', '.git', 'dist', 'build', 'out',
590
- '.next', '.nuxt', '.svelte-kit',
591
- 'venv', 'env', '.venv',
592
- '.vscode', '.idea', 'coverage',
593
- 'tests', '__tests__', 'fixtures'
594
- ];
595
-
596
- if (ignoredFolders.includes(folderName)) {
597
- return;
598
- }
680
+ if (isPathIgnored(targetPath)) {
681
+ return;
682
+ }
599
683
 
684
+ if (stats.isDirectory()) {
600
685
  console.log(`\n Scanning directory: ${targetPath}`);
601
686
  const items = fs.readdirSync(targetPath);
602
687
  for (const item of items) {
@@ -628,7 +713,8 @@ Options:
628
713
  let comments = [];
629
714
  let commentedCode;
630
715
  if (mode !== 'clean' && mode !== 'prune') {
631
- const cleanData = spliceComments(data, [], 'clean', ext);
716
+ const preProcessMode = isOverwrite ? 'prune' : 'clean';
717
+ const cleanData = spliceComments(data, [], preProcessMode, ext);
632
718
  comments = await getComments(cleanData, filename, config, mode);
633
719
  commentedCode = spliceComments(cleanData, comments, mode, ext);
634
720
  } else {
@@ -677,7 +763,6 @@ Options:
677
763
  rl.close();
678
764
  }
679
765
 
680
- // Run the CLI interface if this script is the main module [ds]
681
766
  if (require.main === module) {
682
767
  runCLI().catch(err => {
683
768
  console.error(err);
@@ -3,60 +3,61 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { spliceComments } = require('./cli');
5
5
 
6
- /** Main execution block [ds] */
6
+ /** Main execution block for post-commit hook [ds] */
7
7
  try {
8
- // Allow users to completely bypass the AI hook using an environment variable [ds]
8
+ // Check if SKIP_DEVSPLAIN environment variable is set [ds]
9
9
  if (process.env.SKIP_DEVSPLAIN) {
10
10
  console.log('[devsplain] SKIP_DEVSPLAIN is set. Bypassing AI generation.');
11
11
  process.exit(0);
12
12
  }
13
13
 
14
- // Prevent the hook from firing during rebases, merges, or cherry-picks [ds]
14
+ // Determine if Git is in a rebasing, merging, or cherry-picking state [ds]
15
15
  const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
16
16
  const isRebasing = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
17
17
  const isMerging = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
18
18
  const isCherryPicking = fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'));
19
19
 
20
+ // Exit if Git is in a rebasing, merging, or cherry-picking state to avoid history conflicts [ds]
20
21
  if (isRebasing || isMerging || isCherryPicking) {
21
22
  console.log('[devsplain] Skipping AI comment generation during git rebase/merge/cherry-pick to avoid history conflicts.');
22
23
  process.exit(0);
23
24
  }
24
25
 
25
- /** Check if the current operation is a rebase, merge or cherry-pick [ds] */
26
+ // Retrieve the last commit message [ds]
26
27
  const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
27
28
  if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
28
29
  process.exit(0);
29
30
  }
30
31
 
31
- // Get the last commit message [ds]
32
+ // Get a list of changed files in the last commit [ds]
32
33
  const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
33
34
  if (!changedFilesStr) {
34
35
  process.exit(0);
35
36
  }
36
37
  const changedFiles = changedFilesStr.split(/\r?\n/);
37
38
 
38
- /** List of valid file extensions for commenting [ds] */
39
+ /** List of valid file extensions for auto-commenting [ds] */
39
40
  const validExtensions = [
40
41
  '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
41
42
  '.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
42
43
  '.swift', '.kt', '.dart', '.sh'
43
44
  ];
44
45
 
45
- /** Filter files to be commented [ds] */
46
+ /** Filter function to determine which files to auto-comment [ds] */
46
47
  const filesToComment = changedFiles.filter(file => {
47
48
  const ext = path.extname(file).toLowerCase();
48
49
  const isIgnored = file.includes('node_modules/') || file.includes('tests/') || file.includes('__tests__/') || file.includes('fixtures/');
49
50
  return validExtensions.includes(ext) && fs.existsSync(file) && !isIgnored;
50
51
  });
51
52
 
52
- // Check if there are any files to comment [ds]
53
53
  if (filesToComment.length === 0) {
54
54
  process.exit(0);
55
55
  }
56
56
 
57
+ // Log the number of files found for auto-commenting [ds]
57
58
  console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
58
59
 
59
- // Parse command line arguments for mode flag [ds]
60
+ // Parse command-line arguments for mode flags [ds]
60
61
  const args = process.argv.slice(2);
61
62
  let modeFlag = '';
62
63
  if (args.includes('--light')) modeFlag = ' --light';
@@ -65,8 +66,9 @@ try {
65
66
  let commentedAny = false;
66
67
  const successfullyCommentedFiles = [];
67
68
 
68
- /** Iterate over files to comment [ds] */
69
+ /** Loop through each file to auto-comment [ds] */
69
70
  for (const file of filesToComment) {
71
+ // Attempt to read file contents and previous version [ds]
70
72
  try {
71
73
  const ext = path.extname(file).toLowerCase();
72
74
  const contentHead = fs.readFileSync(file, 'utf8');
@@ -79,11 +81,12 @@ try {
79
81
  } catch (prevErr) {
80
82
  }
81
83
 
82
- // Compare file content before and after the last commit [ds]
84
+ // Check if file contents have changed (ignoring comments) [ds]
83
85
  if (contentPrev) {
84
86
  const cleanHead = spliceComments(contentHead, [], 'prune', ext);
85
87
  const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
86
- if (cleanHead === cleanPrev) {
88
+ const isExplicitOverwrite = !!process.env.DS_OVER;
89
+ if (cleanHead === cleanPrev && !isExplicitOverwrite) {
87
90
  console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
88
91
  continue;
89
92
  }
@@ -91,11 +94,16 @@ try {
91
94
  } catch (cleanErr) {
92
95
  }
93
96
 
94
- // Comment the file using the CLI [ds]
97
+ // Log and attempt to auto-comment the current file [ds]
95
98
  console.log(`[devsplain] Automatically commenting file: ${file}`);
96
99
  try {
100
+ let extraFlags = '';
101
+ if (process.env.DS_OVER) extraFlags += ' --overwrite';
102
+ if (process.env.DS_KEEP) extraFlags += ' --keep';
103
+
104
+ // Construct the command to run the auto-commenting CLI [ds]
97
105
  const cliPath = path.join(__dirname, 'cli.js');
98
- execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
106
+ execSync(`node "${cliPath}" "${file}" --force${modeFlag}${extraFlags}`, { stdio: 'inherit' });
99
107
  commentedAny = true;
100
108
  successfullyCommentedFiles.push(file);
101
109
  } catch (err) {
@@ -103,16 +111,14 @@ try {
103
111
  }
104
112
  }
105
113
 
106
- /** Stage and commit auto-generated comments if any [ds] */
114
+ /** If any files were successfully commented, stage and commit changes [ds] */
107
115
  if (commentedAny) {
108
- // Only stage the exact files that the AI touched to avoid accidentally committing unstaged work [ds]
109
116
  for (const file of successfullyCommentedFiles) {
110
117
  try {
111
118
  execSync(`git add "${file}"`);
112
119
  } catch (addErr) {}
113
120
  }
114
121
 
115
- // Check if there are actually staged changes now [ds]
116
122
  const stagedChanges = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim();
117
123
  if (stagedChanges.length > 0) {
118
124
  console.log('[devsplain] Staging and committing auto-generated comments...');
@@ -120,7 +126,7 @@ try {
120
126
  console.log('[devsplain] Comments committed successfully! Rollback via: git reset --hard HEAD~1');
121
127
  }
122
128
  }
123
- /** Catch and log any errors [ds] */
129
+ // Catch and log any errors that occur during execution [ds]
124
130
  } catch (e) {
125
131
  console.warn(`[devsplain] Warning: post-commit hook script failed: ${e.message}`);
126
132
  }
package/bin/setup-hook.js CHANGED
@@ -3,36 +3,44 @@ const path = require('path');
3
3
  const { execSync } = require('child_process');
4
4
  const readline = require('readline');
5
5
 
6
- /** Installs Git hooks for the repository. [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();
10
+ const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
10
11
  const hooksDir = path.join(gitDir, 'hooks');
11
12
  if (!fs.existsSync(hooksDir)) {
12
13
  fs.mkdirSync(hooksDir, { recursive: true });
13
14
  }
14
15
 
15
16
  let modeChoice = '1';
16
- // Check if process is running in a TTY to prompt for user input [ds]
17
+ // Check if process is run in a TTY environment [ds]
17
18
  if (process.stdout.isTTY) {
18
19
  const rl = readline.createInterface({
19
20
  input: process.stdin,
20
21
  output: process.stdout
21
22
  });
22
- // Create a readline interface for user input [ds]
23
23
  const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
24
24
 
25
- // Display a menu for the user to select the default commenting mode [ds]
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
 
35
- // Determine the command line arguments based on the chosen mode [ds]
43
+ // Determine mode arguments based on user choice [ds]
36
44
  let modeArgs = '';
37
45
  if (modeChoice === '2') {
38
46
  modeArgs = ' --light';
@@ -40,7 +48,7 @@ async function installHooks() {
40
48
  modeArgs = ' --full';
41
49
  }
42
50
 
43
- // Define the path to the pre-commit hook file [ds]
51
+ // Define the pre-commit hook script [ds]
44
52
  const preCommitHookPath = path.join(hooksDir, 'pre-commit');
45
53
  const preCommitContent = `#!/bin/sh
46
54
  # devsplain native pre-commit hook
@@ -49,16 +57,16 @@ if [ -f package.json ] && grep -q '"test"' package.json 2>/dev/null; then
49
57
  npm test || exit 1
50
58
  fi
51
59
  `;
60
+ // Write the pre-commit hook to the Git hooks directory [ds]
52
61
  fs.writeFileSync(preCommitHookPath, preCommitContent);
53
- // Attempt to set the execute permissions for the pre-commit hook file [ds]
54
62
  try {
55
63
  fs.chmodSync(preCommitHookPath, 0o755);
56
64
  } catch (err) {}
57
65
 
58
- // Define the path to the post-commit script [ds]
66
+ // Define the post-commit script path [ds]
59
67
  const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
60
68
 
61
- // Define the path to the post-commit hook file [ds]
69
+ // Define the post-commit hook script [ds]
62
70
  const postCommitHookPath = path.join(hooksDir, 'post-commit');
63
71
  const postCommitContent = `#!/bin/sh
64
72
  # devsplain native post-commit hook
@@ -66,22 +74,46 @@ echo "Auto-generating comments for files in the last commit..."
66
74
  node "${postCommitScript}"${modeArgs} || exit 1
67
75
  `;
68
76
  fs.writeFileSync(postCommitHookPath, postCommitContent);
69
- // Attempt to set the execute permissions for the post-commit hook file [ds]
70
77
  try {
71
78
  fs.chmodSync(postCommitHookPath, 0o755);
72
79
  } catch (err) {}
73
80
 
74
- // Display a success message if the hooks are installed successfully [ds]
75
- console.log('Success: Native Git pre-commit and post-commit hooks installed successfully!');
76
- // Catch and handle any exceptions that occur during the installation process [ds]
81
+ // Inform the user about the successful installation of the post-commit hook [ds]
82
+ console.log(`[devsplain] Git post-commit hook successfully installed at: ${postCommitHookPath}`);
83
+
84
+ // Check if a .devsplainignore file exists in the Git root directory [ds]
85
+ const ignorePath = path.join(gitRoot, '.devsplainignore');
86
+ if (!fs.existsSync(ignorePath)) {
87
+ const defaultIgnore = `node_modules/
88
+ .git/
89
+ dist/
90
+ build/
91
+ out/
92
+ .next/
93
+ .nuxt/
94
+ .svelte-kit/
95
+ venv/
96
+ env/
97
+ .venv/
98
+ .vscode/
99
+ .idea/
100
+ coverage/
101
+ tests/
102
+ __tests__/
103
+ fixtures/
104
+ `;
105
+ fs.writeFileSync(ignorePath, defaultIgnore);
106
+ console.log(`[devsplain] Created default .devsplainignore file at: ${ignorePath}`);
107
+ }
77
108
  } catch (e) {
78
109
  console.warn('Warning: Could not set up Git hooks (not inside a git repository or git command missing).');
110
+ console.warn(e.message);
79
111
  }
80
112
  }
81
113
 
82
- // Check if this script is being run directly (not being required as a module) [ds]
114
+ // Check if the script is run as the main module [ds]
83
115
  if (require.main === module) {
84
116
  installHooks();
85
117
  }
86
- // Export the installHooks function as a module [ds]
118
+ // Export the installHooks function for external use [ds]
87
119
  module.exports = installHooks;
package/lib/config.js CHANGED
@@ -4,7 +4,36 @@ const os = require('os');
4
4
  const readline = require('readline');
5
5
  const configPath = path.join(os.homedir(), '.devsplainrc');
6
6
 
7
- /** Retrieves the configuration for the AI provider. If a configuration file exists and no forceWizard flag is provided, the existing configuration will be returned. Otherwise, the function will prompt the user to input their configuration details. [ds] */
7
+ /** Prompts the user for a secret input, hiding the input from the console [ds] */
8
+ function askSecret(query) {
9
+ return new Promise((resolve) => {
10
+ const rl = readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout
13
+ });
14
+ process.stdout.write(query);
15
+ process.stdin.setRawMode(true);
16
+ let key = '';
17
+ const onData = (char) => {
18
+ char = char.toString();
19
+ if (char === '\n' || char === '\r') {
20
+ process.stdin.setRawMode(false);
21
+ process.stdin.removeListener('data', onData);
22
+ process.stdout.write('\n');
23
+ rl.close();
24
+ resolve(key);
25
+ } else if (char === '\u0003') { // Ctrl+C
26
+ process.exit();
27
+ } else {
28
+ key += char;
29
+ process.stdout.write('*');
30
+ }
31
+ };
32
+ process.stdin.on('data', onData);
33
+ });
34
+ }
35
+
36
+ /** Retrieves the configuration, either from environment variables or by prompting the user [ds] */
8
37
  async function getConfig(forceWizard = false) {
9
38
  if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
10
39
  const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
@@ -18,24 +47,24 @@ async function getConfig(forceWizard = false) {
18
47
  };
19
48
  }
20
49
 
21
- // Check if configuration file exists or if forceWizard flag is set [ds]
50
+ // If the configuration file does not exist or forceWizard is true, prompt the user to configure [ds]
22
51
  if (!fs.existsSync(configPath) || forceWizard) {
23
- const rl = readline.createInterface({
52
+ let rl = readline.createInterface({
24
53
  input: process.stdin,
25
54
  output: process.stdout
26
55
  });
27
- const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
56
+ let askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
28
57
 
29
58
  let config = null;
30
59
  let confirmed = false;
31
60
 
32
- // Continuously prompt user for configuration details until confirmation [ds]
61
+ // Continuously prompt the user until the configuration is confirmed [ds]
33
62
  while (!confirmed) {
34
63
  let baseUrl = "";
35
64
  let model = "";
36
65
  let provider = "";
37
66
 
38
- // Display available AI provider options [ds]
67
+ // Display the list of available AI providers [ds]
39
68
  console.log("\nWhich AI Provider Do You want to use?");
40
69
  console.log("1. Groq (Free, Fast, Llama-3)");
41
70
  console.log("2. Gemini (Free Tier)");
@@ -43,10 +72,8 @@ async function getConfig(forceWizard = false) {
43
72
  console.log("4. Custom (Ollama, local, etc)");
44
73
  console.log("5. Claude (Anthropic)");
45
74
 
46
- // Get user's selected AI provider option [ds]
47
75
  const choice = await askQuestion("Select (1-5): ");
48
76
 
49
- // Handle selected AI provider option [ds]
50
77
  if (choice === '1') {
51
78
  provider = 'groq';
52
79
  baseUrl = 'https://api.groq.com/openai';
@@ -67,8 +94,16 @@ async function getConfig(forceWizard = false) {
67
94
  model = customModel.trim() || 'gpt-4o';
68
95
  } else if (choice === '4') {
69
96
  provider = 'custom';
70
- model = await askQuestion("Model name (e.g., llama3): ");
71
- 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
+ }
72
107
  } else if (choice === '5') {
73
108
  provider = 'claude';
74
109
  baseUrl = 'https://api.anthropic.com';
@@ -80,38 +115,82 @@ async function getConfig(forceWizard = false) {
80
115
  continue;
81
116
  }
82
117
 
83
- // Get API key from user [ds]
84
- const apiKey = await askQuestion("Paste your API key (leave blank for local models): ");
118
+ // Prompt the user for an API key [ds]
119
+ let apiKey = '';
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}'.`);
143
+ }
144
+
145
+ // Ask the user if they want to enable auto-pruning of existing 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
+ }
85
158
 
86
- // Display configuration summary [ds]
159
+ // Display a summary of the configuration [ds]
87
160
  console.log("\n--- Configuration Summary ---");
88
- console.log(`Provider: ${provider}`);
89
- console.log(`Model: ${model}`);
90
- console.log(`Base URL: ${baseUrl || 'N/A'}`);
91
- console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
161
+ console.log(`Provider: ${provider}`);
162
+ console.log(`Model: ${model}`);
163
+ console.log(`Base URL: ${baseUrl || 'N/A'}`);
164
+ console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
165
+ console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
92
166
  console.log("-----------------------------\n");
93
167
 
94
- // Confirm configuration with user [ds]
95
- const confirm = await askQuestion("Does this look correct? (y/n, default: y): ");
96
- if (confirm.toLowerCase() === 'y' || confirm.trim() === '') {
97
- config = {
98
- provider,
99
- apiKey,
100
- model,
101
- baseUrl
102
- };
103
- confirmed = true;
104
- } else {
105
- console.log("Let's restart the configuration setup.");
168
+ // Confirm the configuration 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'.");
106
185
  }
107
186
  }
108
187
 
109
188
  rl.close();
110
189
 
111
- // Write configuration to file [ds]
190
+ // Write the configuration to the config file [ds]
112
191
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
113
192
  try {
114
- // Set file permissions to prevent unauthorized access [ds]
193
+ // Set the permissions of the config file to prevent other users from reading it [ds]
115
194
  if (process.platform !== 'win32') {
116
195
  fs.chmodSync(configPath, 0o600);
117
196
  }
@@ -120,11 +199,9 @@ async function getConfig(forceWizard = false) {
120
199
 
121
200
  return config;
122
201
  } else {
123
- // Read existing configuration from file [ds]
124
202
  const rawData = fs.readFileSync(configPath, 'utf8');
125
203
  return JSON.parse(rawData);
126
204
  }
127
205
  }
128
206
 
129
- // Export the getConfig function [ds]
130
- module.exports = { getConfig };
207
+ module.exports = { getConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "1.8.0",
3
+ "version": "2.0.1",
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",