@probelabs/visor 0.1.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.
Files changed (116) hide show
  1. package/README.md +1240 -0
  2. package/action.yml +142 -0
  3. package/defaults/.visor.yaml +184 -0
  4. package/dist/action-cli-bridge.d.ts +104 -0
  5. package/dist/action-cli-bridge.d.ts.map +1 -0
  6. package/dist/action-cli-bridge.js +372 -0
  7. package/dist/action-cli-bridge.js.map +1 -0
  8. package/dist/ai-review-service.d.ts +84 -0
  9. package/dist/ai-review-service.d.ts.map +1 -0
  10. package/dist/ai-review-service.js +674 -0
  11. package/dist/ai-review-service.js.map +1 -0
  12. package/dist/check-execution-engine.d.ts +165 -0
  13. package/dist/check-execution-engine.d.ts.map +1 -0
  14. package/dist/check-execution-engine.js +1172 -0
  15. package/dist/check-execution-engine.js.map +1 -0
  16. package/dist/cli-main.d.ts +6 -0
  17. package/dist/cli-main.d.ts.map +1 -0
  18. package/dist/cli-main.js +247 -0
  19. package/dist/cli-main.js.map +1 -0
  20. package/dist/cli.d.ts +47 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +224 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/commands.d.ts +10 -0
  25. package/dist/commands.d.ts.map +1 -0
  26. package/dist/commands.js +53 -0
  27. package/dist/commands.js.map +1 -0
  28. package/dist/config.d.ts +63 -0
  29. package/dist/config.d.ts.map +1 -0
  30. package/dist/config.js +369 -0
  31. package/dist/config.js.map +1 -0
  32. package/dist/dependency-resolver.d.ts +54 -0
  33. package/dist/dependency-resolver.d.ts.map +1 -0
  34. package/dist/dependency-resolver.js +163 -0
  35. package/dist/dependency-resolver.js.map +1 -0
  36. package/dist/event-mapper.d.ts +125 -0
  37. package/dist/event-mapper.d.ts.map +1 -0
  38. package/dist/event-mapper.js +311 -0
  39. package/dist/event-mapper.js.map +1 -0
  40. package/dist/failure-condition-evaluator.d.ts +81 -0
  41. package/dist/failure-condition-evaluator.d.ts.map +1 -0
  42. package/dist/failure-condition-evaluator.js +445 -0
  43. package/dist/failure-condition-evaluator.js.map +1 -0
  44. package/dist/git-repository-analyzer.d.ts +45 -0
  45. package/dist/git-repository-analyzer.d.ts.map +1 -0
  46. package/dist/git-repository-analyzer.js +285 -0
  47. package/dist/git-repository-analyzer.js.map +1 -0
  48. package/dist/github-check-service.d.ts +104 -0
  49. package/dist/github-check-service.d.ts.map +1 -0
  50. package/dist/github-check-service.js +382 -0
  51. package/dist/github-check-service.js.map +1 -0
  52. package/dist/github-comments.d.ts +109 -0
  53. package/dist/github-comments.d.ts.map +1 -0
  54. package/dist/github-comments.js +289 -0
  55. package/dist/github-comments.js.map +1 -0
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +1265 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/output-formatters.d.ts +66 -0
  61. package/dist/output-formatters.d.ts.map +1 -0
  62. package/dist/output-formatters.js +624 -0
  63. package/dist/output-formatters.js.map +1 -0
  64. package/dist/pr-analyzer.d.ts +47 -0
  65. package/dist/pr-analyzer.d.ts.map +1 -0
  66. package/dist/pr-analyzer.js +194 -0
  67. package/dist/pr-analyzer.js.map +1 -0
  68. package/dist/pr-detector.d.ts +78 -0
  69. package/dist/pr-detector.d.ts.map +1 -0
  70. package/dist/pr-detector.js +357 -0
  71. package/dist/pr-detector.js.map +1 -0
  72. package/dist/providers/ai-check-provider.d.ts +40 -0
  73. package/dist/providers/ai-check-provider.d.ts.map +1 -0
  74. package/dist/providers/ai-check-provider.js +416 -0
  75. package/dist/providers/ai-check-provider.js.map +1 -0
  76. package/dist/providers/check-provider-registry.d.ts +67 -0
  77. package/dist/providers/check-provider-registry.d.ts.map +1 -0
  78. package/dist/providers/check-provider-registry.js +138 -0
  79. package/dist/providers/check-provider-registry.js.map +1 -0
  80. package/dist/providers/check-provider.interface.d.ts +78 -0
  81. package/dist/providers/check-provider.interface.d.ts.map +1 -0
  82. package/dist/providers/check-provider.interface.js +11 -0
  83. package/dist/providers/check-provider.interface.js.map +1 -0
  84. package/dist/providers/index.d.ts +10 -0
  85. package/dist/providers/index.d.ts.map +1 -0
  86. package/dist/providers/index.js +19 -0
  87. package/dist/providers/index.js.map +1 -0
  88. package/dist/providers/script-check-provider.d.ts +20 -0
  89. package/dist/providers/script-check-provider.d.ts.map +1 -0
  90. package/dist/providers/script-check-provider.js +163 -0
  91. package/dist/providers/script-check-provider.js.map +1 -0
  92. package/dist/providers/tool-check-provider.d.ts +19 -0
  93. package/dist/providers/tool-check-provider.d.ts.map +1 -0
  94. package/dist/providers/tool-check-provider.js +125 -0
  95. package/dist/providers/tool-check-provider.js.map +1 -0
  96. package/dist/providers/webhook-check-provider.d.ts +21 -0
  97. package/dist/providers/webhook-check-provider.d.ts.map +1 -0
  98. package/dist/providers/webhook-check-provider.js +173 -0
  99. package/dist/providers/webhook-check-provider.js.map +1 -0
  100. package/dist/reviewer.d.ts +88 -0
  101. package/dist/reviewer.d.ts.map +1 -0
  102. package/dist/reviewer.js +760 -0
  103. package/dist/reviewer.js.map +1 -0
  104. package/dist/types/cli.d.ts +41 -0
  105. package/dist/types/cli.d.ts.map +1 -0
  106. package/dist/types/cli.js +3 -0
  107. package/dist/types/cli.js.map +1 -0
  108. package/dist/types/config.d.ts +315 -0
  109. package/dist/types/config.d.ts.map +1 -0
  110. package/dist/types/config.js +6 -0
  111. package/dist/types/config.js.map +1 -0
  112. package/dist/utils/env-resolver.d.ts +38 -0
  113. package/dist/utils/env-resolver.d.ts.map +1 -0
  114. package/dist/utils/env-resolver.js +130 -0
  115. package/dist/utils/env-resolver.js.map +1 -0
  116. package/package.json +116 -0
