devsplain 1.5.6 → 1.7.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
@@ -6,9 +6,10 @@ An industrial-grade, agent-agnostic CLI tool that automatically adds JSDoc and i
6
6
 
7
7
  ## Key Features
8
8
 
9
- - **Mathematical Safety Invariants**: Uses an index-preserving splicing engine. Your functional code is mathematically verified to remain identical before and after commenting.
9
+ - **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.
10
10
  - **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.
11
- - **Local Deterministic Scrubber**: The `--clean` flag strips comments locally using a deterministic lexical state machine—no LLM calls, API keys, or internet required.
11
+ - **Comment Preservation & Tagging**: AI-generated comments are tagged with `[ds]`. Your manually written comments are safe and will never be touched by the engine.
12
+ - **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.
12
13
  - **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.
13
14
  - **Bring Your Own LLM**: Native setup wizard for Groq, Gemini, OpenAI, or any OpenAI-compatible API endpoint (like Ollama or LMStudio).
14
15
  - **Exponential Backoff**: Resilient AI request handler that automatically retries rate-limited requests with exponential backoff.
@@ -34,6 +35,19 @@ Many AI code formatters rewrite your code entirely, exposing you to logic regres
34
35
  ### String Literal Guardrails
35
36
  The engine tracks lexical state across template strings, single quotes, double quotes, and multi-line docstrings (such as Python triple-quotes). Comment insertion is blocked if the target line resides within a string literal, preventing broken syntax.
36
37
 
38
+ ### Why Not AST Verification?
39
+
40
+ AST verification would require language-specific parser dependencies for every supported language.
41
+
42
+ `devsplain` instead uses deterministic source-preservation verification:
43
+
44
+ 1. Original source is loaded.
45
+ 2. Comments are inserted.
46
+ 3. Generated comments are removed.
47
+ 4. The remaining source must match the original file exactly.
48
+
49
+ If any non-comment source line differs, the operation aborts.
50
+
37
51
  ---
38
52
 
39
53
  ## Installation
@@ -78,7 +92,8 @@ devsplain <file-or-directory> [options]
78
92
  | `--full` | Aggressive commenting. Explains complex logic blocks line-by-line inside functions. |
79
93
  | `--dry-run` | Preview comments in the terminal without writing to files. Prompts for manual save confirmation. |
80
94
  | `--force` | Bypasses the safety block check that prevents running `devsplain` on a dirty Git working tree. |
81
- | `--clean` | Scrubber mode. Deterministically removes all comments and docstrings from source files. |
95
+ | `--clean` | Scrubber mode. Deterministically removes only devsplain-generated comments tagged with `[ds]`, preserving your manual comments. |
96
+ | `--prune` | Destructive scrubber mode. Removes ALL comments and docstrings from source files, including your own manual comments. |
82
97
  | `--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). |
83
98
  | `--model <name>` | Temporary one-off override for the model name for this command run only. |
84
99
  | `--api-key <key>` | Temporary one-off override for the API key for this command run only. |
@@ -97,9 +112,12 @@ devsplain src/index.js --light
97
112
  # Deep logic commenting on a folder (skips node_modules, .git, etc.)
98
113
  devsplain src/ --full
99
114
 
100
- # Clean and scrub comments from your codebase locally without AI calls
115
+ # Clean and scrub AI-generated comments locally without API calls
101
116
  devsplain lib/ --clean
102
117
 
118
+ # Destructively remove ALL comments (both AI and manual) from a folder
119
+ devsplain lib/ --prune
120
+
103
121
  # Headless run using overriding credentials
104
122
  devsplain src/utils.ts --provider gemini --model gemini-2.0-flash --api-key YOUR_KEY
105
123
  ```
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 is dirty by inspecting status. */
13
+ /** Checks if the current 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,7 +23,7 @@ function isGitDirty() {
23
23
  return false;
24
24
  }
25
25
 
26
- /** Determines if a specific line index is within a string literal (handling quotes/backticks). */
26
+ /** Checks if a given line index is inside a string in a list of code lines [ds] */
27
27
  function isLineInsideString(lines, targetLineIndex, ext = '') {
28
28
  const isPython = ext.toLowerCase() === '.py';
29
29
  let inBacktick = false;
@@ -97,7 +97,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
97
97
  return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
98
98
  }
