devsplain 1.6.0 → 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 +14 -1
- package/bin/cli.js +30 -22
- package/bin/post-commit.js +13 -21
- package/bin/setup-hook.js +14 -14
- package/lib/config.js +13 -15
- package/lib/llm.js +8 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ 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
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.
|
|
@@ -35,6 +35,19 @@ Many AI code formatters rewrite your code entirely, exposing you to logic regres
|
|
|
35
35
|
### String Literal Guardrails
|
|
36
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.
|
|
37
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
|
+
|
|
38
51
|
---
|
|
39
52
|
|
|
40
53
|
## Installation
|
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,8 +23,7 @@ function isGitDirty() {
|
|
|
23
23
|
return false;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
/** 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] */
|
|
28
27
|
function isLineInsideString(lines, targetLineIndex, ext = '') {
|
|
29
28
|
const isPython = ext.toLowerCase() === '.py';
|
|
30
29
|
let inBacktick = false;
|
|
@@ -98,8 +97,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
|
|
|
98
97
|
return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
/**
|
|
102
|
-
/** Parses a file to identify pure comments and block structures. */
|
|
100
|
+
/** Analyzes comments in a list of code lines [ds] */
|
|
103
101
|
function analyzeComments(lines, ext = '') {
|
|
104
102
|
const isPython = ext.toLowerCase() === '.py';
|
|
105
103
|
const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
|
|
@@ -135,12 +133,23 @@ function analyzeComments(lines, ext = '') {
|
|
|
135
133
|
j++;
|
|
136
134
|
continue;
|
|
137
135
|
}
|
|
136
|
+
// Check for comment start index in non-Python files [ds]
|
|
138
137
|
if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
|
|
139
138
|
if (isPython) {
|
|
140
139
|
if (line[j] === '#') {
|
|
141
140
|
commentStartIndex = j;
|
|
142
141
|
break;
|
|
143
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
|
+
}
|
|
144
153
|
} else if (isHTML) {
|
|
145
154
|
if (line.slice(j, j + 4) === '<!--') {
|
|
146
155
|
commentStartIndex = j;
|
|
@@ -148,6 +157,12 @@ function analyzeComments(lines, ext = '') {
|
|
|
148
157
|
j += 4;
|
|
149
158
|
continue;
|
|
150
159
|
}
|
|
160
|
+
if (line.slice(j, j + 2) === '/*') {
|
|
161
|
+
commentStartIndex = j;
|
|
162
|
+
inBlockJS = true;
|
|
163
|
+
j += 2;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
151
166
|
if (line.slice(j, j + 2) === '//') {
|
|
152
167
|
commentStartIndex = j;
|
|
153
168
|
break;
|
|
@@ -184,6 +199,7 @@ function analyzeComments(lines, ext = '') {
|
|
|
184
199
|
continue;
|
|
185
200
|
}
|
|
186
201
|
}
|
|
202
|
+
// Check for string literals in non-Python files [ds]
|
|
187
203
|
} else {
|
|
188
204
|
if (!inSingle && !inDouble) {
|
|
189
205
|
if (line[j] === '`') {
|
|
@@ -223,6 +239,7 @@ function analyzeComments(lines, ext = '') {
|
|
|
223
239
|
}
|
|
224
240
|
}
|
|
225
241
|
}
|
|
242
|
+
// Increment character index [ds]
|
|
226
243
|
j++;
|
|
227
244
|
}
|
|
228
245
|
if (!isPython) {
|
|
@@ -250,8 +267,7 @@ function analyzeComments(lines, ext = '') {
|
|
|
250
267
|
return analysis;
|
|
251
268
|
}
|
|
252
269
|
|
|
253
|
-
/** Splices comments into
|
|
254
|
-
/** Splices comments into code or cleans existing ones, with safety checks. */
|
|
270
|
+
/** Splices comments into a list of code lines [ds] */
|
|
255
271
|
function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
256
272
|
const hasCRLF = data.includes('\r\n');
|
|
257
273
|
const lineEnding = hasCRLF ? '\r\n' : '\n';
|
|
@@ -266,7 +282,6 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
|
266
282
|
if (mode === 'clean' || mode === 'prune') {
|
|
267
283
|
analysis = analyzeComments(originalLines, ext);
|
|
268
284
|
const finalDeletions = new Set();
|
|
269
|
-
|
|
270
285
|
if (mode === 'clean') {
|
|
271
286
|
let i = 0;
|
|
272
287
|
while (i < originalLines.length) {
|
|
@@ -274,15 +289,12 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
|
274
289
|
let start = i;
|
|
275
290
|
let end = i;
|
|
276
291
|
while (end < originalLines.length && analysis[end].isInsideBlock) end++;
|
|
277
|
-
|
|
278
292
|
let blockStart = start - 1;
|
|
279
293
|
let blockEnd = end - 1;
|
|
280
|
-
|
|
281
294
|
let hasDs = false;
|
|
282
295
|
for (let k = blockStart; k <= blockEnd; k++) {
|
|
283
296
|
if (originalLines[k].includes('[ds]')) hasDs = true;
|
|
284
297
|
}
|
|
285
|
-
|
|
286
298
|
if (hasDs) {
|
|
287
299
|
for (let k = blockStart; k <= blockEnd; k++) {
|
|
288
300
|
dsBlocks.add(k + 1);
|
|
@@ -420,7 +432,6 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
|
420
432
|
if (lineAnalysis && lineAnalysis.commentStartIndex !== -1 && !lineAnalysis.isPureComment) {
|
|
421
433
|
const isDsBlockLine = dsBlocks.has(origIdx + 1);
|
|
422
434
|
const hasDsInline = originalLine.includes('[ds]');
|
|
423
|
-
|
|
424
435
|
if (mode === 'prune' || (mode === 'clean' && (hasDsInline || isDsBlockLine))) {
|
|
425
436
|
const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
|
|
426
437
|
if (text === expectedStripped) {
|
|
@@ -448,8 +459,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
|
448
459
|
return annotated.map(line => line.text).join(lineEnding);
|
|
449
460
|
}
|
|
450
461
|
|
|
451
|
-
/**
|
|
452
|
-
/** Main entry point for the CLI tool. */
|
|
462
|
+
/** Runs the CLI interface for the commenting tool [ds] */
|
|
453
463
|
async function runCLI() {
|
|
454
464
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
455
465
|
askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
@@ -504,7 +514,6 @@ Options:
|
|
|
504
514
|
return;
|
|
505
515
|
}
|
|
506
516
|
|
|
507
|
-
// Helper to extract flag values from command line arguments [ds]
|
|
508
517
|
const getArgValue = (flag) => {
|
|
509
518
|
const index = args.indexOf(flag);
|
|
510
519
|
if (index !== -1 && index + 1 < args.length) {
|
|
@@ -570,9 +579,8 @@ Options:
|
|
|
570
579
|
let successCount = 0;
|
|
571
580
|
let failCount = 0;
|
|
572
581
|
|
|
573
|
-
/** Recursively processes files or directories to apply AI-generated comments. [ds] */
|
|
574
|
-
/** Recursively processes files or directories to apply AI-generated comments. */
|
|
575
582
|
async function processPath(targetPath) {
|
|
583
|
+
// Process a directory or file path [ds]
|
|
576
584
|
const stats = fs.statSync(targetPath);
|
|
577
585
|
|
|
578
586
|
if (stats.isDirectory()) {
|
|
@@ -581,7 +589,8 @@ Options:
|
|
|
581
589
|
'node_modules', '.git', 'dist', 'build', 'out',
|
|
582
590
|
'.next', '.nuxt', '.svelte-kit',
|
|
583
591
|
'venv', 'env', '.venv',
|
|
584
|
-
'.vscode', '.idea', 'coverage'
|
|
592
|
+
'.vscode', '.idea', 'coverage',
|
|
593
|
+
'tests', '__tests__', 'fixtures'
|
|
585
594
|
];
|
|
586
595
|
|
|
587
596
|
if (ignoredFolders.includes(folderName)) {
|
|
@@ -618,12 +627,12 @@ Options:
|
|
|
618
627
|
try {
|
|
619
628
|
let comments = [];
|
|
620
629
|
let commentedCode;
|
|
621
|
-
if (mode !== 'clean') {
|
|
630
|
+
if (mode !== 'clean' && mode !== 'prune') {
|
|
622
631
|
const cleanData = spliceComments(data, [], 'clean', ext);
|
|
623
632
|
comments = await getComments(cleanData, filename, config, mode);
|
|
624
633
|
commentedCode = spliceComments(cleanData, comments, mode, ext);
|
|
625
634
|
} else {
|
|
626
|
-
commentedCode = spliceComments(data, [],
|
|
635
|
+
commentedCode = spliceComments(data, [], mode, ext);
|
|
627
636
|
}
|
|
628
637
|
if (isDryRun) {
|
|
629
638
|
console.log(`\n --- DRY RUN PREVIEW: ${filename} ---`);
|
|
@@ -668,8 +677,7 @@ Options:
|
|
|
668
677
|
rl.close();
|
|
669
678
|
}
|
|
670
679
|
|
|
671
|
-
//
|
|
672
|
-
// 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]
|
|
673
681
|
if (require.main === module) {
|
|
674
682
|
runCLI().catch(err => {
|
|
675
683
|
console.error(err);
|
package/bin/post-commit.js
CHANGED
|
@@ -3,45 +3,43 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spliceComments } = require('./cli');
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
/** Main execution block to detect changes and process documentation generation */
|
|
6
|
+
/** Attempts to execute the post-commit hook [ds] */
|
|
8
7
|
try {
|
|
9
|
-
//
|
|
10
|
-
// Retrieve the last commit message to prevent infinite loops during automated commits [ds]
|
|
8
|
+
// Get the last commit message [ds]
|
|
11
9
|
const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
|
|
12
10
|
if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
|
|
13
11
|
process.exit(0);
|
|
14
12
|
}
|
|
15
13
|
|
|
16
|
-
// Get the list of
|
|
14
|
+
// Get the list of changed files in the last commit [ds]
|
|
17
15
|
const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
|
|
18
16
|
if (!changedFilesStr) {
|
|
19
17
|
process.exit(0);
|
|
20
18
|
}
|
|
21
|
-
// Retrieve list of files modified in the latest commit
|
|
22
19
|
const changedFiles = changedFilesStr.split(/\r?\n/);
|
|
23
20
|
|
|
24
|
-
|
|
21
|
+
/** Defines a list of valid file extensions for commenting [ds] */
|
|
25
22
|
const validExtensions = [
|
|
26
23
|
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
27
24
|
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
28
25
|
'.swift', '.kt', '.dart', '.sh'
|
|
29
26
|
];
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
/** Filters the changed files based on valid extensions and existence [ds] */
|
|
32
29
|
const filesToComment = changedFiles.filter(file => {
|
|
33
30
|
const ext = path.extname(file).toLowerCase();
|
|
34
|
-
|
|
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;
|
|
35
33
|
});
|
|
36
34
|
|
|
37
35
|
if (filesToComment.length === 0) {
|
|
38
36
|
process.exit(0);
|
|
39
37
|
}
|
|
40
38
|
|
|
39
|
+
// Log the number of files to be commented [ds]
|
|
41
40
|
console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
|
|
42
41
|
|
|
43
|
-
// Parse command
|
|
44
|
-
// Parse CLI arguments for verbosity preferences [ds]
|
|
42
|
+
// Parse command-line arguments for commenting mode [ds]
|
|
45
43
|
const args = process.argv.slice(2);
|
|
46
44
|
let modeFlag = '';
|
|
47
45
|
if (args.includes('--light')) modeFlag = ' --light';
|
|
@@ -49,15 +47,13 @@ try {
|
|
|
49
47
|
|
|
50
48
|
let commentedAny = false;
|
|
51
49
|
|
|
52
|
-
|
|
50
|
+
/** Iterates through the files to be commented and attempts to comment each one [ds] */
|
|
53
51
|
for (const file of filesToComment) {
|
|
54
52
|
try {
|
|
55
53
|
const ext = path.extname(file).toLowerCase();
|
|
56
54
|
const contentHead = fs.readFileSync(file, 'utf8');
|
|
57
55
|
let contentPrev = '';
|
|
58
56
|
try {
|
|
59
|
-
// Attempt to retrieve the file version from the previous commit for change comparison [ds]
|
|
60
|
-
// Attempt to fetch the file content from the previous commit state for comparison
|
|
61
57
|
contentPrev = execSync(`git show HEAD~1:"${file}"`, {
|
|
62
58
|
encoding: 'utf8',
|
|
63
59
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
@@ -65,12 +61,10 @@ try {
|
|
|
65
61
|
} catch (prevErr) {
|
|
66
62
|
}
|
|
67
63
|
|
|
64
|
+
// Check if the file has been modified beyond just comments [ds]
|
|
68
65
|
if (contentPrev) {
|
|
69
|
-
// Prune comments to isolate actual code changes and ignore documentation-only commits [ds]
|
|
70
|
-
// Strip comments from head and previous versions to detect if logic actually changed
|
|
71
66
|
const cleanHead = spliceComments(contentHead, [], 'prune', ext);
|
|
72
67
|
const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
|
|
73
|
-
// Skip processing if only comments were modified in the commit
|
|
74
68
|
if (cleanHead === cleanPrev) {
|
|
75
69
|
console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
|
|
76
70
|
continue;
|
|
@@ -79,10 +73,9 @@ try {
|
|
|
79
73
|
} catch (cleanErr) {
|
|
80
74
|
}
|
|
81
75
|
|
|
76
|
+
// Attempt to comment the file using the cli script [ds]
|
|
82
77
|
console.log(`[devsplain] Automatically commenting file: ${file}`);
|
|
83
78
|
try {
|
|
84
|
-
// Execute the CLI generator for the specific file
|
|
85
|
-
// Path to the underlying documentation generation engine [ds]
|
|
86
79
|
const cliPath = path.join(__dirname, 'cli.js');
|
|
87
80
|
execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
|
|
88
81
|
commentedAny = true;
|
|
@@ -91,8 +84,7 @@ try {
|
|
|
91
84
|
}
|
|
92
85
|
}
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
// 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] */
|
|
96
88
|
if (commentedAny) {
|
|
97
89
|
const status = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
|
|
98
90
|
if (status.length > 0) {
|
package/bin/setup-hook.js
CHANGED
|
@@ -3,29 +3,26 @@ 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
|
-
* Sets up necessary directories and writes hook files with user-selected configuration.
|
|
9
|
-
*/
|
|
6
|
+
/** Installs Git hooks for the repository. [ds] */
|
|
10
7
|
async function installHooks() {
|
|
11
8
|
try {
|
|
12
|
-
// Determine the actual git directory path using git command line tool
|
|
13
9
|
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
14
10
|
const hooksDir = path.join(gitDir, 'hooks');
|
|
15
|
-
// Ensure the hooks directory exists before attempting to write files
|
|
16
11
|
if (!fs.existsSync(hooksDir)) {
|
|
17
12
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
18
13
|
}
|
|
19
14
|
|
|
20
15
|
let modeChoice = '1';
|
|
21
|
-
//
|
|
16
|
+
// Check if process is running in a TTY to prompt for user input [ds]
|
|
22
17
|
if (process.stdout.isTTY) {
|
|
23
18
|
const rl = readline.createInterface({
|
|
24
19
|
input: process.stdin,
|
|
25
20
|
output: process.stdout
|
|
26
21
|
});
|
|
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,7 +40,7 @@ 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
|
|
@@ -53,15 +50,15 @@ if [ -f package.json ] && grep -q '"test"' package.json 2>/dev/null; then
|
|
|
53
50
|
fi
|
|
54
51
|
`;
|
|
55
52
|
fs.writeFileSync(preCommitHookPath, preCommitContent);
|
|
53
|
+
// Attempt to set the execute permissions for the pre-commit hook file [ds]
|
|
56
54
|
try {
|
|
57
|
-
// Apply execute permissions to the hook file
|
|
58
55
|
fs.chmodSync(preCommitHookPath, 0o755);
|
|
59
56
|
} catch (err) {}
|
|
60
57
|
|
|
61
|
-
//
|
|
58
|
+
// Define the path to the post-commit script [ds]
|
|
62
59
|
const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
|
|
63
60
|
|
|
64
|
-
//
|
|
61
|
+
// Define the path to the post-commit hook file [ds]
|
|
65
62
|
const postCommitHookPath = path.join(hooksDir, 'post-commit');
|
|
66
63
|
const postCommitContent = `#!/bin/sh
|
|
67
64
|
# devsplain native post-commit hook
|
|
@@ -69,19 +66,22 @@ echo "Auto-generating comments for files in the last commit..."
|
|
|
69
66
|
node "${postCommitScript}"${modeArgs} || exit 1
|
|
70
67
|
`;
|
|
71
68
|
fs.writeFileSync(postCommitHookPath, postCommitContent);
|
|
72
|
-
//
|
|
69
|
+
// Attempt to set the execute permissions for the post-commit hook file [ds]
|
|
73
70
|
try {
|
|
74
71
|
fs.chmodSync(postCommitHookPath, 0o755);
|
|
75
72
|
} catch (err) {}
|
|
76
73
|
|
|
74
|
+
// Display a success message if the hooks are installed successfully [ds]
|
|
77
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]
|
|
78
77
|
} catch (e) {
|
|
79
78
|
console.warn('Warning: Could not set up Git hooks (not inside a git repository or git command missing).');
|
|
80
79
|
}
|
|
81
80
|
}
|
|
82
81
|
|
|
83
|
-
//
|
|
82
|
+
// Check if this script is being run directly (not being required as a module) [ds]
|
|
84
83
|
if (require.main === module) {
|
|
85
84
|
installHooks();
|
|
86
85
|
}
|
|
86
|
+
// Export the installHooks function as a module [ds]
|
|
87
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