sonar-autofix 0.0.5

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.
@@ -0,0 +1,473 @@
1
+ // Core orchestrator for Sonar Autofix
2
+ // Implements the complete workflow: Sonar → Fetch → Prioritize → Group → Fix → Validate → Repeat → Commit → Push → PR
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ // Priority and type ordering as specified in PROJECT.md
8
+ const SEVERITY_ORDER = { BLOCKER: 0, CRITICAL: 1, MAJOR: 2, MINOR: 3, INFO: 4 };
9
+ const TYPE_ORDER = { BUG: 0, VULNERABILITY: 1, SECURITY_HOTSPOT: 2, CODE_SMELL: 3 };
10
+
11
+ const DEFAULT_RETRY_LIMIT = 3;
12
+ const DEFAULT_MAX_ITERATIONS = 5;
13
+
14
+ class CoreOrchestrator {
15
+ /**
16
+ * @param {object} config - Full configuration object
17
+ * @param {object} [deps] - Dependency injection for clients
18
+ * @param {object} [deps.sonarClient]
19
+ * @param {object} [deps.llmClient]
20
+ * @param {object} [deps.gitClient]
21
+ * @param {object} [deps.validator]
22
+ */
23
+ constructor(config, deps = {}) {
24
+ this.config = config;
25
+ this.dryRun = config.dryRun || false;
26
+ this.retryLimit = config.retry?.maxRetries || DEFAULT_RETRY_LIMIT;
27
+ this.maxIterations = config.retry?.maxIterations || DEFAULT_MAX_ITERATIONS;
28
+ this.cwd = config.cwd || process.cwd();
29
+
30
+ // Dependency injection — lazily initialize if not provided
31
+ this._sonarClient = deps.sonarClient || null;
32
+ this._llmClient = deps.llmClient || null;
33
+ this._gitClient = deps.gitClient || null;
34
+ this._validator = deps.validator || null;
35
+
36
+ // Plugin hooks
37
+ this.hooks = {
38
+ beforeRun: config.hooks?.beforeRun || [],
39
+ beforeFile: config.hooks?.beforeFile || [],
40
+ afterFile: config.hooks?.afterFile || [],
41
+ afterRun: config.hooks?.afterRun || [],
42
+ };
43
+
44
+ // Load prompt template
45
+ this.promptTemplate = null;
46
+ this.retryPromptTemplate = null;
47
+ }
48
+
49
+ // Lazy client initialization
50
+ get sonarClient() {
51
+ if (!this._sonarClient) {
52
+ const SonarClient = require('@sonar-autofix/sonar-client');
53
+ this._sonarClient = new SonarClient(this.config);
54
+ }
55
+ return this._sonarClient;
56
+ }
57
+
58
+ get llmClient() {
59
+ if (!this._llmClient) {
60
+ const LLMClient = require('@sonar-autofix/llm-client');
61
+ this._llmClient = new LLMClient(this.config);
62
+ }
63
+ return this._llmClient;
64
+ }
65
+
66
+ get gitClient() {
67
+ if (!this._gitClient) {
68
+ const GitClient = require('@sonar-autofix/git-client');
69
+ this._gitClient = new GitClient(this.config, this.cwd);
70
+ }
71
+ return this._gitClient;
72
+ }
73
+
74
+ get validator() {
75
+ if (!this._validator) {
76
+ // const Validator = require('@sonar-autofix/validator');
77
+ // this._validator = new Validator(this.config, this.cwd);
78
+ }
79
+ return this._validator;
80
+ }
81
+
82
+ /**
83
+ * Main orchestration — runs the complete workflow
84
+ */
85
+ async run() {
86
+ console.log('✓ Starting Sonar Autofix workflow...');
87
+
88
+ await this.runHooks('beforeRun');
89
+
90
+ try {
91
+ // Step 1: Create a working branch
92
+ const branchName = this.config.git?.branch || 'sonar-autofix';
93
+ if (!this.dryRun) {
94
+ await this.gitClient.createBranch(branchName);
95
+ console.log(`✓ Created branch: ${branchName}`);
96
+ }
97
+
98
+ let iteration = 0;
99
+ let totalFixed = 0;
100
+
101
+ while (iteration < this.maxIterations) {
102
+ iteration++;
103
+ console.log(`\n--- Iteration ${iteration}/${this.maxIterations} ---`);
104
+
105
+ // Step 2: Run Sonar analysis
106
+ console.log('✓ Running Sonar analysis...');
107
+ if (!this.dryRun) {
108
+ await this.sonarClient.runAnalysis(this.config.sonar?.project);
109
+ }
110
+
111
+ // Step 3: Fetch issues
112
+ console.log('✓ Fetching issues from Sonar...');
113
+ const issues = await this.fetchAndNormalizeIssues();
114
+ console.log(`✓ ${issues.length} issues found`);
115
+
116
+ if (issues.length === 0) {
117
+ console.log('✓ No issues remaining — workflow complete');
118
+ break;
119
+ }
120
+
121
+ // Step 4: Prioritize
122
+ const prioritized = this.prioritizeIssues(issues);
123
+
124
+ // Step 5: Group by file
125
+ const grouped = this.groupByFile(prioritized);
126
+ console.log(`✓ Grouped into ${Object.keys(grouped).length} files`);
127
+
128
+ // Step 6: Fix files
129
+ let fixedInIteration = 0;
130
+ for (const [filePath, fileIssues] of Object.entries(grouped)) {
131
+ await this.runHooks('beforeFile', { filePath, issues: fileIssues });
132
+
133
+ console.log(`✓ Fixing ${filePath} (${fileIssues.length} issues)...`);
134
+ console.log(issues[0]);
135
+ const result = await this.fixFileWithRetry(filePath, fileIssues);
136
+
137
+ if (result.success) {
138
+ fixedInIteration++;
139
+ console.log(` ✓ ${filePath} fixed successfully`);
140
+ } else {
141
+ console.log(` ✗ ${filePath} could not be fixed — restoring original`);
142
+ if (!this.dryRun) {
143
+ await this.gitClient.restoreFile(filePath);
144
+ }
145
+ }
146
+
147
+ await this.runHooks('afterFile', { filePath, issues: fileIssues, result });
148
+ }
149
+
150
+ totalFixed += fixedInIteration;
151
+
152
+ if (fixedInIteration === 0) {
153
+ console.log('✓ No files fixed in this iteration — stopping');
154
+ break;
155
+ }
156
+
157
+ //TODO
158
+ // Step 7: Validate the batch
159
+ // console.log('✓ Running batch validation...');
160
+ // const batchResult = await this.validator.validateBatch();
161
+ // if (!batchResult.success) {
162
+ // console.log('✗ Batch validation failed — restoring all changes');
163
+ // if (!this.dryRun) {
164
+ // await this.gitClient.reset('hard');
165
+ // }
166
+ // break;
167
+ // }
168
+ // console.log('✓ Batch validation passed');
169
+ }
170
+
171
+ // Step 8: Commit and push
172
+ if (totalFixed > 0 && !this.dryRun) {
173
+ const hasChanges = await this.gitClient.hasChanges();
174
+ if (hasChanges) {
175
+ console.log('✓ Committing changes...');
176
+ await this.gitClient.commit();
177
+
178
+ console.log('✓ Pushing branch...');
179
+ await this.gitClient.push(branchName);
180
+
181
+ // Step 9: Create PR
182
+ console.log('✓ Creating Pull Request...');
183
+ const prUrl = await this.gitClient.createPullRequest(
184
+ 'fix(sonar): automatically resolve Sonar issues',
185
+ this.buildPRBody(totalFixed, iteration),
186
+ );
187
+ console.log(`✓ Pull Request created: ${prUrl}`);
188
+ }
189
+ } else if (this.dryRun) {
190
+ console.log('✓ Dry-run mode — skipping commit/push/PR');
191
+ } else {
192
+ console.log('✓ No changes to commit');
193
+ }
194
+
195
+ const summary = { totalFixed, iterations: iteration };
196
+ await this.runHooks('afterRun', summary);
197
+
198
+ console.log(`\n✓ Workflow complete — ${totalFixed} files fixed in ${iteration} iteration(s)`);
199
+ return summary;
200
+ } catch (error) {
201
+ console.error('✗ Workflow failed:', error.message);
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Fetch issues from Sonar and normalize them into a consistent format
208
+ */
209
+ async fetchAndNormalizeIssues() {
210
+ const projectKey = this.config.sonar?.project;
211
+ const response = await this.sonarClient.getIssues(projectKey);
212
+
213
+ // Sonar API returns { issues: [...], ... }
214
+ const rawIssues = response.issues || response || [];
215
+
216
+ return rawIssues.map(issue => ({
217
+ key: issue.key,
218
+ rule: issue.rule,
219
+ severity: issue.severity || 'MAJOR',
220
+ type: issue.type || 'CODE_SMELL',
221
+ message: issue.message,
222
+ component: issue.component,
223
+ file: this.extractFilePath(issue.component, projectKey),
224
+ line: issue.line || (issue.textRange && issue.textRange.startLine) || 0,
225
+ effort: issue.effort,
226
+ status: issue.status,
227
+ }));
228
+ }
229
+
230
+ /**
231
+ * Extract the relative file path from a Sonar component key
232
+ * Component keys look like: "project-key:src/main/File.ts"
233
+ */
234
+ extractFilePath(component, projectKey) {
235
+ if (!component) return '';
236
+ const prefix = projectKey + ':';
237
+ if (component.startsWith(prefix)) {
238
+ return component.substring(prefix.length);
239
+ }
240
+ return component;
241
+ }
242
+
243
+ /**
244
+ * Prioritize issues per PROJECT.md ordering:
245
+ * 1. By severity: BLOCKER > CRITICAL > MAJOR > MINOR > INFO
246
+ * 2. By type: Bug > Vulnerability > Security Hotspot > Code Smell
247
+ */
248
+ prioritizeIssues(issues) {
249
+ return [...issues].sort((a, b) => {
250
+ const sevA = SEVERITY_ORDER[a.severity] ?? 99;
251
+ const sevB = SEVERITY_ORDER[b.severity] ?? 99;
252
+ if (sevA !== sevB) return sevA - sevB;
253
+
254
+ const typeA = TYPE_ORDER[a.type] ?? 99;
255
+ const typeB = TYPE_ORDER[b.type] ?? 99;
256
+ return typeA - typeB;
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Group issues by file path — one LLM call per file, not per issue
262
+ */
263
+ groupByFile(issues) {
264
+ const grouped = {};
265
+ for (const issue of issues) {
266
+ const file = issue.file;
267
+ if (!file) continue;
268
+ if (!grouped[file]) grouped[file] = [];
269
+ grouped[file].push(issue);
270
+ }
271
+ return grouped;
272
+ }
273
+
274
+ /**
275
+ * Fix a file with retry logic
276
+ * On failure: retry up to retryLimit times, then restore original
277
+ */
278
+ async fixFileWithRetry(filePath, issues) {
279
+ const absPath = path.resolve(this.cwd, filePath);
280
+
281
+ // Read original file content
282
+ let originalContent;
283
+ try {
284
+ originalContent = fs.readFileSync(absPath, 'utf8');
285
+ } catch (err) {
286
+ console.log(` ✗ Cannot read file: ${err.message}`);
287
+ return { success: false, error: err.message };
288
+ }
289
+
290
+ let currentContent = originalContent;
291
+ let lastErrors = '';
292
+
293
+ for (let attempt = 1; attempt <= this.retryLimit; attempt++) {
294
+ if (attempt > 1) {
295
+ console.log(` ↻ Retry attempt ${attempt}/${this.retryLimit}...`);
296
+ }
297
+
298
+ try {
299
+ // Build prompt and call LLM
300
+ const fixedContent = await this.requestFix(filePath, currentContent, issues, lastErrors, attempt);
301
+
302
+ if (!fixedContent || fixedContent.trim() === currentContent.trim()) {
303
+ console.log(' ✗ LLM returned unchanged content');
304
+ return { success: false, error: 'No changes produced' };
305
+ }
306
+
307
+ // Write the fixed content
308
+ if (!this.dryRun) {
309
+ fs.writeFileSync(absPath, fixedContent, 'utf8');
310
+ }
311
+
312
+ // Validate the file
313
+ // const validation = await this.validator.validateFile(filePath);
314
+ // if (validation.success) {
315
+ return { success: true, attempt };
316
+ // }
317
+
318
+ // Validation failed — prepare for retry
319
+ // lastErrors = validation.errors.join('\n');
320
+ // currentContent = fixedContent;
321
+ // console.log(` ✗ Validation failed: ${validation.errors[0]}`);
322
+ } catch (err) {
323
+ console.log(` ✗ Fix attempt failed: ${err.message}`);
324
+ lastErrors = err.message;
325
+ }
326
+ }
327
+
328
+ // All retries exhausted — restore original
329
+ if (!this.dryRun) {
330
+ fs.writeFileSync(absPath, originalContent, 'utf8');
331
+ }
332
+ return { success: false, error: 'All retry attempts exhausted' };
333
+ }
334
+
335
+ /**
336
+ * Request a fix from the LLM
337
+ */
338
+ async requestFix(filePath, fileContent, issues, validationErrors, attempt) {
339
+ // Truncate file content if too long
340
+
341
+ const issueDescriptions = issues.map((issue, i) =>
342
+ `Issue ${i + 1}:\n- Rule: ${issue.rule}\n- Severity: ${issue.severity}\n- Type: ${issue.type}\n- Message: ${issue.message}\n- Line: ${issue.line}`
343
+ ).join('\n\n');
344
+
345
+ let systemPrompt = 'You are an expert software engineer tasked with fixing code quality issues identified by SonarQube/SonarCloud.';
346
+
347
+ let userPrompt;
348
+ if (attempt > 1 && validationErrors) {
349
+ // Retry prompt
350
+ userPrompt = `A previous attempt to fix this file failed validation. Please fix the file again, addressing both the original Sonar issues AND the validation errors.
351
+
352
+ File: ${filePath}
353
+
354
+ \`\`\`
355
+ ${fileContent}
356
+ \`\`\`
357
+
358
+ Original Sonar Issues:
359
+ ${issueDescriptions}
360
+
361
+ Validation Errors from Previous Attempt:
362
+ \`\`\`
363
+ ${validationErrors}
364
+ \`\`\`
365
+
366
+ Rules:
367
+ - Modify only the supplied file.
368
+ - Do not rename public APIs.
369
+ - Preserve formatting and code style.
370
+ - Do not introduce unrelated changes.
371
+ - Return the complete file content.
372
+
373
+ Return ONLY the complete fixed file content, wrapped in a code block.`;
374
+ } else {
375
+ // First attempt prompt
376
+ userPrompt = `Fix the following Sonar issues in this file.
377
+
378
+ File: ${filePath}
379
+
380
+ \`\`\`
381
+ ${fileContent}
382
+ \`\`\`
383
+
384
+ Issues to Fix:
385
+ ${issueDescriptions}
386
+
387
+ ${this.config.prompt?.codingGuidelines ? `Coding Guidelines:\n${this.config.prompt.codingGuidelines}\n` : ''}
388
+ ${this.config.prompt?.projectInstructions ? `Project Instructions:\n${this.config.prompt.projectInstructions}\n` : ''}
389
+
390
+ Rules:
391
+ - Modify only the supplied file.
392
+ - Do not rename public APIs.
393
+ - Preserve formatting and code style.
394
+ - Do not introduce unrelated changes.
395
+ - Return the complete file content.
396
+
397
+ Return ONLY the complete fixed file content, wrapped in a code block.`;
398
+ }
399
+
400
+ const messages = [
401
+ { role: 'system', content: systemPrompt },
402
+ { role: 'user', content: userPrompt },
403
+ ];
404
+
405
+ const response = await this.llmClient.chat(messages, {
406
+ temperature: 0.1
407
+ });
408
+
409
+ // Extract code from the response (strip markdown code block markers)
410
+ return this.extractCodeFromResponse(response);
411
+ }
412
+
413
+ /**
414
+ * Extract code content from LLM response, stripping markdown fences
415
+ */
416
+ extractCodeFromResponse(response) {
417
+ if (!response) {
418
+ console.log('No response from LLM');
419
+ return null;
420
+ }
421
+
422
+ // Try to extract from code block
423
+ const codeBlockMatch = response.match(/```[\w]*\n([\s\S]*?)```/);
424
+ if (codeBlockMatch) {
425
+ return codeBlockMatch[1];
426
+ }
427
+
428
+ // If no code block found, return the response as-is (it might be raw code)
429
+ return response.trim();
430
+ }
431
+
432
+ /**
433
+ * Build a descriptive PR body
434
+ */
435
+ buildPRBody(totalFixed, iterations) {
436
+ return `## Automated Sonar Issue Fixes
437
+
438
+ This PR was automatically generated by **sonar-autofix**.
439
+
440
+ ### Summary
441
+ - **Files fixed:** ${totalFixed}
442
+ - **Iterations:** ${iterations}
443
+
444
+ ### What was done
445
+ 1. Ran SonarQube/SonarCloud analysis
446
+ 2. Fetched and prioritized open issues
447
+ 3. Used LLM to generate fixes grouped by file
448
+ 4. Validated each fix (syntax, lint, build)
449
+ 5. Re-ran Sonar to verify improvement
450
+ 6. Committed all successful fixes
451
+
452
+ ### Review Notes
453
+ - All changes have been validated against the project's build, lint, and test configuration.
454
+ - Please review the changes carefully before merging.
455
+
456
+ ---
457
+ *Generated by [sonar-autofix](https://github.com/sonar-autofix)*`;
458
+ }
459
+
460
+ /**
461
+ * Run plugin hooks
462
+ */
463
+ async runHooks(hookName, context = {}) {
464
+ const hooks = this.hooks[hookName] || [];
465
+ for (const hook of hooks) {
466
+ if (typeof hook === 'function') {
467
+ await hook(context);
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ module.exports = CoreOrchestrator;
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@sonar-autofix/core",
3
+ "version": "0.0.1",
4
+ "description": "Core orchestrator for the Sonar Autofix tool",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [
10
+ "sonar",
11
+ "autofix",
12
+ "github",
13
+ "action"
14
+ ],
15
+ "author": "Sonar Autofix Team",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@sonar-autofix/sonar-client": "^0.0.1",
19
+ "@sonar-autofix/llm-client": "^0.0.1",
20
+ "@sonar-autofix/git-client": "^0.0.1"
21
+ }
22
+ }