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 +22 -4
- package/bin/cli.js +109 -22
- package/bin/post-commit.js +15 -14
- package/bin/setup-hook.js +18 -15
- package/lib/config.js +13 -15
- package/lib/llm.js +8 -24
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
312
|
+
const lineStr = originalLines[i];
|
|
313
|
+
const lineAnalysis = analysis[i];
|
|
314
|
+
|
|
315
|
+
if (lineStr.trim().startsWith('#!')) {
|
|
268
316
|
continue;
|
|
269
317
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
/**
|
|
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, [],
|
|
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
|
-
//
|
|
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);
|
package/bin/post-commit.js
CHANGED
|
@@ -3,40 +3,43 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spliceComments } = require('./cli');
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/** Attempts to execute the post-commit hook [ds] */
|
|
7
7
|
try {
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
104
|
+
// Write configuration to file [ds]
|
|
108
105
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
109
106
|
try {
|
|
110
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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