devsplain 1.5.6 → 1.6.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
@@ -8,7 +8,8 @@ An industrial-grade, agent-agnostic CLI tool that automatically adds JSDoc and i
8
8
 
9
9
  - **Mathematical Safety Invariants**: Uses an index-preserving splicing engine. Your functional code is mathematically verified to remain identical before and after commenting.
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.
@@ -78,7 +79,8 @@ devsplain <file-or-directory> [options]
78
79
  | `--full` | Aggressive commenting. Explains complex logic blocks line-by-line inside functions. |
79
80
  | `--dry-run` | Preview comments in the terminal without writing to files. Prompts for manual save confirmation. |
80
81
  | `--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. |
82
+ | `--clean` | Scrubber mode. Deterministically removes only devsplain-generated comments tagged with `[ds]`, preserving your manual comments. |
83
+ | `--prune` | Destructive scrubber mode. Removes ALL comments and docstrings from source files, including your own manual comments. |
82
84
  | `--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
85
  | `--model <name>` | Temporary one-off override for the model name for this command run only. |
84
86
  | `--api-key <key>` | Temporary one-off override for the API key for this command run only. |
@@ -97,9 +99,12 @@ devsplain src/index.js --light
97
99
  # Deep logic commenting on a folder (skips node_modules, .git, etc.)
98
100
  devsplain src/ --full
99
101
 
100
- # Clean and scrub comments from your codebase locally without AI calls
102
+ # Clean and scrub AI-generated comments locally without API calls
101
103
  devsplain lib/ --clean
102
104
 
105
+ # Destructively remove ALL comments (both AI and manual) from a folder
106
+ devsplain lib/ --prune
107
+
103
108
  # Headless run using overriding credentials
104
109
  devsplain src/utils.ts --provider gemini --model gemini-2.0-flash --api-key YOUR_KEY