@@ -0,0 +1,760 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PRReviewer = void 0;
40
+ exports.calculateTotalIssues = calculateTotalIssues;
41
+ exports.calculateCriticalIssues = calculateCriticalIssues;
42
+ exports.convertIssuesToComments = convertIssuesToComments;
43
+ const github_comments_1 = require("./github-comments");
44
+ const ai_review_service_1 = require("./ai-review-service");
45
+ const liquidjs_1 = require("liquidjs");
46
+ const promises_1 = __importDefault(require("fs/promises"));
47
+ const path_1 = __importDefault(require("path"));
48
+ const crypto = __importStar(require("crypto"));
49
+ // Helper functions for calculating metrics from issues
50
+ function calculateTotalIssues(issues) {
51
+ return issues.length;
52
+ }
53
+ function calculateCriticalIssues(issues) {
54
+ return issues.filter(i => i.severity === 'critical').length;
55
+ }
56
+ function convertIssuesToComments(issues) {
57
+ return issues.map(issue => ({
58
+ file: issue.file,
59
+ line: issue.line,
60
+ message: issue.message,
61
+ severity: issue.severity,
62
+ category: issue.category,
63
+ suggestion: issue.suggestion,
64
+ replacement: issue.replacement,
65
+ ruleId: issue.ruleId, // Preserve ruleId for check-based grouping
66
+ }));
67
+ }
68
+ class PRReviewer {
69
+ octokit;
70
+ commentManager;
71
+ aiReviewService;
72
+ constructor(octokit) {
73
+ this.octokit = octokit;
74
+ this.commentManager = new github_comments_1.CommentManager(octokit);
75
+ this.aiReviewService = new ai_review_service_1.AIReviewService();
76
+ }
77
+ async reviewPR(owner, repo, prNumber, prInfo, options = {}) {
78
+ const { debug = false, config, checks } = options;
79
+ // If we have a config and checks, use CheckExecutionEngine
80
+ if (config && checks && checks.length > 0) {
81
+ // Import CheckExecutionEngine dynamically to avoid circular dependencies
82
+ const { CheckExecutionEngine } = await Promise.resolve().then(() => __importStar(require('./check-execution-engine')));
83
+ const engine = new CheckExecutionEngine();
84
+ // Execute checks using the engine
85
+ const reviewSummary = await engine['executeReviewChecks'](prInfo, checks, undefined, config, undefined, debug);
86
+ // Return all issues - no filtering needed
87
+ return reviewSummary;
88
+ }
89
+ // No config provided - require configuration
90
+ throw new Error('No configuration provided. Please create a .visor.yaml file with check definitions. ' +
91
+ 'Built-in prompts have been removed - all checks must be explicitly configured.');
92
+ }
93
+ async postReviewComment(owner, repo, prNumber, summary, options = {}) {
94
+ // Group issues by their group property
95
+ const issuesByGroup = this.groupIssuesByGroup(summary.issues);
96
+ // If no groups or only one group, still use consistent group-based comment IDs
97
+ if (Object.keys(issuesByGroup).length <= 1) {
98
+ const comment = await this.formatReviewCommentWithVisorFormat(summary, options, {
99
+ owner,
100
+ repo,
101
+ prNumber,
102
+ commitSha: options.commitSha,
103
+ });
104
+ // Use consistent group-based comment ID even for single group
105
+ const baseCommentId = options.commentId || 'visor-review';
106
+ const groupName = Object.keys(issuesByGroup)[0] || 'default';
107
+ const consistentCommentId = `${baseCommentId}-${groupName}`;
108
+ await this.commentManager.updateOrCreateComment(owner, repo, prNumber, comment, {
109
+ commentId: consistentCommentId,
110
+ triggeredBy: options.triggeredBy || 'unknown',
111
+ allowConcurrentUpdates: false,
112
+ commitSha: options.commitSha,
113
+ });
114
+ return;
115
+ }
116
+ // Sort groups by the earliest timestamp of issues in each group
117
+ // This ensures comments are posted in the order checks completed
118
+ const sortedGroups = Object.entries(issuesByGroup).sort(([_aName, aIssues], [_bName, bIssues]) => {
119
+ // Find the earliest timestamp in each group
120
+ const aEarliest = Math.min(...aIssues.map(i => i.timestamp || Infinity));
121
+ const bEarliest = Math.min(...bIssues.map(i => i.timestamp || Infinity));
122
+ return aEarliest - bEarliest;
123
+ });
124
+ // Create separate comments for each group
125
+ for (const [groupName, groupIssues] of sortedGroups) {
126
+ const groupSummary = {
127
+ ...summary,
128
+ issues: groupIssues,
129
+ };
130
+ // Use group name in comment ID to create separate comments
131
+ // Always include the group name suffix to ensure uniqueness
132
+ const baseCommentId = options.commentId || 'visor-review';
133
+ const groupCommentId = `${baseCommentId}-${groupName}`;
134
+ const comment = await this.formatReviewCommentWithVisorFormat(groupSummary, options, {
135
+ owner,
136
+ repo,
137
+ prNumber,
138
+ commitSha: options.commitSha,
139
+ });
140
+ await this.commentManager.updateOrCreateComment(owner, repo, prNumber, comment, {
141
+ commentId: groupCommentId,
142
+ triggeredBy: options.triggeredBy || 'unknown',
143
+ allowConcurrentUpdates: false,
144
+ commitSha: options.commitSha,
145
+ });
146
+ }
147
+ }
148
+ async formatReviewCommentWithVisorFormat(summary, _options, githubContext) {
149
+ const totalIssues = calculateTotalIssues(summary.issues);
150
+ let comment = '';
151
+ // Add main header
152
+ if (totalIssues === 0) {
153
+ comment += `## ✅ All Checks Passed\n\n**No issues found – changes LGTM.**\n\n`;
154
+ }
155
+ else {
156
+ comment += `## 🔍 Code Analysis Results\n\n`;
157
+ // Use new schema-template system for content generation
158
+ const templateContent = await this.renderWithSchemaTemplate(summary, githubContext);
159
+ comment += templateContent;
160
+ }
161
+ // Add debug section if available
162
+ if (summary.debug) {
163
+ comment += '\n\n' + this.formatDebugSection(summary.debug);
164
+ comment += '\n\n';
165
+ }
166
+ // Simple footer
167
+ comment += `---\n*Powered by [Visor](https://probelabs.com/visor) from [Probelabs](https://probelabs.com)*`;
168
+ return comment;
169
+ }
170
+ async renderWithSchemaTemplate(summary, githubContext) {
171
+ try {
172
+ // Group issues by check name and render each check separately
173
+ const issuesByCheck = this.groupIssuesByCheck(summary.issues);
174
+ if (Object.keys(issuesByCheck).length === 0) {
175
+ return 'No issues found in this group.';
176
+ }
177
+ const renderedSections = [];
178
+ for (const [checkName, checkIssues] of Object.entries(issuesByCheck)) {
179
+ const checkSchema = checkIssues[0]?.schema || 'code-review';
180
+ const customTemplate = checkIssues[0]?.template;
181
+ const renderedSection = await this.renderSingleCheckTemplate(checkName, checkIssues, checkSchema, customTemplate, githubContext);
182
+ renderedSections.push(renderedSection);
183
+ }
184
+ // Combine all check sections with proper spacing
185
+ return renderedSections.join('\n\n');
186
+ }
187
+ catch (error) {
188
+ console.warn('Failed to render with schema-template system, falling back to old system:', error);
189
+ // Fallback to old system if template fails
190
+ const comments = convertIssuesToComments(summary.issues);
191
+ return this.formatIssuesTable(comments);
192
+ }
193
+ }
194
+ generateGitHubDiffHash(filePath) {
195
+ // GitHub uses SHA256 hash of the file path for diff anchors
196
+ return crypto.createHash('sha256').update(filePath).digest('hex');
197
+ }
198
+ enhanceIssuesWithGitHubLinks(issues, githubContext) {
199
+ if (!githubContext) {
200
+ return issues;
201
+ }
202
+ // Use commit SHA for permalink format that auto-expands
203
+ // If no commit SHA provided, fall back to PR files view
204
+ const baseUrl = githubContext.commitSha
205
+ ? `https://github.com/${githubContext.owner}/${githubContext.repo}/blob/${githubContext.commitSha}`
206
+ : `https://github.com/${githubContext.owner}/${githubContext.repo}/pull/${githubContext.prNumber}/files`;
207
+ return issues.map(issue => ({
208
+ ...issue,
209
+ githubUrl: githubContext.commitSha && issue.line
210
+ ? `${baseUrl}/${issue.file}#L${issue.line}${issue.endLine && issue.endLine !== issue.line ? `-L${issue.endLine}` : ''}`
211
+ : baseUrl,
212
+ fileHash: this.generateGitHubDiffHash(issue.file),
213
+ }));
214
+ }
215
+ async renderSingleCheckTemplate(checkName, issues, schema, customTemplate, githubContext) {
216
+ const liquid = new liquidjs_1.Liquid({
217
+ // Configure Liquid to handle whitespace better
218
+ trimTagLeft: false, // Don't auto-trim left side of tags
219
+ trimTagRight: false, // Don't auto-trim right side of tags
220
+ trimOutputLeft: false, // Don't auto-trim left side of output
221
+ trimOutputRight: false, // Don't auto-trim right side of output
222
+ greedy: false, // Don't be greedy with whitespace trimming
223
+ });
224
+ // Load template content based on configuration
225
+ let templateContent;
226
+ if (customTemplate) {
227
+ templateContent = await this.loadCustomTemplate(customTemplate);
228
+ }
229
+ else {
230
+ // Sanitize schema name to prevent path traversal attacks
231
+ const sanitizedSchema = schema.replace(/[^a-zA-Z0-9-]/g, '');
232
+ if (!sanitizedSchema) {
233
+ throw new Error('Invalid schema name');
234
+ }
235
+ // Load the appropriate template based on schema
236
+ const templatePath = path_1.default.join(__dirname, `../output/${sanitizedSchema}/template.liquid`);
237
+ templateContent = await promises_1.default.readFile(templatePath, 'utf-8');
238
+ }
239
+ // Enhance issues with GitHub links if context is available
240
+ const enhancedIssues = this.enhanceIssuesWithGitHubLinks(issues, githubContext);
241
+ let templateData;
242
+ if (schema === 'plain') {
243
+ // For plain schema, pass the message content directly
244
+ templateData = {
245
+ content: issues.length > 0 ? issues[0].message : 'No content available',
246
+ checkName: checkName,
247
+ github: githubContext,
248
+ };
249
+ }
250
+ else {
251
+ // For code-review schema, pass enhanced issues with GitHub links
252
+ templateData = {
253
+ issues: enhancedIssues,
254
+ checkName: checkName,
255
+ github: githubContext,
256
+ };
257
+ }
258
+ // Render with Liquid template and trim any extra whitespace at the start/end
259
+ const rendered = await liquid.parseAndRender(templateContent, templateData);
260
+ return rendered.trim();
261
+ }
262
+ groupIssuesByCheck(issues) {
263
+ const grouped = {};
264
+ for (const issue of issues) {
265
+ const checkName = this.extractCheckNameFromRuleId(issue.ruleId || 'uncategorized');
266
+ if (!grouped[checkName]) {
267
+ grouped[checkName] = [];
268
+ }
269
+ grouped[checkName].push(issue);
270
+ }
271
+ return grouped;
272
+ }
273
+ extractCheckNameFromRuleId(ruleId) {
274
+ if (ruleId && ruleId.includes('/')) {
275
+ return ruleId.split('/')[0];
276
+ }
277
+ return 'uncategorized';
278
+ }
279
+ groupIssuesByGroup(issues) {
280
+ const grouped = {};
281
+ for (const issue of issues) {
282
+ const groupName = issue.group || 'default';
283
+ if (!grouped[groupName]) {
284
+ grouped[groupName] = [];
285
+ }
286
+ grouped[groupName].push(issue);
287
+ }
288
+ return grouped;
289
+ }
290
+ formatReviewComment(summary, options) {
291
+ const { format = 'table' } = options;
292
+ // Calculate metrics from issues
293
+ const totalIssues = calculateTotalIssues(summary.issues);
294
+ const criticalIssues = calculateCriticalIssues(summary.issues);
295
+ const comments = convertIssuesToComments(summary.issues);
296
+ let comment = `## 🤖 AI Code Review\n\n`;
297
+ comment += `**Issues Found:** ${totalIssues} (${criticalIssues} critical)\n\n`;
298
+ if (summary.suggestions.length > 0) {
299
+ comment += `### 💡 Suggestions\n`;
300
+ for (const suggestion of summary.suggestions) {
301
+ comment += `- ${suggestion}\n`;
302
+ }
303
+ comment += '\n';
304
+ }
305
+ if (comments.length > 0) {
306
+ comment += `### 🔍 Code Issues\n`;
307
+ for (const reviewComment of comments) {
308
+ const emoji = reviewComment.severity === 'error'
309
+ ? '❌'
310
+ : reviewComment.severity === 'warning'
311
+ ? '⚠️'
312
+ : 'ℹ️';
313
+ comment += `${emoji} **${reviewComment.file}:${reviewComment.line}** (${reviewComment.category})\n`;
314
+ comment += ` ${reviewComment.message}\n\n`;
315
+ }
316
+ }
317
+ if (format === 'table' && totalIssues > 5) {
318
+ comment += `*Showing top 5 issues. Use \`/review --format=markdown\` for complete analysis.*\n\n`;
319
+ }
320
+ // Add debug section if debug information is available
321
+ if (summary.debug) {
322
+ comment += '\n\n' + this.formatDebugSection(summary.debug);
323
+ comment += '\n\n';
324
+ }
325
+ comment += `---\n`;
326
+ comment += `*Powered by [Visor](https://probelabs.com/visor) from [Probelabs](https://probelabs.com)*`;
327
+ return comment;
328
+ }
329
+ groupCommentsByCategory(comments) {
330
+ const grouped = {
331
+ security: [],
332
+ performance: [],
333
+ style: [],
334
+ logic: [],
335
+ documentation: [],
336
+ };
337
+ for (const comment of comments) {
338
+ if (!grouped[comment.category]) {
339
+ grouped[comment.category] = [];
340
+ }
341
+ grouped[comment.category].push(comment);
342
+ }
343
+ return grouped;
344
+ }
345
+ groupCommentsByCheck(comments) {
346
+ const grouped = {};
347
+ for (const comment of comments) {
348
+ // Extract check name from ruleId prefix (e.g., "security/sql-injection" -> "security")
349
+ let checkName = 'uncategorized';
350
+ if (comment.ruleId && comment.ruleId.includes('/')) {
351
+ const parts = comment.ruleId.split('/');
352
+ checkName = parts[0];
353
+ }
354
+ if (!grouped[checkName]) {
355
+ grouped[checkName] = [];
356
+ }
357
+ grouped[checkName].push(comment);
358
+ }
359
+ return grouped;
360
+ }
361
+ formatDebugSection(debug) {
362
+ const formattedContent = [
363
+ `**Provider:** ${debug.provider}`,
364
+ `**Model:** ${debug.model}`,
365
+ `**API Key Source:** ${debug.apiKeySource}`,
366
+ `**Processing Time:** ${debug.processingTime}ms`,
367
+ `**Timestamp:** ${debug.timestamp}`,
368
+ `**Prompt Length:** ${debug.promptLength} characters`,
369
+ `**Response Length:** ${debug.responseLength} characters`,
370
+ `**JSON Parse Success:** ${debug.jsonParseSuccess ? '✅' : '❌'}`,
371
+ ];
372
+ if (debug.errors && debug.errors.length > 0) {
373
+ formattedContent.push('', '### Errors');
374
+ debug.errors.forEach(error => {
375
+ formattedContent.push(`- ${error}`);
376
+ });
377
+ }
378
+ // Check if debug content would be too large for GitHub comment
379
+ const fullDebugContent = [
380
+ ...formattedContent,
381
+ '',
382
+ '### AI Prompt',
383
+ '```',
384
+ debug.prompt,
385
+ '```',
386
+ '',
387
+ '### Raw AI Response',
388
+ '```json',
389
+ debug.rawResponse,
390
+ '```',
391
+ ].join('\n');
392
+ // GitHub comment limit is 65536 characters, leave some buffer
393
+ if (fullDebugContent.length > 60000) {
394
+ // Save debug info to artifact and provide link
395
+ const artifactPath = this.saveDebugArtifact(debug);
396
+ formattedContent.push('');
397
+ formattedContent.push('### Debug Details');
398
+ formattedContent.push('⚠️ Debug information is too large for GitHub comments.');
399
+ if (artifactPath) {
400
+ formattedContent.push(`📁 **Full debug information saved to artifact:** \`${artifactPath}\``);
401
+ formattedContent.push('');
402
+ // Try to get GitHub context for artifact link
403
+ const runId = process.env.GITHUB_RUN_ID;
404
+ const repoUrl = process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY
405
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}`
406
+ : null;
407
+ if (runId && repoUrl) {
408
+ formattedContent.push(`🔗 **Download Link:** [visor-debug-${process.env.GITHUB_RUN_NUMBER || runId}](${repoUrl}/actions/runs/${runId})`);
409
+ }
410
+ formattedContent.push('💡 Go to the GitHub Action run above and download the debug artifact to view complete prompts and responses.');
411
+ }
412
+ else {
413
+ formattedContent.push('📝 **Prompt preview:** ' + debug.prompt.substring(0, 500) + '...');
414
+ formattedContent.push('📝 **Response preview:** ' + debug.rawResponse.substring(0, 500) + '...');
415
+ }
416
+ }
417
+ else {
418
+ // Include full debug content if it fits
419
+ formattedContent.push('');
420
+ formattedContent.push('### AI Prompt');
421
+ formattedContent.push('```');
422
+ formattedContent.push(debug.prompt);
423
+ formattedContent.push('```');
424
+ formattedContent.push('');
425
+ formattedContent.push('### Raw AI Response');
426
+ formattedContent.push('```json');
427
+ formattedContent.push(debug.rawResponse);
428
+ formattedContent.push('```');
429
+ }
430
+ return this.commentManager.createCollapsibleSection('🐛 Debug Information', formattedContent.join('\n'), false // Start collapsed
431
+ );
432
+ }
433
+ saveDebugArtifact(debug) {
434
+ try {
435
+ const fs = require('fs');
436
+ const path = require('path');
437
+ // Create debug directory if it doesn't exist
438
+ const debugDir = path.join(process.cwd(), 'debug-artifacts');
439
+ if (!fs.existsSync(debugDir)) {
440
+ fs.mkdirSync(debugDir, { recursive: true });
441
+ }
442
+ // Create debug file with timestamp
443
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
444
+ const filename = `visor-debug-${timestamp}.md`;
445
+ const filePath = path.join(debugDir, filename);
446
+ // Parse the combined prompts and responses to extract individual checks
447
+ const markdownContent = this.formatDebugAsMarkdown(debug);
448
+ fs.writeFileSync(filePath, markdownContent);
449
+ return filename;
450
+ }
451
+ catch (error) {
452
+ console.error(`❌ Failed to save debug artifact: ${error}`);
453
+ return null;
454
+ }
455
+ }
456
+ formatDebugAsMarkdown(debug) {
457
+ const lines = [
458
+ '# Visor AI Debug Information',
459
+ '',
460
+ `**Generated:** ${debug.timestamp}`,
461
+ `**Provider:** ${debug.provider}`,
462
+ `**Model:** ${debug.model}`,
463
+ `**API Key Source:** ${debug.apiKeySource}`,
464
+ `**Total Processing Time:** ${debug.processingTime}ms`,
465
+ `**Total Prompt Length:** ${debug.promptLength} characters`,
466
+ `**Total Response Length:** ${debug.responseLength} characters`,
467
+ `**JSON Parse Success:** ${debug.jsonParseSuccess ? '✅' : '❌'}`,
468
+ '',
469
+ ];
470
+ if (debug.errors && debug.errors.length > 0) {
471
+ lines.push('## ❌ Errors');
472
+ debug.errors.forEach(error => {
473
+ lines.push(`- ${error}`);
474
+ });
475
+ lines.push('');
476
+ }
477
+ // Parse combined prompt and response to extract individual checks
478
+ const promptSections = this.parseCheckSections(debug.prompt);
479
+ const responseSections = this.parseCheckSections(debug.rawResponse);
480
+ lines.push('## 📊 Check Results Summary');
481
+ lines.push('');
482
+ promptSections.forEach(section => {
483
+ const responseSection = responseSections.find(r => r.checkName === section.checkName);
484
+ lines.push(`- **${section.checkName}**: ${responseSection ? 'Success' : 'Failed'}`);
485
+ });
486
+ lines.push('');
487
+ // Add detailed information for each check
488
+ promptSections.forEach((promptSection, index) => {
489
+ const responseSection = responseSections.find(r => r.checkName === promptSection.checkName);
490
+ lines.push(`## ${index + 1}. ${promptSection.checkName.toUpperCase()} Check`);
491
+ lines.push('');
492
+ lines.push('### 📝 AI Prompt');
493
+ lines.push('');
494
+ lines.push('```');
495
+ lines.push(promptSection.content);
496
+ lines.push('```');
497
+ lines.push('');
498
+ lines.push('### 🤖 AI Response');
499
+ lines.push('');
500
+ if (responseSection) {
501
+ lines.push('```json');
502
+ lines.push(responseSection.content);
503
+ lines.push('```');
504
+ }
505
+ else {
506
+ lines.push('❌ No response available for this check');
507
+ }
508
+ lines.push('');
509
+ lines.push('---');
510
+ lines.push('');
511
+ });
512
+ return lines.join('\n');
513
+ }
514
+ parseCheckSections(combinedText) {
515
+ const sections = [];
516
+ // Split by check sections like [security], [performance], etc.
517
+ const parts = combinedText.split(/\[(\w+)\]\s*\n/);
518
+ for (let i = 1; i < parts.length; i += 2) {
519
+ const checkName = parts[i];
520
+ const content = parts[i + 1]?.trim() || '';
521
+ if (checkName && content) {
522
+ sections.push({ checkName, content });
523
+ }
524
+ }
525
+ return sections;
526
+ }
527
+ formatIssuesTable(comments) {
528
+ let content = `## 🔍 Code Analysis Results\n\n`;
529
+ // Group comments by check (extracted from ruleId prefix)
530
+ const groupedComments = this.groupCommentsByCheck(comments);
531
+ // Create a table for each check that has issues
532
+ for (const [checkName, checkComments] of Object.entries(groupedComments)) {
533
+ if (checkComments.length === 0)
534
+ continue;
535
+ const checkTitle = checkName.charAt(0).toUpperCase() + checkName.slice(1);
536
+ // Check heading
537
+ content += `### ${checkTitle} Issues (${checkComments.length})\n\n`;
538
+ // Start HTML table for this category
539
+ content += `<table>\n`;
540
+ content += ` <thead>\n`;
541
+ content += ` <tr>\n`;
542
+ content += ` <th>Severity</th>\n`;
543
+ content += ` <th>File</th>\n`;
544
+ content += ` <th>Line</th>\n`;
545
+ content += ` <th>Issue</th>\n`;
546
+ content += ` </tr>\n`;
547
+ content += ` </thead>\n`;
548
+ content += ` <tbody>\n`;
549
+ // Sort comments within check by severity, then by file
550
+ const sortedCheckComments = checkComments.sort((a, b) => {
551
+ const severityOrder = { critical: 0, error: 1, warning: 2, info: 3 };
552
+ const severityDiff = (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4);
553
+ if (severityDiff !== 0)
554
+ return severityDiff;
555
+ return a.file.localeCompare(b.file);
556
+ });
557
+ for (const comment of sortedCheckComments) {
558
+ const severityEmoji = comment.severity === 'critical'
559
+ ? '🔴'
560
+ : comment.severity === 'error'
561
+ ? '🔴'
562
+ : comment.severity === 'warning'
563
+ ? '🟡'
564
+ : '🟢';
565
+ const severityText = comment.severity.charAt(0).toUpperCase() + comment.severity.slice(1);
566
+ // Build the issue description with suggestion/replacement if available
567
+ // Wrap content in a div for better table layout control
568
+ let issueContent = '';
569
+ // Pass the message as-is - Liquid template will handle escaping
570
+ issueContent += comment.message;
571
+ if (comment.suggestion) {
572
+ // Pass suggestion as-is - Liquid template will handle escaping
573
+ issueContent += `\n<details><summary>💡 <strong>Suggestion</strong></summary>${comment.suggestion}</details>`;
574
+ }
575
+ if (comment.replacement) {
576
+ // Extract language hint from file extension
577
+ const fileExt = comment.file.split('.').pop()?.toLowerCase() || 'text';
578
+ const languageHint = this.getLanguageHint(fileExt);
579
+ // Pass replacement as-is - Liquid template will handle escaping
580
+ issueContent += `\n<details><summary>🔧 <strong>Suggested Fix</strong></summary><pre><code class="language-${languageHint}">${comment.replacement}</code></pre></details>`;
581
+ }
582
+ // Wrap all content in a div for better table cell containment
583
+ const issueDescription = `<div>${issueContent}</div>`;
584
+ content += ` <tr>\n`;
585
+ content += ` <td>${severityEmoji} ${severityText}</td>\n`;
586
+ content += ` <td><code>${comment.file}</code></td>\n`;
587
+ content += ` <td>${comment.line}</td>\n`;
588
+ content += ` <td>${issueDescription}</td>\n`;
589
+ content += ` </tr>\n`;
590
+ }
591
+ // Close HTML table for this category
592
+ content += ` </tbody>\n`;
593
+ content += `</table>\n\n`;
594
+ // No hardcoded recommendations - all guidance comes from .visor.yaml prompts
595
+ }
596
+ return content;
597
+ }
598
+ getLanguageHint(fileExtension) {
599
+ const langMap = {
600
+ ts: 'typescript',
601
+ tsx: 'typescript',
602
+ js: 'javascript',
603
+ jsx: 'javascript',
604
+ py: 'python',
605
+ java: 'java',
606
+ kt: 'kotlin',
607
+ swift: 'swift',
608
+ go: 'go',
609
+ rs: 'rust',
610
+ cpp: 'cpp',
611
+ c: 'c',
612
+ cs: 'csharp',
613
+ php: 'php',
614
+ rb: 'ruby',
615
+ scala: 'scala',
616
+ sh: 'bash',
617
+ bash: 'bash',
618
+ zsh: 'bash',
619
+ sql: 'sql',
620
+ json: 'json',
621
+ yaml: 'yaml',
622
+ yml: 'yaml',
623
+ xml: 'xml',
624
+ html: 'html',
625
+ css: 'css',
626
+ scss: 'scss',
627
+ sass: 'sass',
628
+ md: 'markdown',
629
+ dockerfile: 'dockerfile',
630
+ tf: 'hcl',
631
+ };
632
+ return langMap[fileExtension] || fileExtension;
633
+ }
634
+ /**
635
+ * Load custom template content from file or raw content
636
+ */
637
+ async loadCustomTemplate(config) {
638
+ if (config.content) {
639
+ // Auto-detect if content is actually a file path
640
+ if (await this.isFilePath(config.content)) {
641
+ return await this.loadTemplateFromFile(config.content);
642
+ }
643
+ else {
644
+ // Use raw template content directly
645
+ return config.content;
646
+ }
647
+ }
648
+ if (config.file) {
649
+ // Legacy explicit file property
650
+ return await this.loadTemplateFromFile(config.file);
651
+ }
652
+ throw new Error('Custom template configuration must specify either "file" or "content"');
653
+ }
654
+ /**
655
+ * Detect if a string is likely a file path and if the file exists
656
+ */
657
+ async isFilePath(str) {
658
+ // Quick checks to exclude obvious non-file-path content
659
+ if (!str || str.trim() !== str || str.length > 512) {
660
+ return false;
661
+ }
662
+ // Exclude strings that are clearly content (contain common content indicators)
663
+ // But be more careful with paths that might contain common words as directory names
664
+ if (/\s{2,}/.test(str) || // Multiple consecutive spaces
665
+ /\n/.test(str) || // Contains newlines
666
+ /^(please|analyze|review|check|find|identify|look|search)/i.test(str.trim()) || // Starts with command words
667
+ str.split(' ').length > 8 // Too many words for a typical file path
668
+ ) {
669
+ return false;
670
+ }
671
+ // For strings with path separators, be more lenient about common words
672
+ // as they might be legitimate directory names
673
+ if (!/[\/\\]/.test(str)) {
674
+ // Only apply strict English word filter to non-path strings
675
+ if (/\b(the|and|or|but|for|with|by|from|in|on|at|as)\b/i.test(str)) {
676
+ return false;
677
+ }
678
+ }
679
+ // Positive indicators for file paths
680
+ const hasFileExtension = /\.[a-zA-Z0-9]{1,10}$/i.test(str);
681
+ const hasPathSeparators = /[\/\\]/.test(str);
682
+ const isRelativePath = /^\.{1,2}\//.test(str);
683
+ const isAbsolutePath = path_1.default.isAbsolute(str);
684
+ const hasTypicalFileChars = /^[a-zA-Z0-9._\-\/\\:~]+$/.test(str);
685
+ // Must have at least one strong indicator
686
+ if (!(hasFileExtension || isRelativePath || isAbsolutePath || hasPathSeparators)) {
687
+ return false;
688
+ }
689
+ // Must contain only typical file path characters
690
+ if (!hasTypicalFileChars) {
691
+ return false;
692
+ }
693
+ // Additional validation for suspected file paths
694
+ try {
695
+ // Try to resolve and check if file exists
696
+ let resolvedPath;
697
+ if (path_1.default.isAbsolute(str)) {
698
+ resolvedPath = path_1.default.normalize(str);
699
+ }
700
+ else {
701
+ // Resolve relative to current working directory
702
+ resolvedPath = path_1.default.resolve(process.cwd(), str);
703
+ }
704
+ // Check if file exists
705
+ try {
706
+ const stat = await promises_1.default.stat(resolvedPath);
707
+ return stat.isFile();
708
+ }
709
+ catch {
710
+ // File doesn't exist, but might still be a valid file path format
711
+ // Return true if it has strong file path indicators
712
+ return hasFileExtension && (isRelativePath || isAbsolutePath || hasPathSeparators);
713
+ }
714
+ }
715
+ catch {
716
+ return false;
717
+ }
718
+ }
719
+ /**
720
+ * Safely load template from file with security checks
721
+ */
722
+ async loadTemplateFromFile(templatePath) {
723
+ // Resolve the path (handles both relative and absolute paths)
724
+ let resolvedPath;
725
+ if (path_1.default.isAbsolute(templatePath)) {
726
+ // Absolute path - use as-is but validate it's not trying to escape expected directories
727
+ resolvedPath = path_1.default.normalize(templatePath);
728
+ }
729
+ else {
730
+ // Relative path - resolve relative to current working directory
731
+ resolvedPath = path_1.default.resolve(process.cwd(), templatePath);
732
+ }
733
+ // Security: Normalize and check for path traversal attempts
734
+ const normalizedPath = path_1.default.normalize(resolvedPath);
735
+ // Security: For relative paths, ensure they don't escape the current directory
736
+ if (!path_1.default.isAbsolute(templatePath)) {
737
+ const currentDir = path_1.default.resolve(process.cwd());
738
+ if (!normalizedPath.startsWith(currentDir)) {
739
+ throw new Error('Invalid template file path: path traversal detected');
740
+ }
741
+ }
742
+ // Security: Additional check for obvious path traversal patterns
743
+ if (templatePath.includes('../..')) {
744
+ throw new Error('Invalid template file path: path traversal detected');
745
+ }
746
+ // Security: Check file extension
747
+ if (!normalizedPath.endsWith('.liquid')) {
748
+ throw new Error('Invalid template file: must have .liquid extension');
749
+ }
750
+ try {
751
+ const templateContent = await promises_1.default.readFile(normalizedPath, 'utf-8');
752
+ return templateContent;
753
+ }
754
+ catch (error) {
755
+ throw new Error(`Failed to load custom template from ${normalizedPath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
756
+ }
757
+ }
758
+ }
759
+ exports.PRReviewer = PRReviewer;
760
+ //# sourceMappingURL=reviewer.js.map