devsplain 1.1.0 → 1.5.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/bin/cli.js CHANGED
@@ -1,77 +1,592 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Import required modules.
5
- * @module llm - Provides functionality for getting comments.
6
- * @module config - Provides functionality for getting configuration.
7
- * @module fs - Provides functionality for interacting with the file system.
8
- */
9
- const { getComments } = require('../lib/llm.js');
10
- const { getConfig } = require('../lib/config.js');
11
- const fs = require('fs');
12
-
13
- /**
14
- * Get the filepath from the command line arguments.
15
- * @type {string}
16
- */
17
- const filepath = process.argv[2];
18
-
19
- /**
20
- * Get additional command line arguments.
21
- * @type {array}
22
- */
23
- const args = process.argv.slice(3);
24
-
25
- /**
26
- * Set the default mode.
27
- * @type {string}
28
- */
29
- let mode = 'default';
30
-
31
- /**
32
- * Check for mode flags in the command line arguments and update the mode accordingly.
33
- */
34
- if (args.includes('--light')) mode = 'light';
35
- if (args.includes('--full')) mode = 'full';
36
-
37
- /**
38
- * Check if a filepath was provided.
39
- */
40
- if (!filepath) {
41
- console.log("usage: devsplain <file>");
42
- process.exit(1);
43
- } else {
44
- /**
45
- * Main execution block.
46
- * @async
47
- */
48
- (async () => {
49
- /**
50
- * Get the configuration.
51
- * @type {object}
52
- */
53
- const config = await getConfig();
54
-
55
- /**
56
- * Read the file at the specified filepath.
57
- * @type {string}
58
- */
59
- const data = fs.readFileSync(filepath, 'utf-8');
60
-
61
- console.log("Analyzing File...");
62
- console.log(`Analyzing File in ${mode} mode...`);
63
-
64
- /**
65
- * Get comments for the code in the file.
66
- * @type {string}
67
- */
68
- const commentedCode = await getComments(data, 'javascript', config, mode);
69
-
70
- /**
71
- * Write the commented code back to the file.
72
- */
73
- fs.writeFileSync(filepath, commentedCode);
74
-
75
- console.log(`Successfully commented ${filepath}`);
76
- })();
1
+
2
+ const { getComments } = require('../lib/llm.js');
3
+ const { getConfig } = require('../lib/config.js');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const readline = require('readline');
7
+ const { execSync } = require('child_process');
8
+
9
+ let rl;
10
+ let askQuestion;
11
+
12
+ /** Checks if the current Git repository has uncommitted changes */
13
+ function isGitDirty() {
14
+ try {
15
+ const gitDir = execSync('git rev-parse --is-inside-work-tree', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
16
+ if (gitDir === 'true') {
17
+ const status = execSync('git status --porcelain', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
18
+ return status.length > 0;
19
+ }
20
+ } catch (e) {
21
+ }
22
+ return false;
23
+ }
24
+
25
+ /** Determines if a specific line index is inside a string or multiline literal */
26
+ function isLineInsideString(lines, targetLineIndex, ext = '') {
27
+ const isPython = ext.toLowerCase() === '.py';
28
+ let inBacktick = false;
29
+ let inTripleDouble = false;
30
+ let inTripleSingle = false;
31
+ let inSingle = false;
32
+ let inDouble = false;
33
+
34
+ for (let i = 0; i < targetLineIndex; i++) {
35
+ const line = lines[i];
36
+ let j = 0;
37
+ while (j < line.length) {
38
+ if (isPython) {
39
+ if (!inTripleSingle) {
40
+ if (line.slice(j, j + 3) === '"""') {
41
+ inTripleDouble = !inTripleDouble;
42
+ j += 3;
43
+ continue;
44
+ }
45
+ }
46
+ if (!inTripleDouble) {
47
+ if (line.slice(j, j + 3) === "'''") {
48
+ inTripleSingle = !inTripleSingle;
49
+ j += 3;
50
+ continue;
51
+ }
52
+ }
53
+ } else {
54
+ if (!inSingle && !inDouble && line[j] === '`') {
55
+ let escaped = false;
56
+ let k = j - 1;
57
+ while (k >= 0 && line[k] === '\\') {
58
+ escaped = !escaped;
59
+ k--;
60
+ }
61
+ if (!escaped) {
62
+ inBacktick = !inBacktick;
63
+ }
64
+ }
65
+ if (!inBacktick) {
66
+ if (line[j] === '"' && !inSingle) {
67
+ let escaped = false;
68
+ let k = j - 1;
69
+ while (k >= 0 && line[k] === '\\') {
70
+ escaped = !escaped;
71
+ k--;
72
+ }
73
+ if (!escaped) {
74
+ inDouble = !inDouble;
75
+ }
76
+ } else if (line[j] === "'" && !inDouble) {
77
+ let escaped = false;
78
+ let k = j - 1;
79
+ while (k >= 0 && line[k] === '\\') {
80
+ escaped = !escaped;
81
+ k--;
82
+ }
83
+ if (!escaped) {
84
+ inSingle = !inSingle;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ j++;
90
+ }
91
+ if (!isPython) {
92
+ inSingle = false;
93
+ inDouble = false;
94
+ }
95
+ }
96
+ return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
97
+ }
98
+
99
+ /** Analyzes code to identify pure comment lines and block boundaries */
100
+ function analyzeComments(lines, ext = '') {
101
+ const isPython = ext.toLowerCase() === '.py';
102
+ const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
103
+ const analysis = [];
104
+ let inBacktick = false;
105
+ let inTripleDouble = false;
106
+ let inTripleSingle = false;
107
+ let inSingle = false;
108
+ let inDouble = false;
109
+ let inBlockJS = false;
110
+ let inBlockHTML = false;
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const line = lines[i];
113
+ let commentStartIndex = -1;
114
+ let isInsideBlockStart = inBlockJS || inBlockHTML;
115
+ let j = 0;
116
+ while (j < line.length) {
117
+ if (inBlockJS) {
118
+ if (line.slice(j, j + 2) === '*/') {
119
+ inBlockJS = false;
120
+ j += 2;
121
+ continue;
122
+ }
123
+ j++;
124
+ continue;
125
+ }
126
+ if (inBlockHTML) {
127
+ if (line.slice(j, j + 3) === '-->') {
128
+ inBlockHTML = false;
129
+ j += 3;
130
+ continue;
131
+ }
132
+ j++;
133
+ continue;
134
+ }
135
+ if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
136
+ if (isPython) {
137
+ if (line[j] === '#') {
138
+ commentStartIndex = j;
139
+ break;
140
+ }
141
+ } else if (isHTML) {
142
+ if (line.slice(j, j + 4) === '<!--') {
143
+ commentStartIndex = j;
144
+ inBlockHTML = true;
145
+ j += 4;
146
+ continue;
147
+ }
148
+ if (line.slice(j, j + 2) === '//') {
149
+ commentStartIndex = j;
150
+ break;
151
+ }
152
+ } else {
153
+ if (line.slice(j, j + 2) === '//') {
154
+ commentStartIndex = j;
155
+ break;
156
+ }
157
+ if (line.slice(j, j + 2) === '/*') {
158
+ commentStartIndex = j;
159
+ inBlockJS = true;
160
+ j += 2;
161
+ continue;
162
+ }
163
+ if (line[j] === '#') {
164
+ commentStartIndex = j;
165
+ break;
166
+ }
167
+ }
168
+ }
169
+ if (isPython) {
170
+ if (!inTripleSingle) {
171
+ if (line.slice(j, j + 3) === '"""') {
172
+ inTripleDouble = !inTripleDouble;
173
+ j += 3;
174
+ continue;
175
+ }
176
+ }
177
+ if (!inTripleDouble) {
178
+ if (line.slice(j, j + 3) === "'''") {
179
+ inTripleSingle = !inTripleSingle;
180
+ j += 3;
181
+ continue;
182
+ }
183
+ }
184
+ } else {
185
+ if (!inSingle && !inDouble) {
186
+ if (line[j] === '`') {
187
+ let escaped = false;
188
+ let k = j - 1;
189
+ while (k >= 0 && line[k] === '\\') {
190
+ escaped = !escaped;
191
+ k--;
192
+ }
193
+ if (!escaped) {
194
+ inBacktick = !inBacktick;
195
+ }
196
+ }
197
+ }
198
+ if (!inBacktick) {
199
+ if (line[j] === '"' && !inSingle) {
200
+ let escaped = false;
201
+ let k = j - 1;
202
+ while (k >= 0 && line[k] === '\\') {
203
+ escaped = !escaped;
204
+ k--;
205
+ }
206
+ if (!escaped) {
207
+ inDouble = !inDouble;
208
+ }
209
+ }
210
+ else if (line[j] === "'" && !inDouble) {
211
+ let escaped = false;
212
+ let k = j - 1;
213
+ while (k >= 0 && line[k] === '\\') {
214
+ escaped = !escaped;
215
+ k--;
216
+ }
217
+ if (!escaped) {
218
+ inSingle = !inSingle;
219
+ }
220
+ }
221
+ }
222
+ }
223
+ j++;
224
+ }
225
+ if (!isPython) {
226
+ inSingle = false;
227
+ inDouble = false;
228
+ }
229
+ const isEntirelyInsideBlock = isInsideBlockStart && (inBlockJS || inBlockHTML || (commentStartIndex === -1));
230
+ let isPureComment = false;
231
+ if (isEntirelyInsideBlock) {
232
+ isPureComment = true;
233
+ } else if (commentStartIndex !== -1) {
234
+ const beforeComment = line.slice(0, commentStartIndex).trim();
235
+ if (beforeComment === '') {
236
+ isPureComment = true;
237
+ }
238
+ } else if (line.trim() === '') {
239
+ isPureComment = true;
240
+ }
241
+ analysis.push({
242
+ isPureComment,
243
+ commentStartIndex,
244
+ isInsideBlock: isEntirelyInsideBlock || isInsideBlockStart
245
+ });
246
+ }
247
+ return analysis;
248
+ }
249
+
250
+ /** Splicers or removes comments from source data based on the requested mode */
251
+ function spliceComments(data, comments, mode = 'default', ext = '') {
252
+ const hasCRLF = data.includes('\r\n');
253
+ const lineEnding = hasCRLF ? '\r\n' : '\n';
254
+ const originalLines = data.split(/\r?\n/);
255
+ const sortedComments = [...comments].sort((a, b) => b.line - a.line);
256
+ const validComments = sortedComments.filter(c => c.line >= 1 && c.line <= originalLines.length + 1);
257
+
258
+ const annotated = originalLines.map((text, index) => ({ text, originalIndex: index }));
259
+ let analysis = null;
260
+
261
+ if (mode === 'clean') {
262
+ analysis = analyzeComments(originalLines, ext);
263
+ const finalDeletions = new Set();
264
+ for (let i = 0; i < originalLines.length; i++) {
265
+ const lineNum = i + 1;
266
+ if (analysis[i].isPureComment) {
267
+ finalDeletions.add(lineNum);
268
+ } else if (analysis[i].commentStartIndex !== -1) {
269
+ annotated[i].text = originalLines[i].slice(0, analysis[i].commentStartIndex).trimEnd();
270
+ }
271
+ }
272
+
273
+ for (const c of validComments) {
274
+ const lineIdx = c.line - 1;
275
+ if (lineIdx >= 0 && lineIdx < originalLines.length) {
276
+ finalDeletions.add(c.line);
277
+ }
278
+ }
279
+
280
+ const linesToDelete = Array.from(finalDeletions).sort((a, b) => b - a);
281
+
282
+ for (const lineNum of linesToDelete) {
283
+ const targetLine = originalLines[lineNum - 1];
284
+ if (!targetLine) continue;
285
+ const trimmedLine = targetLine.trim();
286
+
287
+ const lineAnalysis = analysis[lineNum - 1];
288
+ const isCommentLine =
289
+ lineAnalysis.isInsideBlock ||
290
+ lineAnalysis.isPureComment ||
291
+ trimmedLine.startsWith('//') ||
292
+ trimmedLine.startsWith('/*') ||
293
+ trimmedLine.startsWith('*') ||
294
+ trimmedLine.startsWith('#') ||
295
+ trimmedLine.startsWith('<!--') ||
296
+ trimmedLine.startsWith('-->') ||
297
+ trimmedLine.startsWith('--') ||
298
+ trimmedLine.endsWith('*/') ||
299
+ trimmedLine === '';
300
+
301
+ if (!isCommentLine) {
302
+ console.warn(`[devsplain] Safety Block: Refused to delete non-comment line ${lineNum}: "${trimmedLine}"`);
303
+ continue;
304
+ }
305
+
306
+ annotated.splice(lineNum - 1, 1);
307
+ }
308
+ } else {
309
+ for (const c of validComments) {
310
+ if (isLineInsideString(originalLines, c.line - 1, ext)) {
311
+ console.warn(`[devsplain] Skipping comment insertion at line ${c.line} to avoid string literal corruption.`);
312
+ continue;
313
+ }
314
+
315
+ const targetLine = originalLines[c.line - 1] || '';
316
+ const indentMatch = targetLine.match(/^([ \t]*)/);
317
+ const indentation = indentMatch ? indentMatch[1] : '';
318
+
319
+ const commentLines = c.comment.split(/\r?\n/).map(line => {
320
+ const trimmed = line.trimStart();
321
+ if (!trimmed) return '';
322
+ if (trimmed.startsWith('*') && !trimmed.startsWith('*/') && !trimmed.startsWith('/*')) {
323
+ return indentation + ' ' + trimmed;
324
+ }
325
+ return indentation + trimmed;
326
+ });
327
+
328
+ const commentObjects = commentLines.map(line => ({ text: line, originalIndex: -1 }));
329
+ annotated.splice(c.line - 1, 0, ...commentObjects);
330
+ }
331
+ }
332
+
333
+ const filtered = annotated.filter(line => line.originalIndex !== -1);
334
+ const filteredText = filtered.map(line => line.text);
335
+ const filteredIndices = filtered.map(line => line.originalIndex);
336
+
337
+ const textEqual = filteredText.every((text, idx) => {
338
+ const origIdx = filteredIndices[idx];
339
+ const originalLine = originalLines[origIdx];
340
+ if (text === originalLine) {
341
+ return true;
342
+ }
343
+ if (mode === 'clean' && analysis) {
344
+ const lineAnalysis = analysis[origIdx];
345
+ if (lineAnalysis && lineAnalysis.commentStartIndex !== -1 && !lineAnalysis.isPureComment) {
346
+ const expectedStripped = originalLine.slice(0, lineAnalysis.commentStartIndex).trimEnd();
347
+ if (text === expectedStripped) {
348
+ return true;
349
+ }
350
+ }
351
+ }
352
+ return false;
353
+ });
354
+
355
+ let indicesSequential = true;
356
+ for (let i = 1; i < filteredIndices.length; i++) {
357
+ if (filteredIndices[i] <= filteredIndices[i - 1]) {
358
+ indicesSequential = false;
359
+ break;
360
+ }
361
+ }
362
+
363
+ if (!textEqual || !indicesSequential) {
364
+ console.error("\nSafety Assertion Failed: Spliced code does not match original code minus comments!");
365
+ process.exit(1);
366
+ }
367
+
368
+ return annotated.map(line => line.text).join(lineEnding);
369
+ }
370
+
371
+ /** Main CLI entry point handler */
372
+ async function runCLI() {
373
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
374
+ askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
375
+
376
+ const args = process.argv.slice(2);
377
+
378
+ if (args.includes('--help') || args.includes('-h')) {
379
+ console.log(`
380
+ devsplain - Universal Polyglot AI Code Commenter
381
+
382
+ Usage:
383
+ devsplain <file-or-directory> [options]
384
+
385
+ Options:
386
+ --light Add ONLY JSDoc/block comments above functions (minimalist)
387
+ --full Add detailed JSDoc/block comments and inline comments
388
+ --dry-run Preview comments without writing to file
389
+ --force Bypass the dirty Git tree safety check
390
+ --provider <name> Override AI provider (gemini, groq, openai, custom)
391
+ --model <name> Override AI model name
392
+ --api-key <key> Override API key for the provider
393
+ --base-url <url> Override base URL for custom APIs
394
+ --config Force run the configuration setup wizard
395
+ --setup-hook Install Git pre-commit and post-commit hooks in repository
396
+ --help, -h Show this help message
397
+ --version, -v Show version information
398
+ `);
399
+ rl.close();
400
+ process.exit(0);
401
+ }
402
+
403
+ if (args.includes('--version') || args.includes('-v')) {
404
+ const pkg = require('../package.json');
405
+ console.log(`devsplain v${pkg.version}`);
406
+ rl.close();
407
+ process.exit(0);
408
+ }
409
+
410
+ if (args.includes('--config')) {
411
+ rl.close();
412
+ await getConfig(true);
413
+ console.log("Success: Configuration updated successfully!");
414
+ process.exit(0);
415
+ }
416
+
417
+ if (args.includes('--setup-hook')) {
418
+ rl.close();
419
+ require('./setup-hook.js');
420
+ return;
421
+ }
422
+
423
+ const getArgValue = (flag) => {
424
+ const index = args.indexOf(flag);
425
+ if (index !== -1 && index + 1 < args.length) {
426
+ return args[index + 1];
427
+ }
428
+ return null;
429
+ };
430
+
431
+ let filepath = '.';
432
+ const flagKeys = ['--provider', '--model', '--api-key', '--base-url'];
433
+ for (let i = 0; i < args.length; i++) {
434
+ const arg = args[i];
435
+ if (arg.startsWith('--')) {
436
+ if (flagKeys.includes(arg)) {
437
+ i++;
438
+ }
439
+ } else {
440
+ filepath = arg;
441
+ break;
442
+ }
443
+ }
444
+
445
+ if (!fs.existsSync(filepath)) {
446
+ console.log(`Error: The path '${filepath}' does not exist.`);
447
+ rl.close();
448
+ process.exit(1);
449
+ }
450
+
451
+ let mode = 'default';
452
+ if (args.includes('--light')) mode = 'light';
453
+ if (args.includes('--full')) mode = 'full';
454
+ if (args.includes('--clean')) mode = 'clean';
455
+ const isDryRun = args.includes('--dry-run');
456
+ const isForce = args.includes('--force');
457
+
458
+ if (process.env.NODE_ENV !== 'test' && isGitDirty() && !isForce) {
459
+ console.error("Error: Git working tree is dirty. Please commit or stash your changes, or use --force to bypass this check.");
460
+ rl.close();
461
+ process.exit(1);
462
+ }
463
+
464
+ const config = await getConfig();
465
+
466
+ const cliProvider = getArgValue('--provider');
467
+ const cliModel = getArgValue('--model');
468
+ const cliApiKey = getArgValue('--api-key');
469
+ const cliBaseUrl = getArgValue('--base-url');
470
+
471
+ if (cliProvider) {
472
+ config.provider = cliProvider;
473
+ if (!cliModel) {
474
+ config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : 'llama-3.3-70b-versatile';
475
+ }
476
+ if (!cliBaseUrl) {
477
+ config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : ''));
478
+ }
479
+ }
480
+ if (cliModel) config.model = cliModel;
481
+ if (cliApiKey) config.apiKey = cliApiKey;
482
+ if (cliBaseUrl) config.baseUrl = cliBaseUrl;
483
+
484
+ let successCount = 0;
485
+ let failCount = 0;
486
+
487
+ /** Recursively traverses directories to process files */
488
+ async function processPath(targetPath) {
489
+ const stats = fs.statSync(targetPath);
490
+
491
+ if (stats.isDirectory()) {
492
+ const folderName = path.basename(targetPath);
493
+ const ignoredFolders = [
494
+ 'node_modules', '.git', 'dist', 'build', 'out',
495
+ '.next', '.nuxt', '.svelte-kit',
496
+ 'venv', 'env', '.venv',
497
+ '.vscode', '.idea', 'coverage'
498
+ ];
499
+
500
+ if (ignoredFolders.includes(folderName)) {
501
+ return;
502
+ }
503
+
504
+ console.log(`\n Scanning directory: ${targetPath}`);
505
+ const items = fs.readdirSync(targetPath);
506
+ for (const item of items) {
507
+ const fullPath = path.join(targetPath, item);
508
+ await processPath(fullPath);
509
+ }
510
+ }
511
+ else if (stats.isFile()) {
512
+ const ext = path.extname(targetPath).toLowerCase();
513
+ const validExtensions = [
514
+ '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
515
+ '.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
516
+ '.swift', '.kt', '.dart', '.sh'
517
+ ];
518
+
519
+ if (!validExtensions.includes(ext)) {
520
+ return;
521
+ }
522
+
523
+ const filename = path.basename(targetPath);
524
+ const data = fs.readFileSync(targetPath, 'utf-8');
525
+ if (data.trim() === '') {
526
+ console.log(` Skipping ${filename} (Empty File)`);
527
+ return;
528
+ }
529
+
530
+ console.log(` Analyzing ${filename} in ${mode} mode...`);
531
+ try {
532
+ let comments = [];
533
+ let commentedCode;
534
+ if (mode !== 'clean') {
535
+ const cleanData = spliceComments(data, [], 'clean', ext);
536
+ comments = await getComments(cleanData, filename, config, mode);
537
+ commentedCode = spliceComments(cleanData, comments, mode, ext);
538
+ } else {
539
+ commentedCode = spliceComments(data, [], 'clean', ext);
540
+ }
541
+ if (isDryRun) {
542
+ console.log(`\n --- DRY RUN PREVIEW: ${filename} ---`);
543
+ console.log(commentedCode);
544
+ console.log(`---------------------------------------\n`);
545
+ const answer = await askQuestion("Type 'write' to save to file, or press any key to discard: ");
546
+ if (answer.toLowerCase() === 'write') {
547
+ const tempPath = targetPath + '.tmp';
548
+ fs.writeFileSync(tempPath, commentedCode, 'utf8');
549
+ fs.renameSync(tempPath, targetPath);
550
+ console.log(` Successfully saved ${targetPath}`);
551
+ } else {
552
+ console.log(` Skipped ${targetPath}`);
553
+ }
554
+ } else {
555
+ const tempPath = targetPath + '.tmp';
556
+ fs.writeFileSync(tempPath, commentedCode, 'utf8');
557
+ fs.renameSync(tempPath, targetPath);
558
+ console.log(` Successfully commented ${targetPath}`);
559
+ }
560
+ successCount++;
561
+ } catch (err) {
562
+ console.error(` Error processing ${filename}: ${err.message}`);
563
+ failCount++;
564
+ }
565
+ }
566
+ }
567
+
568
+ await processPath(filepath);
569
+
570
+ if (failCount > 0 && successCount === 0) {
571
+ console.error("\nFailed: No files were successfully commented.");
572
+ rl.close();
573
+ process.exit(1);
574
+ }
575
+
576
+ if (successCount > 0 && failCount > 0) {
577
+ console.log(`\n All done! (Successfully commented: ${successCount}, Failed: ${failCount})`);
578
+ } else {
579
+ console.log("\n All done!");
580
+ }
581
+ rl.close();
582
+ }
583
+
584
+ // If running as a standalone script, start the CLI; otherwise export helpers
585
+ if (require.main === module) {
586
+ runCLI().catch(err => {
587
+ console.error(err);
588
+ process.exit(1);
589
+ });
590
+ } else {
591
+ module.exports = { spliceComments, isLineInsideString };
77
592
  }