105
110
  ```
package/bin/cli.js CHANGED
@@ -23,6 +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). [ds] */
26
27
  /** Determines if a specific line index is within a string literal (handling quotes/backticks). */
27
28
  function isLineInsideString(lines, targetLineIndex, ext = '') {
28
29
  const isPython = ext.toLowerCase() === '.py';
@@ -97,6 +98,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
97
98
  return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
98
99
  }
99
100
 
101
+ /** Parses a file to identify pure comments and block structures. [ds] */
100
102
  /** Parses a file to identify pure comments and block structures. */
101
103
  function analyzeComments(lines, ext = '') {
102
104
  const isPython = ext.toLowerCase() === '.py';
@@ -248,6 +250,7 @@ function analyzeComments(lines, ext = '') {
248
250
  return analysis;
249
251
  }
250
252
 
253
+ /** Splices comments into code or cleans existing ones, with safety checks. [ds] */
251
254
  /** Splices comments into code or cleans existing ones, with safety checks. */
252
255
  function spliceComments(data, comments, mode = 'default', ext = '') {
253
256
  const hasCRLF = data.includes('\r\n');
@@ -258,22 +261,72 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
258
261
 
259
262
  const annotated = originalLines.map((text, index) => ({ text, originalIndex: index }));
260
263
  let analysis = null;
264
+ let dsBlocks = new Set();
261
265
 
262
- if (mode === 'clean') {
266
+ if (mode === 'clean' || mode === 'prune') {
263
267
  analysis = analyzeComments(originalLines, ext);
264
268
  const finalDeletions = new Set();
269
+
270
+ if (mode === 'clean') {
271
+ let i = 0;
272
+ while (i < originalLines.length) {
273
+ if (analysis[i].isInsideBlock) {
274
+ let start = i;
275
+ let end = i;
276
+ while (end < originalLines.length && analysis[end].isInsideBlock) end++;
277
+
278
+ let blockStart = start - 1;
279
+ let blockEnd = end - 1;
280
+
281
+ let hasDs = false;
282
+ for (let k = blockStart; k <= blockEnd; k++) {
283
+ if (originalLines[k].includes('[ds]')) hasDs = true;
284
+ }
285
+
286
+ if (hasDs) {
287
+ for (let k = blockStart; k <= blockEnd; k++) {
288
+ dsBlocks.add(k + 1);
289
+ }
290
+ }
291
+ i = end;
292
+ } else {
293
+ i++;
294
+ }
295
+ }
296
+ }
297
+
265
298
  for (let i = 0; i < originalLines.length; i++) {
266
299
  const lineNum = i + 1;
267
- if (originalLines[i].trim().startsWith('#!')) {
300
+ const lineStr = originalLines[i];
301
+ const lineAnalysis = analysis[i];
302
+
303
+ if (lineStr.trim().startsWith('#!')) {
268
304
  continue;
269
305
  }
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();
306
+
307
+ if (mode === 'prune') {
308
+ if (lineAnalysis.isPureComment) {
309
+ finalDeletions.add(lineNum);
310
+ } else if (lineAnalysis.commentStartIndex !== -1) {
311
+ annotated[i].text = lineStr.slice(0, lineAnalysis.commentStartIndex).trimEnd();
312
+ }
313
+ } else if (mode === 'clean') {
314
+ const isDsBlockLine = dsBlocks.has(lineNum);
315
+ const hasDsInline = lineStr.includes('[ds]');
316
+
317
+ if (lineAnalysis.isPureComment) {
318
+ if (isDsBlockLine || hasDsInline) {
319
+ finalDeletions.add(lineNum);
320
+ }
321
+ } else if (lineAnalysis.commentStartIndex !== -1) {
322
+ if (isDsBlockLine || hasDsInline) {
323
+ annotated[i].text = lineStr.slice(0, lineAnalysis.commentStartIndex).trimEnd();
324
+ }
325
+ }
274
326
  }
275
327
  }
276
328
 
329
+
277
330
  for (const c of validComments) {
278
331
  const lineIdx = c.line - 1;
279
332
  if (lineIdx >= 0 && lineIdx < originalLines.length) {
@@ -324,9 +377,23 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
324
377
  const indentMatch = targetLine.match(/^([ \t]*)/);
325
378
  const indentation = indentMatch ? indentMatch[1] : '';
326
379
 
327
- const commentLines = c.comment.split(/\r?\n/).map(line => {
328
- const trimmed = line.trimStart();
380
+ const commentLines = c.comment.split(/\r?\n/).map((line, idx) => {
381
+ let trimmed = line.trimStart();
329
382
  if (!trimmed) return '';
383
+
384
+ const isSingleLine = trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('--');
385
+ const isBlockEnd = trimmed.endsWith('*/') || trimmed.endsWith('-->');
386
+
387
+ if (isSingleLine) {
388
+ trimmed = trimmed + ' [ds]';
389
+ } else if (idx === 0) {
390
+ if (isBlockEnd) {
391
+ trimmed = trimmed.replace(/(\*\/|-->)$/, '[ds] $1');
392
+ } else {
393
+ trimmed = trimmed + ' [ds]';
394
+ }
395
+ }
396
+
330
397
  if (trimmed.startsWith('*') && !trimmed.startsWith('*/') && !trimmed.startsWith('/*')) {
331
398
  return indentation + ' ' + trimmed;
332
399
  }
@@ -348,12 +415,17 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
348
415
  if (text === originalLine) {
349
416
  return true;
350
417
  }
351
- if (mode === 'clean' && analysis) {
418
+ if ((mode === 'clean' || mode === 'prune') && analysis) {
352
419
  const lineAnalysis = analysis[origIdx];
353
420
  if (lineAnalysis && lineAnalysis.commentStartIndex !== -1 && !lineAnalysis.isPureComment) {
354
- const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
355
- if (text === expectedStripped) {
356
- return true;
421
+ const isDsBlockLine = dsBlocks.has(origIdx + 1);
422
+ const hasDsInline = originalLine.includes('[ds]');
423
+
424
+ if (mode === 'prune' || (mode === 'clean' && (hasDsInline || isDsBlockLine))) {
425
+ const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
426
+ if (text === expectedStripped) {
427
+ return true;
428
+ }
357
429
  }
358
430
  }
359
431
  }
@@ -376,6 +448,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
376
448
  return annotated.map(line => line.text).join(lineEnding);
377
449
  }
378
450
 
451
+ /** Main entry point for the CLI tool. [ds] */
379
452
  /** Main entry point for the CLI tool. */
380
453
  async function runCLI() {
381
454
  rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -395,6 +468,8 @@ Options:
395
468
  --full Add detailed JSDoc/block comments and inline comments
396
469
  --dry-run Preview comments without writing to file
397
470
  --force Bypass the dirty Git tree safety check
471
+ --clean Scrub only devsplain-generated [ds] comments
472
+ --prune Destructively scrub ALL comments from files
398
473
  --provider <name> Override AI provider (gemini, groq, openai, custom)
399
474
  --model <name> Override AI model name
400
475
  --api-key <key> Override API key for the provider
@@ -429,6 +504,7 @@ Options:
429
504
  return;
430
505
  }
431
506
 
507
+ // Helper to extract flag values from command line arguments [ds]
432
508
  const getArgValue = (flag) => {
433
509
  const index = args.indexOf(flag);
434
510
  if (index !== -1 && index + 1 < args.length) {
@@ -461,6 +537,7 @@ Options:
461
537
  if (args.includes('--light')) mode = 'light';
462
538
  if (args.includes('--full')) mode = 'full';
463
539
  if (args.includes('--clean')) mode = 'clean';
540
+ if (args.includes('--prune')) mode = 'prune';
464
541
  const isDryRun = args.includes('--dry-run');
465
542
  const isForce = args.includes('--force');
466
543
 
@@ -493,6 +570,7 @@ Options:
493
570
  let successCount = 0;
494
571
  let failCount = 0;
495
572
 
573
+ /** Recursively processes files or directories to apply AI-generated comments. [ds] */
496
574
  /** Recursively processes files or directories to apply AI-generated comments. */
497
575
  async function processPath(targetPath) {
498
576
  const stats = fs.statSync(targetPath);
@@ -590,6 +668,7 @@ Options:
590
668
  rl.close();
591
669
  }
592
670
 
671
+ // Check if the script is run directly vs required as a module [ds]
593
672
  // Check if the script is run directly vs required as a module
594
673
  if (require.main === module) {
595
674
  runCLI().catch(err => {
@@ -3,14 +3,17 @@ 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 [ds] */
6
7
  /** Main execution block to detect changes and process documentation generation */
7
8
  try {
8
9
  // Prevent recursive loops if the previous commit was an automated documentation commit
10
+ // Retrieve the last commit message to prevent infinite loops during automated commits [ds]
9
11
  const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
10
12
  if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
11
13
  process.exit(0);
12
14
  }
13
15
 
16
+ // Get the list of file paths modified in the current commit [ds]
14
17
  const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
15
18
  if (!changedFilesStr) {
16
19
  process.exit(0);
@@ -18,6 +21,7 @@ try {
18
21
  // Retrieve list of files modified in the latest commit
19
22
  const changedFiles = changedFilesStr.split(/\r?\n/);
20
23
 
24
+ // Define allowed file types for documentation processing [ds]
21
25
  const validExtensions = [
22
26
  '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
23
27
  '.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
@@ -37,6 +41,7 @@ try {
37
41
  console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
38
42
 
39
43
  // Parse command line arguments to determine documentation verbosity mode
44
+ // Parse CLI arguments for verbosity preferences [ds]
40
45
  const args = process.argv.slice(2);
41
46
  let modeFlag = '';
42
47
  if (args.includes('--light')) modeFlag = ' --light';
@@ -51,6 +56,7 @@ try {
51
56
  const contentHead = fs.readFileSync(file, 'utf8');
52
57
  let contentPrev = '';
53
58
  try {
59
+ // Attempt to retrieve the file version from the previous commit for change comparison [ds]
54
60
  // Attempt to fetch the file content from the previous commit state for comparison
55
61
  contentPrev = execSync(`git show HEAD~1:"${file}"`, {
56
62
  encoding: 'utf8',
@@ -60,9 +66,10 @@ try {
60
66
  }
61
67
 
62
68
  if (contentPrev) {
69
+ // Prune comments to isolate actual code changes and ignore documentation-only commits [ds]
63
70
  // 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);
71
+ const cleanHead = spliceComments(contentHead, [], 'prune', ext);
72
+ const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
66
73
  // Skip processing if only comments were modified in the commit
67
74
  if (cleanHead === cleanPrev) {
68
75
  console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
@@ -75,6 +82,7 @@ try {
75
82
  console.log(`[devsplain] Automatically commenting file: ${file}`);
76
83
  try {
77
84
  // Execute the CLI generator for the specific file
85
+ // Path to the underlying documentation generation engine [ds]
78
86
  const cliPath = path.join(__dirname, 'cli.js');
79
87
  execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
80
88
  commentedAny = true;
@@ -83,6 +91,7 @@ try {
83
91
  }
84
92
  }
85
93
 
94
+ // Stage changes back to the repo if new documentation was generated [ds]
86
95
  // If changes were made by the generator, stage and commit the result back to the repository
87
96
  if (commentedAny) {
88
97
  const status = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
package/bin/setup-hook.js CHANGED
@@ -5,25 +5,25 @@ const readline = require('readline');
5
5
 
6
6
  /**
7
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.
8
+ * Sets up necessary directories and writes hook files with user-selected configuration.
9
9
  */
10
10
  async function installHooks() {
11
11
  try {
12
- // Determine the path to the .git directory
12
+ // Determine the actual git directory path using git command line tool
13
13
  const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
14
14
  const hooksDir = path.join(gitDir, 'hooks');
15
+ // Ensure the hooks directory exists before attempting to write files
15
16
  if (!fs.existsSync(hooksDir)) {
16
17
  fs.mkdirSync(hooksDir, { recursive: true });
17
18
  }
18
19
 
19
20
  let modeChoice = '1';
20
- // Interact with user via terminal to select documentation verbosity
21
+ // Prompt the user for mode selection only if running in an interactive terminal session
21
22
  if (process.stdout.isTTY) {
22
23
  const rl = readline.createInterface({
23
24
  input: process.stdin,
24
25
  output: process.stdout
25
26
  });
26
- // Promisify readline to allow async flow control
27
27
  const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
28
28
 
29
29
  console.log('\nSelect default commenting mode for Git commits:');
@@ -35,7 +35,7 @@ async function installHooks() {
35
35
  rl.close();
36
36
  }
37
37
 
38
- // Map selected mode to command-line arguments for the post-commit script
38
+ // Map user input to CLI arguments for the post-commit script execution
39
39
  let modeArgs = '';
40
40
  if (modeChoice === '2') {
41
41
  modeArgs = ' --light';
@@ -43,23 +43,25 @@ async function installHooks() {
43
43
  modeArgs = ' --full';
44
44
  }
45
45
 
46
- // Create the executable pre-commit script
46
+ // Generate and write the pre-commit shell script to trigger tests before committing
47
47
  const preCommitHookPath = path.join(hooksDir, 'pre-commit');
48
48
  const preCommitContent = `#!/bin/sh
