devsplain 2.2.4 → 2.3.1
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 +2 -2
- package/bin/cli.js +76 -57
- package/lib/config.js +27 -4
- package/lib/llm.js +475 -432
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Unlike interactive AI editors, `devsplain` is designed for batch documentation p
|
|
|
18
18
|
- **Comment Preservation & Tagging**: AI-generated comments are tagged with `[ds]`. Your manually written comments are safe and will never be touched by the engine.
|
|
19
19
|
- **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.
|
|
20
20
|
- **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.
|
|
21
|
-
- **Bring Your Own LLM**: Native setup wizard for Groq, Gemini, OpenAI, or any OpenAI-compatible API endpoint (like Ollama or LMStudio).
|
|
21
|
+
- **Bring Your Own LLM**: Native setup wizard for Groq, Gemini, OpenAI, Claude, DeepSeek, or any OpenAI-compatible API endpoint (like Ollama or LMStudio).
|
|
22
22
|
- **Exponential Backoff**: Resilient AI request handler that automatically retries rate-limited requests with exponential backoff.
|
|
23
23
|
- **Headless & Override Control**: Configure via environment variables or override global config settings dynamically on the fly with command-line flags.
|
|
24
24
|
|
|
@@ -109,7 +109,7 @@ devsplain <file-or-directory> [options]
|
|
|
109
109
|
| `--force` | Bypasses the safety block check that prevents running `devsplain` on a dirty Git working tree. |
|
|
110
110
|
| `--clean` | Scrubber mode. Deterministically removes only devsplain-generated comments tagged with `[ds]`, preserving your manual comments. |
|
|
111
111
|
| `--prune` | Destructive scrubber mode. Removes ALL comments and docstrings from source files, including your own manual comments. |
|
|
112
|
-
| `--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). |
|
|
112
|
+
| `--provider <name>`| Temporary one-off override for the AI provider (`gemini`, `groq`, `openai`, `claude`, `deepseek`, `custom`) for this command run only (does not modify the saved config file). |
|
|
113
113
|
| `--model <name>` | Temporary one-off override for the model name for this command run only. |
|
|
114
114
|
| `--api-key <key>` | Temporary one-off override for the API key for this command run only. |
|
|
115
115
|
| `--base-url <url>` | Temporary one-off override for the API base URL for this command run only. |
|
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const { getComments } = require('../lib/llm.js');
|
|
3
|
+
const { getComments, runWithConcurrency, resetConcurrency } = require('../lib/llm.js');
|
|
4
4
|
const { getConfig } = require('../lib/config.js');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
@@ -766,7 +766,7 @@ Options:
|
|
|
766
766
|
--force Bypass the dirty Git tree safety check
|
|
767
767
|
--clean Scrub only devsplain-generated [ds] comments
|
|
768
768
|
--prune Destructively scrub ALL comments from files
|
|
769
|
-
--provider <name> Override AI provider (gemini, groq, openai, custom)
|
|
769
|
+
--provider <name> Override AI provider (gemini, groq, openai, claude, deepseek, custom)
|
|
770
770
|
--model <name> Override AI model name
|
|
771
771
|
--api-key <key> Override API key for the provider
|
|
772
772
|
--base-url <url> Override base URL for custom APIs
|
|
@@ -817,7 +817,7 @@ Options:
|
|
|
817
817
|
};
|
|
818
818
|
|
|
819
819
|
let filepath = '.';
|
|
820
|
-
const flagKeys = ['--provider', '--model', '--api-key', '--base-url'];
|
|
820
|
+
const flagKeys = ['--provider', '--model', '--api-key', '--base-url', '--concurrency'];
|
|
821
821
|
for (let i = 0; i < args.length; i++) {
|
|
822
822
|
const arg = args[i];
|
|
823
823
|
if (arg.startsWith('--')) {
|
|
@@ -862,10 +862,10 @@ Options:
|
|
|
862
862
|
if (cliProvider) {
|
|
863
863
|
config.provider = cliProvider;
|
|
864
864
|
if (!cliModel) {
|
|
865
|
-
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : (cliProvider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile');
|
|
865
|
+
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : (cliProvider === 'claude' ? 'claude-3-5-sonnet-20240620' : (cliProvider === 'deepseek' ? 'deepseek-chat' : (cliProvider === 'openai' ? 'gpt-4o' : 'llama-3.3-70b-versatile')));
|
|
866
866
|
}
|
|
867
867
|
if (!cliBaseUrl) {
|
|
868
|
-
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : (cliProvider === 'claude' ? 'https://api.anthropic.com' : '')));
|
|
868
|
+
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : (cliProvider === 'claude' ? 'https://api.anthropic.com' : (cliProvider === 'deepseek' ? 'https://api.deepseek.com' : ''))));
|
|
869
869
|
}
|
|
870
870
|
}
|
|
871
871
|
if (cliModel) config.model = cliModel;
|
|
@@ -877,6 +877,10 @@ Options:
|
|
|
877
877
|
|
|
878
878
|
const isOverwrite = (hasOverwriteFlag || config.autoPrune) && !hasKeepFlag;
|
|
879
879
|
|
|
880
|
+
// Parse --concurrency flag (default: 2, max: 5, min: 1) [ds]
|
|
881
|
+
const cliConcurrency = parseInt(getArgValue('--concurrency'), 10);
|
|
882
|
+
const concurrencyLevel = (cliConcurrency && cliConcurrency >= 1 && cliConcurrency <= 5) ? cliConcurrency : 2;
|
|
883
|
+
|
|
880
884
|
let userIgnorePatterns = [];
|
|
881
885
|
try {
|
|
882
886
|
const ignorePath = path.join(process.cwd(), '.devsplainignore');
|
|
@@ -908,80 +912,95 @@ Options:
|
|
|
908
912
|
return false;
|
|
909
913
|
}
|
|
910
914
|
|
|
911
|
-
|
|
915
|
+
const validExtensions = [
|
|
916
|
+
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
917
|
+
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
918
|
+
'.swift', '.kt', '.dart', '.sh', '.sql'
|
|
919
|
+
];
|
|
920
|
+
|
|
921
|
+
// Separate file discovery from processing for concurrency support [ds]
|
|
922
|
+
function collectFiles(targetPath) {
|
|
923
|
+
const collected = [];
|
|
912
924
|
const stats = fs.statSync(targetPath);
|
|
913
925
|
|
|
914
|
-
if (isPathIgnored(targetPath))
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
926
|
+
if (isPathIgnored(targetPath)) return collected;
|
|
917
927
|
|
|
918
928
|
if (stats.isDirectory()) {
|
|
919
929
|
console.log(`\n Scanning directory: ${targetPath}`);
|
|
920
930
|
const items = fs.readdirSync(targetPath);
|
|
921
931
|
for (const item of items) {
|
|
922
|
-
|
|
923
|
-
await processPath(fullPath);
|
|
932
|
+
collected.push(...collectFiles(path.join(targetPath, item)));
|
|
924
933
|
}
|
|
925
|
-
}
|
|
926
|
-
else if (stats.isFile()) {
|
|
934
|
+
} else if (stats.isFile()) {
|
|
927
935
|
const ext = path.extname(targetPath).toLowerCase();
|
|
928
|
-
|
|
929
|
-
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
930
|
-
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
931
|
-
'.swift', '.kt', '.dart', '.sh', '.sql'
|
|
932
|
-
];
|
|
933
|
-
|
|
934
|
-
if (!validExtensions.includes(ext)) {
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
936
|
+
if (!validExtensions.includes(ext)) return collected;
|
|
937
937
|
|
|
938
|
-
const filename = path.basename(targetPath);
|
|
939
938
|
const data = fs.readFileSync(targetPath, 'utf-8');
|
|
940
939
|
if (data.trim() === '') {
|
|
941
|
-
console.log(` Skipping ${
|
|
942
|
-
return;
|
|
940
|
+
console.log(` Skipping ${path.basename(targetPath)} (Empty File)`);
|
|
941
|
+
return collected;
|
|
943
942
|
}
|
|
943
|
+
collected.push(targetPath);
|
|
944
|
+
}
|
|
945
|
+
return collected;
|
|
946
|
+
}
|
|
944
947
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
console.log(` Skipped ${targetPath}`);
|
|
969
|
-
}
|
|
970
|
-
} else {
|
|
948
|
+
async function processSingleFile(targetPath) {
|
|
949
|
+
const filename = path.basename(targetPath);
|
|
950
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
951
|
+
const data = fs.readFileSync(targetPath, 'utf-8');
|
|
952
|
+
|
|
953
|
+
console.log(` Analyzing ${filename} in ${mode} mode...`);
|
|
954
|
+
try {
|
|
955
|
+
let comments = [];
|
|
956
|
+
let commentedCode;
|
|
957
|
+
if (mode !== 'clean' && mode !== 'prune') {
|
|
958
|
+
const preProcessMode = isOverwrite ? 'prune' : 'clean';
|
|
959
|
+
const cleanData = spliceComments(data, [], preProcessMode, ext);
|
|
960
|
+
comments = await getComments(cleanData, filename, config, mode);
|
|
961
|
+
commentedCode = spliceComments(cleanData, comments, mode, ext);
|
|
962
|
+
} else {
|
|
963
|
+
commentedCode = spliceComments(data, [], mode, ext);
|
|
964
|
+
}
|
|
965
|
+
if (isDryRun) {
|
|
966
|
+
console.log(`\n --- DRY RUN PREVIEW: ${filename} ---`);
|
|
967
|
+
console.log(commentedCode);
|
|
968
|
+
console.log(`---------------------------------------\n`);
|
|
969
|
+
const answer = await askQuestion("Type 'write' to save to file, or press any key to discard: ");
|
|
970
|
+
if (answer.toLowerCase() === 'write') {
|
|
971
971
|
const tempPath = targetPath + '.tmp';
|
|
972
972
|
fs.writeFileSync(tempPath, commentedCode, 'utf8');
|
|
973
973
|
fs.renameSync(tempPath, targetPath);
|
|
974
|
-
console.log(` Successfully
|
|
974
|
+
console.log(` Successfully saved ${targetPath}`);
|
|
975
|
+
} else {
|
|
976
|
+
console.log(` Skipped ${targetPath}`);
|
|
975
977
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
978
|
+
} else {
|
|
979
|
+
const tempPath = targetPath + '.tmp';
|
|
980
|
+
fs.writeFileSync(tempPath, commentedCode, 'utf8');
|
|
981
|
+
fs.renameSync(tempPath, targetPath);
|
|
982
|
+
console.log(` Successfully commented ${targetPath}`);
|
|
980
983
|
}
|
|
984
|
+
successCount++;
|
|
985
|
+
} catch (err) {
|
|
986
|
+
console.error(` Error processing ${filename}: ${err.message}`);
|
|
987
|
+
failCount++;
|
|
981
988
|
}
|
|
982
989
|
}
|
|
983
990
|
|
|
984
|
-
|
|
991
|
+
// Collect all eligible files, then process with adaptive concurrency [ds]
|
|
992
|
+
const filesToProcess = collectFiles(filepath);
|
|
993
|
+
|
|
994
|
+
// Dry-run mode processes files serially to allow interactive prompts [ds]
|
|
995
|
+
if (isDryRun || mode === 'clean' || mode === 'prune') {
|
|
996
|
+
for (const file of filesToProcess) {
|
|
997
|
+
await processSingleFile(file);
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
resetConcurrency(concurrencyLevel);
|
|
1001
|
+
console.log(`\n Processing ${filesToProcess.length} file(s) with concurrency: ${concurrencyLevel}`);
|
|
1002
|
+
await runWithConcurrency(filesToProcess, processSingleFile);
|
|
1003
|
+
}
|
|
985
1004
|
|
|
986
1005
|
if (failCount > 0 && successCount === 0) {
|
|
987
1006
|
console.error("\nFailed: No files were successfully commented.");
|
package/lib/config.js
CHANGED
|
@@ -61,8 +61,24 @@ function migrateConfig(oldConfig) {
|
|
|
61
61
|
async function getConfig(forceWizard = false) {
|
|
62
62
|
if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
|
|
63
63
|
const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
|
|
64
|
-
const model = process.env.DEVSPLAIN_MODEL || (
|
|
65
|
-
|
|
64
|
+
const model = process.env.DEVSPLAIN_MODEL || (
|
|
65
|
+
provider === 'gemini' ? 'gemini-2.0-flash' : (
|
|
66
|
+
provider === 'claude' ? 'claude-3-5-sonnet-20240620' : (
|
|
67
|
+
provider === 'deepseek' ? 'deepseek-chat' : (
|
|
68
|
+
provider === 'openai' ? 'gpt-4o' : 'llama-3.3-70b-versatile'
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (
|
|
74
|
+
provider === 'gemini' ? null : (
|
|
75
|
+
provider === 'claude' ? 'https://api.anthropic.com' : (
|
|
76
|
+
provider === 'deepseek' ? 'https://api.deepseek.com' : (
|
|
77
|
+
provider === 'openai' ? 'https://api.openai.com' : 'https://api.groq.com/openai'
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
);
|
|
66
82
|
return {
|
|
67
83
|
provider,
|
|
68
84
|
apiKey: process.env.DEVSPLAIN_API_KEY || '',
|
|
@@ -136,8 +152,9 @@ async function getConfig(forceWizard = false) {
|
|
|
136
152
|
console.log("3. OpenAI (Paid)");
|
|
137
153
|
console.log("4. Custom (Ollama, local, etc)");
|
|
138
154
|
console.log("5. Claude (Anthropic)");
|
|
155
|
+
console.log("6. DeepSeek (deepseek-chat, deepseek-reasoner)");
|
|
139
156
|
|
|
140
|
-
const choice = await askQuestion("Select (1-
|
|
157
|
+
const choice = await askQuestion("Select (1-6): ");
|
|
141
158
|
|
|
142
159
|
if (choice === '1') {
|
|
143
160
|
provider = 'groq';
|
|
@@ -175,8 +192,14 @@ async function getConfig(forceWizard = false) {
|
|
|
175
192
|
console.log("\nGet your Anthropic key here: https://console.anthropic.com/settings/keys");
|
|
176
193
|
const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
|
|
177
194
|
model = customModel.trim() || 'claude-3-5-sonnet-20240620';
|
|
195
|
+
} else if (choice === '6') {
|
|
196
|
+
provider = 'deepseek';
|
|
197
|
+
baseUrl = 'https://api.deepseek.com';
|
|
198
|
+
console.log("\nGet your DeepSeek key here: https://platform.deepseek.com/api_keys");
|
|
199
|
+
const customModel = await askQuestion("Model name (press Enter for default 'deepseek-chat'): ");
|
|
200
|
+
model = customModel.trim() || 'deepseek-chat';
|
|
178
201
|
} else {
|
|
179
|
-
console.log("Invalid choice. Please select 1, 2, 3, 4, or
|
|
202
|
+
console.log("Invalid choice. Please select 1, 2, 3, 4, 5, or 6.");
|
|
180
203
|
continue;
|
|
181
204
|
}
|
|
182
205
|
} else {
|
package/lib/llm.js
CHANGED
|
@@ -1,432 +1,475 @@
|
|
|
1
|
-
/** [ds]
|
|
2
|
-
* Sends an HTTP request with automatic retry logic and timeout management.
|
|
3
|
-
* @param {string} url - The target URL.
|
|
4
|
-
* @param {object} options - Fetch options including method, headers, and body.
|
|
5
|
-
* @param {number} maxRetries - Maximum number of retry attempts.
|
|
6
|
-
* @param {number} initialDelay - Initial delay in milliseconds for exponential backoff.
|
|
7
|
-
* @returns {Promise<Response>} - Returns the successful Response object.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let lastError;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (!data.
|
|
253
|
-
|
|
254
|
-
throw new Error(`AI Provider returned
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const commentLines = trimmedComment.split(/\r?\n/);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1
|
+
/** [ds]
|
|
2
|
+
* Sends an HTTP request with automatic retry logic and timeout management.
|
|
3
|
+
* @param {string} url - The target URL.
|
|
4
|
+
* @param {object} options - Fetch options including method, headers, and body.
|
|
5
|
+
* @param {number} maxRetries - Maximum number of retry attempts.
|
|
6
|
+
* @param {number} initialDelay - Initial delay in milliseconds for exponential backoff.
|
|
7
|
+
* @returns {Promise<Response>} - Returns the successful Response object.
|
|
8
|
+
* @throws {Error} Throws a RateLimitError (with .isRateLimit=true) on 429, or the last error after exhausting retries.
|
|
9
|
+
*/
|
|
10
|
+
async function fetchWithRetry(url, options, maxRetries = 3, initialDelay = 1000) {
|
|
11
|
+
let lastError;
|
|
12
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(url, {
|
|
17
|
+
...options,
|
|
18
|
+
signal: controller.signal
|
|
19
|
+
});
|
|
20
|
+
clearTimeout(timeoutId);
|
|
21
|
+
if (!response) {
|
|
22
|
+
throw new Error("No response received from fetch");
|
|
23
|
+
}
|
|
24
|
+
if (response.ok) {
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
27
|
+
// Surface 429 rate limits immediately so the adaptive controller can react [ds]
|
|
28
|
+
if (response.status === 429) {
|
|
29
|
+
const err = new Error(`HTTP Error 429: ${response.statusText || 'Too Many Requests'}`);
|
|
30
|
+
err.isRateLimit = true;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (response.status >= 500 && response.status < 600) {
|
|
34
|
+
lastError = new Error(`HTTP Error ${response.status}: ${response.statusText}`);
|
|
35
|
+
} else {
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
// Propagate rate limit errors immediately without retrying [ds]
|
|
41
|
+
if (err.isRateLimit) {
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
if (err.name === 'AbortError') {
|
|
45
|
+
lastError = new Error("Request timed out after 45 seconds");
|
|
46
|
+
} else {
|
|
47
|
+
lastError = err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (attempt < maxRetries - 1) {
|
|
52
|
+
const backoffDelay = initialDelay * Math.pow(2, attempt);
|
|
53
|
+
console.warn(`[devsplain] AI request failed. Retrying in ${backoffDelay}ms... (Attempt ${attempt + 1}/${maxRetries})`);
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw lastError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Chunking Constants ───────────────────────────────────────────────────────
|
|
61
|
+
const CHUNK_SIZE = 200;
|
|
62
|
+
const CHUNK_OVERLAP = 20;
|
|
63
|
+
const CHUNK_THRESHOLD = 250;
|
|
64
|
+
|
|
65
|
+
// ─── Adaptive Concurrency Controller ──────────────────────────────────────────
|
|
66
|
+
// Shared mutable state: starts at the requested concurrency and drops to 1 on 429 [ds]
|
|
67
|
+
let _concurrencyLimit = 2;
|
|
68
|
+
let _hitRateLimit = false;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset the adaptive concurrency controller for a new run.
|
|
72
|
+
* @param {number} initialLimit - Starting concurrency level.
|
|
73
|
+
*/
|
|
74
|
+
function resetConcurrency(initialLimit = 2) {
|
|
75
|
+
_concurrencyLimit = initialLimit;
|
|
76
|
+
_hitRateLimit = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Zero-dependency promise pool that respects the adaptive concurrency limit.
|
|
81
|
+
* If a task throws a 429 RateLimitError, concurrency is reduced to 1 for
|
|
82
|
+
* the remainder of the run, and the failed task is retried after a backoff.
|
|
83
|
+
* @param {Array} items - Items to process.
|
|
84
|
+
* @param {Function} taskFn - Async function to run per item.
|
|
85
|
+
* @returns {Promise<Array>} - Resolved results in order.
|
|
86
|
+
*/
|
|
87
|
+
async function runWithConcurrency(items, taskFn) {
|
|
88
|
+
const results = [];
|
|
89
|
+
const executing = new Set();
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < items.length; i++) {
|
|
92
|
+
const item = items[i];
|
|
93
|
+
|
|
94
|
+
while (executing.size >= _concurrencyLimit) {
|
|
95
|
+
await Promise.race(executing);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const task = (async () => {
|
|
99
|
+
const maxRetries = 3;
|
|
100
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
101
|
+
try {
|
|
102
|
+
return await taskFn(item);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.isRateLimit && attempt < maxRetries - 1) {
|
|
105
|
+
if (!_hitRateLimit) {
|
|
106
|
+
_hitRateLimit = true;
|
|
107
|
+
_concurrencyLimit = 1;
|
|
108
|
+
console.warn(`[devsplain] Rate limit hit — switching to serial mode.`);
|
|
109
|
+
}
|
|
110
|
+
const backoff = (process.env.NODE_ENV === 'test' ? 50 : 2000) * Math.pow(2, attempt) + Math.random() * 500;
|
|
111
|
+
console.warn(`[devsplain] Backing off for ${Math.round(backoff)}ms...`);
|
|
112
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
|
|
120
|
+
let tracked;
|
|
121
|
+
tracked = task.finally(() => {
|
|
122
|
+
executing.delete(tracked);
|
|
123
|
+
});
|
|
124
|
+
tracked.catch(() => {});
|
|
125
|
+
executing.add(tracked);
|
|
126
|
+
results.push(tracked);
|
|
127
|
+
}
|
|
128
|
+
return Promise.all(results);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Prompt Builder ───────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the LLM prompt for a block of numbered code lines.
|
|
135
|
+
* @param {string} numberedCode - Code with line numbers prepended.
|
|
136
|
+
* @param {string} language - Filename or language identifier.
|
|
137
|
+
* @param {string} mode - Documentation mode ('default', 'light', 'full').
|
|
138
|
+
* @returns {string} The assembled prompt string.
|
|
139
|
+
*/
|
|
140
|
+
function buildPrompt(numberedCode, language, mode) {
|
|
141
|
+
const extMatch = language.match(/\.[0-9a-z]+$/i);
|
|
142
|
+
const ext = extMatch ? extMatch[0].toLowerCase() : '';
|
|
143
|
+
const isPython = ext === '.py';
|
|
144
|
+
const isRubyOrShell = ['.rb', '.sh'].includes(ext);
|
|
145
|
+
const isHTML = ['.html', '.vue', '.svelte'].includes(ext);
|
|
146
|
+
const isCss = ['.css', '.scss'].includes(ext);
|
|
147
|
+
const isSql = ext === '.sql';
|
|
148
|
+
let singleLineToken = '//';
|
|
149
|
+
let blockExample = '/** Calculates the total price */';
|
|
150
|
+
let inlineExample = '// Check for null values';
|
|
151
|
+
|
|
152
|
+
if (isPython || isRubyOrShell) {
|
|
153
|
+
singleLineToken = '#';
|
|
154
|
+
blockExample = '# Calculates the total price';
|
|
155
|
+
inlineExample = '# Check for null values';
|
|
156
|
+
} else if (isHTML) {
|
|
157
|
+
singleLineToken = '<!--';
|
|
158
|
+
blockExample = '<!-- Calculates the total price -->';
|
|
159
|
+
inlineExample = '<!-- Check for null values -->';
|
|
160
|
+
} else if (isCss) {
|
|
161
|
+
singleLineToken = '/*';
|
|
162
|
+
blockExample = '/* Calculates the total price */';
|
|
163
|
+
inlineExample = '/* Check for null values */';
|
|
164
|
+
} else if (isSql) {
|
|
165
|
+
singleLineToken = '--';
|
|
166
|
+
blockExample = '-- Calculates the total price';
|
|
167
|
+
inlineExample = '-- Check for null values';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let instruction = `Provide block comments above functions and sparse inline comments for complex logic.`;
|
|
171
|
+
if (mode === 'light') {
|
|
172
|
+
instruction = `Provide ONLY block comments above functions. Keep it minimal.`;
|
|
173
|
+
} else if (mode === 'full') {
|
|
174
|
+
instruction = `Provide highly detailed block comments above functions, and exhaustive step-by-step inline comments explaining every conditional branch, loop, variable assignment, and logical block inside function bodies. Do not be sparse; explain the code's execution flow in detail.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let rule5 = `5. IMPORTANT: Use ONLY ${singleLineToken} for comments. DO NOT use docstrings or multi-line string literals like """ or ''' for comments.`;
|
|
178
|
+
if (isCss) {
|
|
179
|
+
rule5 = `5. IMPORTANT: In CSS/SCSS, you MUST use /* ... */ for comments. DO NOT use // comments under any circumstances.`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Anti-triviality negative constraints to eliminate syntax-narrating clutter [ds]
|
|
183
|
+
const antiTrivialityRules = `
|
|
184
|
+
ANTI-TRIVIALITY RULES (STRICTLY ENFORCED):
|
|
185
|
+
6. NEVER write comments that merely narrate the syntax (e.g. NEVER write "// Loop over items" above a for loop, "// Return result" above a return, "// Increment i" above i++, or "// Define variable" above a declaration).
|
|
186
|
+
7. NEVER comment standard variable initializations, obvious assignments, or self-describing code.
|
|
187
|
+
8. ONLY write comments where:
|
|
188
|
+
- The WHY or architectural intent is non-obvious.
|
|
189
|
+
- An edge case, security workaround, or regex heuristic is being handled.
|
|
190
|
+
- A tricky formula, index manipulation (e.g. 0-indexed vs 1-indexed), or protocol-specific behavior occurs.
|
|
191
|
+
9. Prefer comprehensive function-level block comments over cluttered inline comments. Quality over quantity.`;
|
|
192
|
+
|
|
193
|
+
const prompt = `
|
|
194
|
+
You are a code documentation engine. Analyze the following ${language} code which has line numbers prepended to it.
|
|
195
|
+
${instruction}
|
|
196
|
+
|
|
197
|
+
CRITICAL RULES:
|
|
198
|
+
1. You MUST respond with ONLY a raw, valid JSON array of objects. NO markdown formatting, NO backticks, NO explanations, NO text before or after the JSON.
|
|
199
|
+
2. Each object must have exactly two properties: "line" (the integer line number where the comment should be inserted ABOVE) and "comment" (the text of the comment itself).
|
|
200
|
+
3. Do NOT include the original code in your response.
|
|
201
|
+
4. If no comments are needed, return an empty array: [].
|
|
202
|
+
${rule5}
|
|
203
|
+
${antiTrivialityRules}
|
|
204
|
+
|
|
205
|
+
Example Output:
|
|
206
|
+
[
|
|
207
|
+
{ "line": 4, "comment": "${blockExample}" },
|
|
208
|
+
{ "line": 12, "comment": "${inlineExample}" }
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
Here is the source code:
|
|
212
|
+
${numberedCode}
|
|
213
|
+
`.trim();
|
|
214
|
+
|
|
215
|
+
return prompt;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Single-Chunk Comment Fetcher ─────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Fetch comments for a single chunk of code from the configured AI provider.
|
|
222
|
+
* @param {string} prompt - The assembled prompt.
|
|
223
|
+
* @param {object} config - Provider config (provider, model, apiKey, baseUrl).
|
|
224
|
+
* @returns {Promise<string>} Raw text response from the AI.
|
|
225
|
+
*/
|
|
226
|
+
async function fetchFromProvider(prompt, config) {
|
|
227
|
+
let textResponse = "";
|
|
228
|
+
|
|
229
|
+
if (config.provider === 'gemini') {
|
|
230
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
|
|
231
|
+
let data;
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetchWithRetry(url, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: {
|
|
236
|
+
'Content-Type': 'application/json'
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
"contents": [{ "parts": [{ "text": prompt }] }]
|
|
240
|
+
})
|
|
241
|
+
});
|
|
242
|
+
data = await response.json();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
// Re-throw rate limit errors so the concurrency controller can catch them [ds]
|
|
245
|
+
if (error.isRateLimit) throw error;
|
|
246
|
+
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
247
|
+
}
|
|
248
|
+
if (data.error) {
|
|
249
|
+
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
250
|
+
throw new Error(`API Error: ${msg}`);
|
|
251
|
+
}
|
|
252
|
+
if (!data.candidates || !data.candidates[0] || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts[0]) {
|
|
253
|
+
const reason = data.candidates?.[0]?.finishReason || 'Unknown error';
|
|
254
|
+
throw new Error(`AI Provider returned no content (finish reason: ${reason})`);
|
|
255
|
+
}
|
|
256
|
+
textResponse = data.candidates[0].content.parts[0].text;
|
|
257
|
+
} else if (config.provider === 'claude') {
|
|
258
|
+
const url = `${config.baseUrl}/v1/messages`;
|
|
259
|
+
let data;
|
|
260
|
+
try {
|
|
261
|
+
const response = await fetchWithRetry(url, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: {
|
|
264
|
+
'Content-Type': 'application/json',
|
|
265
|
+
'x-api-key': config.apiKey,
|
|
266
|
+
'anthropic-version': '2023-06-01'
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify({
|
|
269
|
+
"model": config.model,
|
|
270
|
+
"max_tokens": 8192,
|
|
271
|
+
"messages": [{
|
|
272
|
+
"role": "user",
|
|
273
|
+
"content": prompt
|
|
274
|
+
}]
|
|
275
|
+
})
|
|
276
|
+
});
|
|
277
|
+
data = await response.json();
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error.isRateLimit) throw error;
|
|
280
|
+
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
281
|
+
}
|
|
282
|
+
if (data.error) {
|
|
283
|
+
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
284
|
+
throw new Error(`API Error: ${msg}`);
|
|
285
|
+
}
|
|
286
|
+
if (!data.content || !data.content[0] || typeof data.content[0].text !== 'string') {
|
|
287
|
+
throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
|
|
288
|
+
}
|
|
289
|
+
textResponse = data.content[0].text;
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const url = `${config.baseUrl}/v1/chat/completions`;
|
|
293
|
+
let data;
|
|
294
|
+
|
|
295
|
+
const reqBody = {
|
|
296
|
+
"model": config.model,
|
|
297
|
+
"messages": [{
|
|
298
|
+
"role": "user",
|
|
299
|
+
"content": prompt
|
|
300
|
+
}]
|
|
301
|
+
};
|
|
302
|
+
if (config.provider === 'groq') {
|
|
303
|
+
reqBody.max_tokens = 1000;
|
|
304
|
+
} else {
|
|
305
|
+
reqBody.max_tokens = 8192;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const response = await fetchWithRetry(url, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: {
|
|
312
|
+
'Content-Type': 'application/json',
|
|
313
|
+
'Authorization': `Bearer ${config.apiKey}`
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify(reqBody)
|
|
316
|
+
});
|
|
317
|
+
data = await response.json();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error.isRateLimit) throw error;
|
|
320
|
+
throw new Error(`AI Provider Request Failed: ${error.message}`);
|
|
321
|
+
}
|
|
322
|
+
if (data.error) {
|
|
323
|
+
const msg = data.error.message || (typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
|
|
324
|
+
throw new Error(`API Error: ${msg}`);
|
|
325
|
+
}
|
|
326
|
+
if (!data.choices || !data.choices[0] || !data.choices[0].message || typeof data.choices[0].message.content !== 'string') {
|
|
327
|
+
throw new Error(`AI Provider returned an unexpected response structure: ${JSON.stringify(data)}`);
|
|
328
|
+
}
|
|
329
|
+
textResponse = data.choices[0].message.content;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return textResponse;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── Response Parser & Validator ──────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Parse, validate, and sanitize the raw text response from the AI provider.
|
|
339
|
+
* @param {string} textResponse - Raw text from the AI.
|
|
340
|
+
* @param {string} mode - Documentation mode.
|
|
341
|
+
* @returns {Array} Validated array of comment objects.
|
|
342
|
+
*/
|
|
343
|
+
function parseAndValidate(textResponse, mode) {
|
|
344
|
+
let cleanText = textResponse.trim();
|
|
345
|
+
const start = cleanText.indexOf('[');
|
|
346
|
+
const end = cleanText.lastIndexOf(']');
|
|
347
|
+
if (start !== -1) {
|
|
348
|
+
if (end !== -1 && end >= start) {
|
|
349
|
+
cleanText = cleanText.substring(start, end + 1);
|
|
350
|
+
} else {
|
|
351
|
+
const lastBrace = cleanText.lastIndexOf('}');
|
|
352
|
+
if (lastBrace > start) {
|
|
353
|
+
cleanText = cleanText.substring(start, lastBrace + 1) + ']';
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let parsed;
|
|
359
|
+
try {
|
|
360
|
+
parsed = JSON.parse(cleanText);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
throw new Error(`Parsing Error: Failed to parse LLM response as JSON. Raw response was:\n${textResponse}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!Array.isArray(parsed)) {
|
|
366
|
+
throw new Error("Schema Error: LLM response is not a JSON array.");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const item of parsed) {
|
|
370
|
+
if (typeof item !== 'object' || item === null) {
|
|
371
|
+
throw new Error("Schema Error: Array elements must be objects.");
|
|
372
|
+
}
|
|
373
|
+
if (!Number.isInteger(item.line) || item.line <= 0) {
|
|
374
|
+
throw new Error("Schema Error: 'line' must be a positive integer.");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (mode === 'clean') {
|
|
378
|
+
if (item.action !== 'delete') {
|
|
379
|
+
throw new Error("Schema Error: 'action' must be 'delete' in clean mode.");
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
if (typeof item.comment !== 'string') {
|
|
383
|
+
throw new Error("Schema Error: 'comment' must be a string.");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const trimmedComment = item.comment.trim();
|
|
387
|
+
const commentLines = trimmedComment.split(/\r?\n/);
|
|
388
|
+
let inBlock = false;
|
|
389
|
+
for (const cl of commentLines) {
|
|
390
|
+
const tcl = cl.trim();
|
|
391
|
+
if (!tcl) continue;
|
|
392
|
+
if (inBlock) {
|
|
393
|
+
if (tcl.includes('*/') || tcl.includes('-->')) {
|
|
394
|
+
inBlock = false;
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const startsWithMarker =
|
|
399
|
+
tcl.startsWith('//') ||
|
|
400
|
+
tcl.startsWith('/*') ||
|
|
401
|
+
tcl.startsWith('*') ||
|
|
402
|
+
tcl.startsWith('#') ||
|
|
403
|
+
tcl.startsWith('<!--') ||
|
|
404
|
+
tcl.startsWith('--');
|
|
405
|
+
if (!startsWithMarker) {
|
|
406
|
+
throw new Error(`Security Error: Comment on line ${item.line} contains invalid non-comment line: "${tcl}"`);
|
|
407
|
+
}
|
|
408
|
+
if ((tcl.startsWith('/*') && !tcl.includes('*/')) || (tcl.startsWith('<!--') && !tcl.includes('-->'))) {
|
|
409
|
+
inBlock = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return parsed;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── Core Public API ──────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Fetch and validate AI-generated comments for a source file.
|
|
422
|
+
* Automatically chunks files exceeding CHUNK_THRESHOLD lines into
|
|
423
|
+
* overlapping windows, processes them with adaptive concurrency,
|
|
424
|
+
* and deduplicates comments across chunk boundaries.
|
|
425
|
+
* @param {string} code - Full source code of the file.
|
|
426
|
+
* @param {string} language - Filename or language identifier.
|
|
427
|
+
* @param {object} config - Provider configuration.
|
|
428
|
+
* @param {string} mode - Documentation mode ('default', 'light', 'full', 'clean').
|
|
429
|
+
* @returns {Promise<Array>} Array of validated comment objects with global line numbers.
|
|
430
|
+
*/
|
|
431
|
+
async function getComments(code, language, config, mode = 'default') {
|
|
432
|
+
const lines = code.split(/\r?\n/);
|
|
433
|
+
|
|
434
|
+
// Small files: single-shot processing (no chunking overhead) [ds]
|
|
435
|
+
if (lines.length <= CHUNK_THRESHOLD) {
|
|
436
|
+
const numberedCode = lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
|
|
437
|
+
const prompt = buildPrompt(numberedCode, language, mode);
|
|
438
|
+
const [textResponse] = await runWithConcurrency([prompt], p => fetchFromProvider(p, config));
|
|
439
|
+
return parseAndValidate(textResponse, mode);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Large files: slice into overlapping chunks with global line numbers [ds]
|
|
443
|
+
const chunks = [];
|
|
444
|
+
for (let start = 0; start < lines.length; start += (CHUNK_SIZE - CHUNK_OVERLAP)) {
|
|
445
|
+
const end = Math.min(start + CHUNK_SIZE, lines.length);
|
|
446
|
+
chunks.push({ start, end });
|
|
447
|
+
if (end >= lines.length) break;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Process chunks through the adaptive concurrency pool [ds]
|
|
451
|
+
const chunkResults = await runWithConcurrency(chunks, async (chunk) => {
|
|
452
|
+
const chunkLines = lines.slice(chunk.start, chunk.end);
|
|
453
|
+
const startLineNum = chunk.start + 1;
|
|
454
|
+
const numberedCode = chunkLines.map((line, i) => `${startLineNum + i}: ${line}`).join('\n');
|
|
455
|
+
const prompt = buildPrompt(numberedCode, language, mode);
|
|
456
|
+
const textResponse = await fetchFromProvider(prompt, config);
|
|
457
|
+
return parseAndValidate(textResponse, mode);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Merge and deduplicate: first comment wins for overlapping line numbers [ds]
|
|
461
|
+
const seenLines = new Set();
|
|
462
|
+
const allComments = [];
|
|
463
|
+
for (const chunkComments of chunkResults) {
|
|
464
|
+
for (const c of chunkComments) {
|
|
465
|
+
if (!seenLines.has(c.line)) {
|
|
466
|
+
seenLines.add(c.line);
|
|
467
|
+
allComments.push(c);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return allComments;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
module.exports = { getComments, runWithConcurrency, resetConcurrency, CHUNK_SIZE, CHUNK_OVERLAP, CHUNK_THRESHOLD };
|
package/package.json
CHANGED