99
99
 
100
- /** Parses a file to identify pure comments and block structures. */
100
+ /** Analyzes comments in a list of code lines [ds] */
101
101
  function analyzeComments(lines, ext = '') {
102
102
  const isPython = ext.toLowerCase() === '.py';
103
103
  const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
@@ -133,12 +133,23 @@ function analyzeComments(lines, ext = '') {
133
133
  j++;
134
134
  continue;
135
135
  }
136
+ // Check for comment start index in non-Python files [ds]
136
137
  if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
137
138
  if (isPython) {
138
139
  if (line[j] === '#') {
139
140
  commentStartIndex = j;
140
141
  break;
141
142
  }
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
+ }
142
153
  } else if (isHTML) {
143
154
  if (line.slice(j, j + 4) === '<!--') {
144
155
  commentStartIndex = j;
@@ -146,6 +157,12 @@ function analyzeComments(lines, ext = '') {
146
157
  j += 4;
147
158
  continue;
148
159
  }
160
+ if (line.slice(j, j + 2) === '/*') {
161
+ commentStartIndex = j;
162
+ inBlockJS = true;
163
+ j += 2;
164
+ continue;
165
+ }
149
166
  if (line.slice(j, j + 2) === '//') {
150
167
  commentStartIndex = j;
151
168
  break;
@@ -182,6 +199,7 @@ function analyzeComments(lines, ext = '') {
182
199
  continue;
183
200
  }
184
201
  }
202
+ // Check for string literals in non-Python files [ds]
185
203
  } else {
186
204
  if (!inSingle && !inDouble) {
187
205
  if (line[j] === '`') {
@@ -221,6 +239,7 @@ function analyzeComments(lines, ext = '') {
221
239
  }
222
240
  }
223
241
  }
242
+ // Increment character index [ds]
224
243
  j++;
225
244
  }
226
245
  if (!isPython) {
@@ -248,7 +267,7 @@ function analyzeComments(lines, ext = '') {
248
267
  return analysis;
249
268
  }
250
269
 
251
- /** Splices comments into code or cleans existing ones, with safety checks. */
270
+ /** Splices comments into a list of code lines [ds] */
252
271
  function spliceComments(data, comments, mode = 'default', ext = '') {
253
272
  const hasCRLF = data.includes('\r\n');
254
273
  const lineEnding = hasCRLF ? '\r\n' : '\n';
@@ -258,22 +277,68 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
258
277
 
259
278
  const annotated = originalLines.map((text, index) => ({ text, originalIndex: index }));
260
279
  let analysis = null;
280
+ let dsBlocks = new Set();
261
281
 
262
- if (mode === 'clean') {
282
+ if (mode === 'clean' || mode === 'prune') {
263
283
  analysis = analyzeComments(originalLines, ext);
264
284
  const finalDeletions = new Set();
285
+ if (mode === 'clean') {
286
+ let i = 0;
287
+ while (i < originalLines.length) {
288
+ if (analysis[i].isInsideBlock) {
289
+ let start = i;
290
+ let end = i;
291
+ while (end < originalLines.length && analysis[end].isInsideBlock) end++;
292
+ let blockStart = start - 1;
293
+ let blockEnd = end - 1;
294
+ let hasDs = false;
295
+ for (let k = blockStart; k <= blockEnd; k++) {
296
+ if (originalLines[k].includes('[ds]')) hasDs = true;
297
+ }
298
+ if (hasDs) {
299
+ for (let k = blockStart; k <= blockEnd; k++) {
300
+ dsBlocks.add(k + 1);
301
+ }
302
+ }
303
+ i = end;
304
+ } else {
305
+ i++;
306
+ }
307
+ }
308
+ }
309
+
265
310
  for (let i = 0; i < originalLines.length; i++) {
266
311
  const lineNum = i + 1;
267
- if (originalLines[i].trim().startsWith('#!')) {
312
+ const lineStr = originalLines[i];
313
+ const lineAnalysis = analysis[i];
314
+
315
+ if (lineStr.trim().startsWith('#!')) {
268
316
  continue;
269
317
  }
270
- if (analysis[i].isPureComment) {
271
- finalDeletions.add(lineNum);
272
- } else if (analysis[i].commentStartIndex !== -1) {
273
- annotated[i].text = originalLines[i].slice(0, analysis[i].commentStartIndex).trimEnd();
318
+
319
+ if (mode === 'prune') {
320
+ if (lineAnalysis.isPureComment) {
321
+ finalDeletions.add(lineNum);
322
+ } else if (lineAnalysis.commentStartIndex !== -1) {
323
+ annotated[i].text = lineStr.slice(0, lineAnalysis.commentStartIndex).trimEnd();
324
+ }
325
+ } else if (mode === 'clean') {
326
+ const isDsBlockLine = dsBlocks.has(lineNum);
327
+ const hasDsInline = lineStr.includes('[ds]');
328
+
329
+ if (lineAnalysis.isPureComment) {
330
+ if (isDsBlockLine || hasDsInline) {
331
+ finalDeletions.add(lineNum);
332
+ }
333
+ } else if (lineAnalysis.commentStartIndex !== -1) {
334
+ if (isDsBlockLine || hasDsInline) {
335
+ annotated[i].text = lineStr.slice(0, lineAnalysis.commentStartIndex).trimEnd();
336
+ }
337
+ }
274
338
  }
275
339
  }
276
340
 
341
+
277
342
  for (const c of validComments) {
278
343
  const lineIdx = c.line - 1;
279
344
  if (lineIdx >= 0 && lineIdx < originalLines.length) {
@@ -324,9 +389,23 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
324
389
  const indentMatch = targetLine.match(/^([ \t]*)/);
325
390
  const indentation = indentMatch ? indentMatch[1] : '';
326
391
 
327
- const commentLines = c.comment.split(/\r?\n/).map(line => {
328
- const trimmed = line.trimStart();
392
+ const commentLines = c.comment.split(/\r?\n/).map((line, idx) => {
393
+ let trimmed = line.trimStart();
329
394
  if (!trimmed) return '';
395
+
396
+ const isSingleLine = trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('--');
397
+ const isBlockEnd = trimmed.endsWith('*/') || trimmed.endsWith('-->');
398
+
399
+ if (isSingleLine) {
400
+ trimmed = trimmed + ' [ds]';
401
+ } else if (idx === 0) {
402
+ if (isBlockEnd) {
403
+ trimmed = trimmed.replace(/(\*\/|-->)$/, '[ds] $1');
404
+ } else {
405
+ trimmed = trimmed + ' [ds]';
406
+ }
407
+ }
408
+
330
409
  if (trimmed.startsWith('*') && !trimmed.startsWith('*/') && !trimmed.startsWith('/*')) {
331
410
  return indentation + ' ' + trimmed;
332
411
  }
@@ -348,12 +427,16 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
348
427
  if (text === originalLine) {
349
428
  return true;
350
429
  }
351
- if (mode === 'clean' && analysis) {
430
+ if ((mode === 'clean' || mode === 'prune') && analysis) {
352
431
  const lineAnalysis = analysis[origIdx];
353
432
  if (lineAnalysis && lineAnalysis.commentStartIndex !== -1 && !lineAnalysis.isPureComment) {
354
- const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
355
- if (text === expectedStripped) {
356
- return true;
433
+ const isDsBlockLine = dsBlocks.has(origIdx + 1);
434
+ const hasDsInline = originalLine.includes('[ds]');
435
+ if (mode === 'prune' || (mode === 'clean' && (hasDsInline || isDsBlockLine))) {
436
+ const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
437
+ if (text === expectedStripped) {
438
+ return true;
439
+ }
357
440
  }
358
441
  }
359
442
  }
@@ -376,7 +459,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
376
459
  return annotated.map(line => line.text).join(lineEnding);
377
460
  }
378
461
 
379
- /** Main entry point for the CLI tool. */
462
+ /** Runs the CLI interface for the commenting tool [ds] */
380
463
  async function runCLI() {
381
464
  rl = readline.createInterface({ input: process.stdin, output: process.stdout });
382
465
  askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
@@ -395,6 +478,8 @@ Options:
395
478
  --full Add detailed JSDoc/block comments and inline comments
396
479
  --dry-run Preview comments without writing to file
397
480
  --force Bypass the dirty Git tree safety check
481
+ --clean Scrub only devsplain-generated [ds] comments
482
+ --prune Destructively scrub ALL comments from files
398
483
  --provider <name> Override AI provider (gemini, groq, openai, custom)
399
484
  --model <name> Override AI model name
400
485
  --api-key <key> Override API key for the provider
@@ -461,6 +546,7 @@ Options:
461
546
  if (args.includes('--light')) mode = 'light';
462
547
  if (args.includes('--full')) mode = 'full';
463
548
  if (args.includes('--clean')) mode = 'clean';
549
+ if (args.includes('--prune')) mode = 'prune';
464
550
  const isDryRun = args.includes('--dry-run');
465
551
  const isForce = args.includes('--force');
466
552
 
@@ -493,8 +579,8 @@ Options:
493
579
  let successCount = 0;
494
580
  let failCount = 0;
495
581
 
496
- /** Recursively processes files or directories to apply AI-generated comments. */
497
582
  async function processPath(targetPath) {
583
+ // Process a directory or file path [ds]
498
584
  const stats = fs.statSync(targetPath);
499
585
 
500
586
  if (stats.isDirectory()) {
@@ -503,7 +589,8 @@ Options:
503
589
  'node_modules', '.git', 'dist', 'build', 'out',
504
590
  '.next', '.nuxt', '.svelte-kit',
505
591
  'venv', 'env', '.venv',
506
- '.vscode', '.idea', 'coverage'
592
+ '.vscode', '.idea', 'coverage',
593
+ 'tests', '__tests__', 'fixtures'
507
594
  ];
508
595
 
509
596
  if (ignoredFolders.includes(folderName)) {
@@ -540,12 +627,12 @@ Options:
540
627
  try {
541
628
  let comments = [];
542
629
  let commentedCode;
543
- if (mode !== 'clean') {
630
+ if (mode !== 'clean' && mode !== 'prune') {
544
631
  const cleanData = spliceComments(data, [], 'clean', ext);
545
632
  comments = await getComments(cleanData, filename, config, mode);
546
633
  commentedCode = spliceComments(cleanData, comments, mode, ext);
547
634
  } else {
548
- commentedCode = spliceComments(data, [], 'clean', ext);
635
+ commentedCode = spliceComments(data, [], mode, ext);
549
636
  }
550
637
  if (isDryRun) {
551
638
  console.log(`\n --- DRY RUN PREVIEW: ${filename} ---`);
@@ -590,7 +677,7 @@ Options:
590
677
  rl.close();
591
678
  }
592
679
 
593
- // Check if the script is run directly vs required as a module
680
+ // Run the CLI interface if this script is the main module [ds]
594
681
  if (require.main === module) {
595
682
  runCLI().catch(err => {
596
683
  console.error(err);
@@ -3,40 +3,43 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { spliceComments } = require('./cli');
5
5
 
6
- /** Main execution block to detect changes and process documentation generation */
6
+ /** Attempts to execute the post-commit hook [ds] */
7
7
  try {
8
- // Prevent recursive loops if the previous commit was an automated documentation commit
8
+ // Get the last commit message [ds]
9
9
  const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
10
10
  if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
11
11
  process.exit(0);
12
12
  }
13
13
 
14
+ // Get the list of changed files in the last commit [ds]
14
15
  const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
15
16
  if (!changedFilesStr) {
16
17
  process.exit(0);
17
18
  }
18
- // Retrieve list of files modified in the latest commit
19
19
  const changedFiles = changedFilesStr.split(/\r?\n/);
20
20
 
21
+ /** Defines a list of valid file extensions for commenting [ds] */
21
22
  const validExtensions = [
22
23
  '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
23
24
  '.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
24
25
  '.swift', '.kt', '.dart', '.sh'
25
26
  ];
26
27
 
27
- // Filter for supported file extensions and ensure the file still exists
28
+ /** Filters the changed files based on valid extensions and existence [ds] */
28
29
  const filesToComment = changedFiles.filter(file => {
29
30
  const ext = path.extname(file).toLowerCase();
30
- return validExtensions.includes(ext) && fs.existsSync(file);
31
+ const isIgnored = file.includes('node_modules/') || file.includes('tests/') || file.includes('__tests__/') || file.includes('fixtures/');
32
+ return validExtensions.includes(ext) && fs.existsSync(file) && !isIgnored;
31
33
  });
32
34
 
33
35
  if (filesToComment.length === 0) {
34
36
  process.exit(0);
35
37
  }
36
38
 
39
+ // Log the number of files to be commented [ds]
37
40
  console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
38
41
 
39
- // Parse command line arguments to determine documentation verbosity mode
42
+ // Parse command-line arguments for commenting mode [ds]
40
43
  const args = process.argv.slice(2);
41
44
  let modeFlag = '';
42
45
  if (args.includes('--light')) modeFlag = ' --light';
@@ -44,14 +47,13 @@ try {
44
47
 
45
48
  let commentedAny = false;
46
49
 
47
- // Process each valid file to determine if documentation needs updating
50
+ /** Iterates through the files to be commented and attempts to comment each one [ds] */
48
51
  for (const file of filesToComment) {
49
52
  try {
50
53
  const ext = path.extname(file).toLowerCase();
51
54
  const contentHead = fs.readFileSync(file, 'utf8');
52
55
  let contentPrev = '';
53
56
  try {
54
- // Attempt to fetch the file content from the previous commit state for comparison
55
57
  contentPrev = execSync(`git show HEAD~1:"${file}"`, {
56
58
  encoding: 'utf8',
57
59
  stdio: ['ignore', 'pipe', 'ignore']
@@ -59,11 +61,10 @@ try {
59
61
  } catch (prevErr) {
60
62
  }
61
63
 
64
+ // Check if the file has been modified beyond just comments [ds]
62
65
  if (contentPrev) {
63
- // Strip comments from head and previous versions to detect if logic actually changed
64
- const cleanHead = spliceComments(contentHead, [], 'clean', ext);
65
- const cleanPrev = spliceComments(contentPrev, [], 'clean', ext);
66
- // Skip processing if only comments were modified in the commit
66
+ const cleanHead = spliceComments(contentHead, [], 'prune', ext);
67
+ const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
67
68
  if (cleanHead === cleanPrev) {
68
69
  console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
69
70
  continue;
@@ -72,9 +73,9 @@ try {
72
73
  } catch (cleanErr) {
73
74
  }
74
75
 
76
+ // Attempt to comment the file using the cli script [ds]
75
77
  console.log(`[devsplain] Automatically commenting file: ${file}`);
76
78
  try {
77
- // Execute the CLI generator for the specific file
78
79
  const cliPath = path.join(__dirname, 'cli.js');
79
80
  execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
80
81
  commentedAny = true;
@@ -83,7 +84,7 @@ try {
83
84
  }
84
85
  }
85
86
 
86
- // If changes were made by the generator, stage and commit the result back to the repository
87
+ /** If any files were commented, stage and commit the changes [ds] */
87
88
  if (commentedAny) {
88
89
  const status = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
89
90
  if (status.length > 0) {
package/bin/setup-hook.js CHANGED
@@ -3,13 +3,9 @@ const path = require('path');
3
3
  const { execSync } = require('child_process');
4
4
  const readline = require('readline');
5
5
 
6
- /**
7
- * Orchestrates the installation of Git pre-commit and post-commit hooks.
8
- * Detects the local .git directory and configures hooks with user-specified mode.
9
- */
6
+ /** Installs Git hooks for the repository. [ds] */
10
7
  async function installHooks() {
11
8
  try {
12
- // Determine the path to the .git directory
13
9
  const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
14
10
  const hooksDir = path.join(gitDir, 'hooks');
15
11
  if (!fs.existsSync(hooksDir)) {
@@ -17,15 +13,16 @@ async function installHooks() {
17
13
  }
18
14
 
19
15
  let modeChoice = '1';
20
- // Interact with user via terminal to select documentation verbosity
16
+ // Check if process is running in a TTY to prompt for user input [ds]
21
17
  if (process.stdout.isTTY) {
22
18
  const rl = readline.createInterface({
23
19
  input: process.stdin,
24
20
  output: process.stdout
25
21
  });
26
- // Promisify readline to allow async flow control
22
+ // Create a readline interface for user input [ds]
27
23
  const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
28
24
 
25
+ // Display a menu for the user to select the default commenting mode [ds]
29
26
  console.log('\nSelect default commenting mode for Git commits:');
30
27
  console.log('1. Balanced (mix of JSDoc and sparse inline comments)');
31
28
  console.log('2. Light (JSDoc block comments above functions only)');
@@ -35,7 +32,7 @@ async function installHooks() {
35
32
  rl.close();
36
33
  }
37
34
 
38
- // Map selected mode to command-line arguments for the post-commit script
35
+ // Determine the command line arguments based on the chosen mode [ds]
39
36
  let modeArgs = '';
40
37
  if (modeChoice === '2') {
41
38
  modeArgs = ' --light';
@@ -43,23 +40,25 @@ async function installHooks() {
43
40
  modeArgs = ' --full';
44
41
  }
45
42
 
46
- // Create the executable pre-commit script
43
+ // Define the path to the pre-commit hook file [ds]
47
44
  const preCommitHookPath = path.join(hooksDir, 'pre-commit');
48
45
  const preCommitContent = `#!/bin/sh
49
46
  # devsplain native pre-commit hook
50
- echo "Running pre-commit tests..."
51
- npm test || exit 1
47
+ if [ -f package.json ] && grep -q '"test"' package.json 2>/dev/null; then
48
+ echo "Running pre-commit tests..."
49
+ npm test || exit 1
50
+ fi
52
51
  `;
53
52
  fs.writeFileSync(preCommitHookPath, preCommitContent);
53
+ // Attempt to set the execute permissions for the pre-commit hook file [ds]
54
54
  try {
55
- // Ensure the hook file is executable
56
55
  fs.chmodSync(preCommitHookPath, 0o755);
57
56
  } catch (err) {}
58
57
 
59
- // Locate the source script for post-commit actions
58
+ // Define the path to the post-commit script [ds]
60
59
  const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
61
60
 
62
- // Create the executable post-commit script that calls the documentation engine
61
+ // Define the path to the post-commit hook file [ds]
63
62
  const postCommitHookPath = path.join(hooksDir, 'post-commit');
64
63
  const postCommitContent = `#!/bin/sh
65
64
  # devsplain native post-commit hook
@@ -67,18 +66,22 @@ echo "Auto-generating comments for files in the last commit..."
67
66
  node "${postCommitScript}"${modeArgs} || exit 1
68
67
  `;
69
68
  fs.writeFileSync(postCommitHookPath, postCommitContent);
69
+ // Attempt to set the execute permissions for the post-commit hook file [ds]
70
70
  try {
71
- // Ensure the hook file is executable
72
71
  fs.chmodSync(postCommitHookPath, 0o755);
73
72
  } catch (err) {}
74
73
 
74
+ // Display a success message if the hooks are installed successfully [ds]
75
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]
76
77
  } catch (e) {
77
78
  console.warn('Warning: Could not set up Git hooks (not inside a git repository or git command missing).');
78
79
  }
79
80
  }
80
81
 
82
+ // Check if this script is being run directly (not being required as a module) [ds]
81
83
  if (require.main === module) {
82
84
  installHooks();
83
85
  }
86
+ // Export the installHooks function as a module [ds]
84
87
  module.exports = installHooks;
package/lib/config.js CHANGED
@@ -4,14 +4,8 @@ const os = require('os');
4
4
  const readline = require('readline');
5
5
  const configPath = path.join(os.homedir(), '.devsplainrc');
6
6
 
7
- /**
8
- * Retrieves configuration from environment variables or a local config file.
9
- * Runs a setup wizard if configuration is missing or forced.
10
- * @param {boolean} forceWizard - Whether to force the interactive setup.
11
- * @returns {Promise<Object>} The configuration object.
12
- */
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] */
13
8
  async function getConfig(forceWizard = false) {
14
- // Priority 1: Check environment variables for configuration
15
9
  if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
16
10
  const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
17
11
  const model = process.env.DEVSPLAIN_MODEL || (provider === 'gemini' ? 'gemini-2.0-flash' : 'llama-3.3-70b-versatile');
@@ -24,33 +18,34 @@ async function getConfig(forceWizard = false) {
24
18
  };
25
19
  }
26
20
 
27
- // Priority 2: Check for existing config file or run setup wizard
21
+ // Check if configuration file exists or if forceWizard flag is set [ds]
28
22
  if (!fs.existsSync(configPath) || forceWizard) {
29
23
  const rl = readline.createInterface({
30
24
  input: process.stdin,
31
25
  output: process.stdout
32
26
  });
33
- // Promisify readline to allow async flow
34
27
  const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
35
28
 
36
29
  let config = null;
37
30
  let confirmed = false;
38
31
 
39
- // Loop until user confirms configuration choices
32
+ // Continuously prompt user for configuration details until confirmation [ds]
40
33
  while (!confirmed) {
41
34
  let baseUrl = "";
42
35
  let model = "";
43
36
  let provider = "";
44
37
 
38
+ // Display available AI provider options [ds]
45
39
  console.log("\nWhich AI Provider Do You want to use?");
46
40
  console.log("1. Groq (Free, Fast, Llama-3)");
47
41
  console.log("2. Gemini (Free Tier)");
48
42
  console.log("3. OpenAI (Paid)");
49
43
  console.log("4. Custom (Ollama, local, etc)");
50
44
 
45
+ // Get user's selected AI provider option [ds]
51
46
  const choice = await askQuestion("Select (1-4): ");
52
47
 
53
- // Handle specific provider setup logic
48
+ // Handle selected AI provider option [ds]
54
49
  if (choice === '1') {
55
50
  provider = 'groq';
56
51
  baseUrl = 'https://api.groq.com/openai';
@@ -78,16 +73,18 @@ async function getConfig(forceWizard = false) {
78
73
  continue;
79
74
  }
80
75
 
76
+ // Get API key from user [ds]
81
77
  const apiKey = await askQuestion("Paste your API key (leave blank for local models): ");
82
78
 
79
+ // Display configuration summary [ds]
83
80
  console.log("\n--- Configuration Summary ---");
84
81
  console.log(`Provider: ${provider}`);
85
82
  console.log(`Model: ${model}`);
86
83
  console.log(`Base URL: ${baseUrl || 'N/A'}`);
87
- // Mask API key for security in display
88
84
  console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
89
85
  console.log("-----------------------------\n");
90
86
 
87
+ // Confirm configuration with user [ds]
91
88
  const confirm = await askQuestion("Does this look correct? (y/n, default: y): ");
92
89
  if (confirm.toLowerCase() === 'y' || confirm.trim() === '') {
93
90
  config = {
@@ -104,10 +101,10 @@ async function getConfig(forceWizard = false) {
104
101
 
105
102
  rl.close();
106
103
 
107
- // Save finalized config to home directory
104
+ // Write configuration to file [ds]
108
105
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
109
106
  try {
110
- // Ensure config file is not readable by other users (POSIX only)
107
+ // Set file permissions to prevent unauthorized access [ds]
111
108
  if (process.platform !== 'win32') {
112
109
  fs.chmodSync(configPath, 0o600);
113
110
  }
@@ -116,10 +113,11 @@ async function getConfig(forceWizard = false) {
116
113
 
117
114
  return config;
118
115
  } else {
119
- // Priority 3: Load from existing file
116
+ // Read existing configuration from file [ds]
120
117
  const rawData = fs.readFileSync(configPath, 'utf8');
121
118
  return JSON.parse(rawData);
122
119
  }
123
120
  }
124
121
 
122
+ // Export the getConfig function [ds]
125
123
  module.exports = { getConfig };
package/lib/llm.js CHANGED
@@ -1,14 +1,7 @@
1
- /**
2
- * Fetches a URL with retry logic, timeout handling, and exponential backoff.
3
- * @param {string} url - The URL to fetch.
4
- * @param {object} options - Fetch options.
5
- * @param {number} maxRetries - Maximum number of retries.
6
- * @param {number} initialDelay - Initial delay in ms for backoff.
7
- */
1
+ /** Fetches a URL with retry functionality [ds] */
8
2
  async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000) {
9
3
  let lastError;
10
4
  for (let attempt = 0; attempt < maxRetries; attempt++) {
11
- // AbortController to handle request timeouts
12
5
  const controller = new AbortController();
13
6
  const timeoutId = setTimeout(() => controller.abort(), 15000);
14
7
  try {
@@ -17,11 +10,9 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
17
10
  signal: controller.signal
18
11
  });
19
12
  clearTimeout(timeoutId);
20
- // If response is valid, return immediately
21
13
  if (response.ok) {
22
14
  return response;
23
15
  }
24
- // Retry on rate limit (429) or server-side errors (500-599)
25
16
  if (response.status === 429 || (response.status >= 500 && response.status < 600)) {
26
17
  lastError = new Error(`HTTP Error ${response.status}: ${response.statusText}`);
27
18
  } else {
@@ -29,7 +20,6 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
29
20
  }
30
21
  } catch (err) {
31
22
  clearTimeout(timeoutId);
32
- // Handle specific timeout case separately
33
23
  if (err.name === 'AbortError') {
34
24
  lastError = new Error("Request timed out after 15 seconds");
35
25
  } else {
@@ -38,7 +28,6 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
38
28
  }
39
29
 
40
30
  if (attempt < maxRetries - 1) {
41
- // Calculate exponential backoff delay
42
31
  const backoffDelay = initialDelay * Math.pow(2, attempt);
43
32
  console.warn(`[devsplain] AI request failed. Retrying in ${backoffDelay}ms... (Attempt ${attempt + 1}/${maxRetries})`);
44
33
  await new Promise(resolve => setTimeout(resolve, backoffDelay));
@@ -47,15 +36,8 @@ async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000)
47
36
  throw lastError;
48
37
  }
49
38
 
50
- /**
51
- * Generates documentation for code by sending it to an LLM provider.
52
- * @param {string} code - The source code to document.
53
- * @param {string} language - The programming language.
54
- * @param {object} config - Provider configuration (apiKey, model, etc.).
55
- * @param {string} mode - Operation mode (default, clean, light, full).
56
- */
39
+ /** Gets comments for the given code [ds] */
57
40
  async function getComments(code, language, config, mode = 'default') {
58
- // Split into lines and prepend line numbers for LLM context
59
41
  const lines = code.split(/\r?\n/);
60
42
  const numberedCode = lines.map((line, index) => `${index + 1}: ${line}`).join('\n');
61
43
 
@@ -110,9 +92,10 @@ ${numberedCode}
110
92
  `.trim();
111
93
  }
112
94
 
95
+ // Initialize text response [ds]
113
96
  let textResponse = "";
114
97
 
115
- // Branching logic for different LLM providers
98
+ // Check if provider is Gemini [ds]
116
99
  if (config.provider === 'gemini') {
117
100
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
118
101
  let data;
@@ -135,6 +118,7 @@ ${numberedCode}
135
118
  }
136
119
  textResponse = data.candidates[0].content.parts[0].text;
137
120
  }
121
+ // Otherwise, use a different provider [ds]
138
122
  else {
139
123
  const url = `${config.baseUrl}/v1/chat/completions`;
140
124
  let data;
@@ -164,7 +148,7 @@ ${numberedCode}
164
148
  textResponse = data.choices[0].message.content;
165
149
  }
166
150
 
167
- // Extract JSON array from LLM response text
151
+ // Clean up the text response [ds]
168
152
  let cleanText = textResponse.trim();
169
153
  const start = cleanText.indexOf('[');
170
154
  const end = cleanText.lastIndexOf(']');
@@ -172,8 +156,8 @@ ${numberedCode}
172
156
  cleanText = cleanText.substring(start, end + 1);
173
157
  }
174
158
 
159
+ // Parse the response as JSON [ds]
175
160
  let parsed;
176
- // Validate response format and schema integrity
177
161
  try {
178
162
  parsed = JSON.parse(cleanText);
179
163
  } catch (e) {
@@ -184,6 +168,7 @@ ${numberedCode}
184
168
  throw new Error("Schema Error: LLM response is not a JSON array.");
185
169
  }
186
170
 
171
+ // Validate the parsed response [ds]
187
172
  for (const item of parsed) {
188
173
  if (typeof item !== 'object' || item === null) {
189
174
  throw new Error("Schema Error: Array elements must be objects.");
@@ -202,7 +187,6 @@ ${numberedCode}
202
187
  }
203
188
 
204
189
  const trimmedComment = item.comment.trim();
205
- // Sanity check for valid comment syntax
206
190
  const startsWithCommentMarker =
207
191
  trimmedComment.startsWith('//') ||
208
192
  trimmedComment.startsWith('/*') ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "1.5.6",
3
+ "version": "1.7.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",