devsplain 1.5.5 → 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
@@ -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 */
13
+ /** Checks if the current Git repository is dirty by inspecting status. */
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,8 @@ function isGitDirty() {
23
23
  return false;
24
24
  }
25
25
 
26
- /** Determines if a line is within a string literal in the source code */
26
+ /** Determines if a specific line index is within a string literal (handling quotes/backticks). [ds] */
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';
29
30
  let inBacktick = false;
@@ -97,7 +98,8 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
97
98
  return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
98
99
  }
99
100
 
100
- /** Analyzes source code to identify comments and code blocks */
101
+ /** Parses a file to identify pure comments and block structures. [ds] */
102
+ /** Parses a file to identify pure comments and block structures. */
101
103
  function analyzeComments(lines, ext = '') {
102
104
  const isPython = ext.toLowerCase() === '.py';
103
105
  const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
@@ -248,7 +250,8 @@ function analyzeComments(lines, ext = '') {
248
250
  return analysis;
249
251
  }
250
252
 
251
- /** Applies or removes comments from source data based on a specified mode */
253
+ /** Splices comments into code or cleans existing ones, with safety checks. [ds] */
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');
254
257
  const lineEnding = hasCRLF ? '\r\n' : '\n';
@@ -258,19 +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 (analysis[i].isPureComment) {
268
- finalDeletions.add(lineNum);
269
- } else if (analysis[i].commentStartIndex !== -1) {
270
- annotated[i].text = originalLines[i].slice(0, analysis[i].commentStartIndex).trimEnd();
300
+ const lineStr = originalLines[i];
301
+ const lineAnalysis = analysis[i];
302
+
303
+ if (lineStr.trim().startsWith('#!')) {
304
+ continue;
305
+ }
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
+ }
271
326
  }
272
327
  }
273
328
 
329
+
274
330
  for (const c of validComments) {
275
331
  const lineIdx = c.line - 1;
276
332
  if (lineIdx >= 0 && lineIdx < originalLines.length) {
@@ -321,9 +377,23 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
321
377
  const indentMatch = targetLine.match(/^([ \t]*)/);
322
378
  const indentation = indentMatch ? indentMatch[1] : '';
323
379
 
324
- const commentLines = c.comment.split(/\r?\n/).map(line => {
325
- const trimmed = line.trimStart();
380
+ const commentLines = c.comment.split(/\r?\n/).map((line, idx) => {
381
+ let trimmed = line.trimStart();
326
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
+
327
397
  if (trimmed.startsWith('*') && !trimmed.startsWith('*/') && !trimmed.startsWith('/*')) {
328
398
  return indentation + ' ' + trimmed;
329
399
  }
@@ -345,12 +415,17 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
345
415
  if (text === originalLine) {
346
416
  return true;
347
417
  }
348
- if (mode === 'clean' && analysis) {
418
+ if ((mode === 'clean' || mode === 'prune') && analysis) {
349
419
  const lineAnalysis = analysis[origIdx];
350
420
  if (lineAnalysis && lineAnalysis.commentStartIndex !== -1 && !lineAnalysis.isPureComment) {
351
- const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
352
- if (text === expectedStripped) {
353
- 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
+ }
354
429
  }
355
430
  }
356
431
  }
@@ -373,7 +448,8 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
373
448
  return annotated.map(line => line.text).join(lineEnding);
374
449
  }
375
450
 
376
- /** Main CLI execution logic */
451
+ /** Main entry point for the CLI tool. [ds] */
452
+ /** Main entry point for the CLI tool. */
377
453
  async function runCLI() {
378
454
  rl = readline.createInterface({ input: process.stdin, output: process.stdout });
379
455
  askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
@@ -392,6 +468,8 @@ Options:
392
468
  --full Add detailed JSDoc/block comments and inline comments
393
469
  --dry-run Preview comments without writing to file
394
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
395
473
  --provider <name> Override AI provider (gemini, groq, openai, custom)
396
474
  --model <name> Override AI model name
397
475
  --api-key <key> Override API key for the provider
@@ -426,7 +504,7 @@ Options:
426
504
  return;
427
505
  }
428
506
 
429
- // Helper to retrieve specific CLI argument values
507
+ // Helper to extract flag values from command line arguments [ds]
430
508
  const getArgValue = (flag) => {
431
509
  const index = args.indexOf(flag);
432
510
  if (index !== -1 && index + 1 < args.length) {
@@ -459,6 +537,7 @@ Options:
459
537
  if (args.includes('--light')) mode = 'light';
460
538
  if (args.includes('--full')) mode = 'full';
461
539
  if (args.includes('--clean')) mode = 'clean';
540
+ if (args.includes('--prune')) mode = 'prune';
462
541
  const isDryRun = args.includes('--dry-run');
463
542
  const isForce = args.includes('--force');
464
543
 
@@ -491,7 +570,8 @@ Options:
491
570
  let successCount = 0;
492
571
  let failCount = 0;
493
572
 
494
- /** Recursively processes files or directories to apply comments */
573
+ /** Recursively processes files or directories to apply AI-generated comments. [ds] */
574
+ /** Recursively processes files or directories to apply AI-generated comments. */
495
575
  async function processPath(targetPath) {
496
576
  const stats = fs.statSync(targetPath);
497
577
 
@@ -504,7 +584,6 @@ Options:
504
584
  '.vscode', '.idea', 'coverage'
505
585
  ];
506
586
 
507
- // Skip common dependency and configuration folders
508
587
  if (ignoredFolders.includes(folderName)) {
509
588
  return;
510
589
  }
@@ -539,7 +618,6 @@ Options:
539
618
  try {
540
619
  let comments = [];
541
620
  let commentedCode;
542
- // Perform comment processing: Clean existing, then inject new comments via LLM
543
621
  if (mode !== 'clean') {
544
622
  const cleanData = spliceComments(data, [], 'clean', ext);
545
623
  comments = await getComments(cleanData, filename, config, mode);
@@ -590,7 +668,8 @@ Options:
590
668
  rl.close();
591
669
  }
592
670
 
593
- // Initialize the CLI application if executed as a script
671
+ // Check if the script is run directly vs required as a module [ds]
672
+ // Check if the script is run directly vs required as a module
594
673
  if (require.main === module) {
595
674
  runCLI().catch(err => {
596
675
  console.error(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.5",
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",