49
49
  # devsplain native pre-commit hook
50
- echo "Running pre-commit tests..."
51
- npm test || exit 1
50
+ if [ -f package.json ] && grep -q '"test"' package.json 2>/dev/null; then
51
+ echo "Running pre-commit tests..."
52
+ npm test || exit 1
53
+ fi
52
54
  `;
53
55
  fs.writeFileSync(preCommitHookPath, preCommitContent);
54
56
  try {
55
- // Ensure the hook file is executable
57
+ // Apply execute permissions to the hook file
56
58
  fs.chmodSync(preCommitHookPath, 0o755);
57
59
  } catch (err) {}
58
60
 
59
- // Locate the source script for post-commit actions
61
+ // Normalize the path for cross-platform compatibility when injecting into shell script
60
62
  const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
61
63
 
62
- // Create the executable post-commit script that calls the documentation engine
64
+ // Generate and write the post-commit shell script to execute the documentation generation
63
65
  const postCommitHookPath = path.join(hooksDir, 'post-commit');
64
66
  const postCommitContent = `#!/bin/sh
65
67
  # devsplain native post-commit hook
@@ -67,8 +69,8 @@ echo "Auto-generating comments for files in the last commit..."
67
69
  node "${postCommitScript}"${modeArgs} || exit 1
68
70
  `;
69
71
  fs.writeFileSync(postCommitHookPath, postCommitContent);
72
+ // Apply execute permissions to the hook file
70
73
  try {
71
- // Ensure the hook file is executable
72
74
  fs.chmodSync(postCommitHookPath, 0o755);
73
75
  } catch (err) {}
74
76
 
@@ -78,6 +80,7 @@ node "${postCommitScript}"${modeArgs} || exit 1
78
80
  }
79
81
  }
80
82
 
83
+ // Execute installation automatically if this script is run as the entry point
81
84
  if (require.main === module) {
82
85
  installHooks();
83
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "1.5.6",
3
+ "version": "1.6.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",