sfcoe-ailabs
Version:
AI-powered code review tool with static analysis integration for comprehensive code quality assessment.
1,233 lines • 59.6 kB
JavaScript
// Import necessary modules
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as process from 'node:process';
import { fileURLToPath, URL } from 'node:url';
import { Command, Flags } from '@oclif/core';
import * as fse from 'fs-extra';
// Import custom modules
import { GitHelper, GitProviderFactory, } from '../../common/gitProvider/index.js';
import { SCATProviderFactory, SCATProviderType, } from '../../common/contextProviders/scat/index.js';
import { AIProviderFactory, } from '../../common/aiProvider/index.js';
import { Logger, Metrics, ObservabilityInitializer, } from '../../common/observability/index.js';
import { FileUtils } from '../../common/utils/fileUtils.js';
import { DocuSignRuleUtils } from '../../common/utils/index.js';
import { COMMAND_DEFAULTS, REVIEW_COMMAND_HINTS_DEFAULTS, AI_PROVIDERS, TEST_PATTERNS, EXCLUDED_DIRECTORIES, DOCUSIGN_SPECIFIC_ISSUES, SUPPORTED_SALESFORCE_EXTENSIONS, SUPPORTED_FILE_EXTENSIONS, } from '../../common/utils/constants.js';
import { MESSAGES } from '../../messages/index.js';
const currentFilename = fileURLToPath(import.meta.url);
const currentDirname = path.dirname(currentFilename);
/**
* @file index.ts
*
* AI-powered Pull Request review command that performs comprehensive static code analysis
* using a hybrid approach combining Salesforce Code Analyzer (SCA) for Salesforce-specific files
* and PMD for general file types, then leverages AI providers to generate intelligent code reviews.
*
* This command:
* 1. Analyzes code diffs between two Git references
* 2. Runs static analysis tools (SCA + PMD) to detect code quality issues
* 3. Uses AI providers (OpenAI, Anthropic) to generate contextual review comments
* 4. Identifies Docusign-specific patterns and best practices
* 5. Posts comprehensive review comments to Pull Requests or outputs locally
*
* @author SF COE Team
* @since 0.1.0
*/
export default class AIPRReview extends Command {
static summary = MESSAGES['pr-review'].summary;
static description = MESSAGES['pr-review'].description;
static examples = MESSAGES['pr-review'].examples;
/**
* Command line flags configuration for the AIPRReview command.
* Defines all available command line options including their types, defaults, and validation
*/
static flags = {
'repo-dir': Flags.directory({
summary: MESSAGES['pr-review'].flags.repoDir,
char: 'r',
default: process.cwd(),
exists: true,
required: false,
}),
from: Flags.string({
summary: MESSAGES['pr-review'].flags.from,
char: 'f',
default: COMMAND_DEFAULTS.GIT_FROM_REF,
required: false,
}),
to: Flags.string({
summary: MESSAGES['pr-review'].flags.to,
char: 't',
default: COMMAND_DEFAULTS.GIT_TO_REF,
required: false,
}),
'pull-request-id': Flags.string({
summary: MESSAGES['pr-review'].flags.pullRequestId,
char: 'p',
required: false,
}),
'sf-config-file': Flags.file({
summary: MESSAGES['pr-review'].flags.sfConfigFile,
char: 's',
exists: true,
required: false,
}),
'pmd-config-file': Flags.file({
summary: MESSAGES['pr-review'].flags.pmdConfigFile,
char: 'x',
exists: true,
required: false,
}),
'ai-provider': Flags.option({
summary: MESSAGES['pr-review'].flags.aiProvider,
char: 'a',
required: true,
multiple: false,
default: COMMAND_DEFAULTS.AI_PROVIDER,
options: [
AI_PROVIDERS.ANTHROPIC,
AI_PROVIDERS.AZURE_OPENAI,
AI_PROVIDERS.OPENAI,
],
})(),
'ai-token': Flags.string({
summary: MESSAGES['pr-review'].flags.aiToken,
char: 'n',
required: true,
}),
'ai-model': Flags.string({
summary: MESSAGES['pr-review'].flags.aiModel,
char: 'm',
default: COMMAND_DEFAULTS.AI_MODEL,
required: true,
}),
'ai-api-endpoint': Flags.string({
summary: MESSAGES['pr-review'].flags.aiApiEndpoint,
char: 'e',
default: COMMAND_DEFAULTS.AI_API_ENDPOINT,
required: false,
}),
'ai-api-version': Flags.string({
summary: MESSAGES['pr-review'].flags.aiApiVersion,
char: 'v',
default: COMMAND_DEFAULTS.AI_API_VERSION,
required: false,
}),
'git-provider': Flags.option({
summary: MESSAGES['pr-review'].flags.gitProvider,
char: 'g',
required: false,
multiple: false,
options: [COMMAND_DEFAULTS.GIT_PROVIDER],
})(),
'git-token': Flags.string({
summary: MESSAGES['pr-review'].flags.gitToken,
char: 'k',
required: false,
}),
'git-owner': Flags.string({
summary: MESSAGES['pr-review'].flags.gitOwner,
char: 'w',
required: false,
}),
'git-repo': Flags.string({
summary: MESSAGES['pr-review'].flags.gitRepo,
char: 'o',
required: false,
}),
};
/**
* Initialize the AIProvider and GitProvider based on command flags
*
* @param flags Command line flags containing provider configuration
* @returns Object containing initialized AIProvider and optional GitProvider instances
* @throws Error if AI provider initialization fails
* @example
* ```typescript
* const { aiProvider, gitProvider } = AIPRReview.initializeProviders(flags);
* ```
*/
static initializeProviders(flags) {
const aiProvider = AIProviderFactory.getInstance(flags['ai-provider'], flags['ai-token'], flags['ai-model'], flags['ai-api-endpoint'], flags['ai-api-version']);
let gitProvider;
if (flags['git-provider'] &&
flags['git-token'] &&
flags['git-owner'] &&
flags['git-repo']) {
gitProvider = GitProviderFactory.getInstance(flags['git-provider'], flags['git-token'], flags['git-owner'], flags['git-repo']);
}
return { aiProvider, gitProvider };
}
/**
* Get the commit SHA for PR comments with fallback mechanism
*
* @param repoDir Path to the Git repository directory
* @param flags Command line flags containing the 'to' commit reference
* @returns Promise resolving to the commit SHA for the PR commit
* @throws Error if unable to determine any valid commit SHA
* @example
* ```typescript
* const commitSha = await AIPRReview.getCommitSha('/path/to/repo', flags);
* ```
*/
static async getCommitSha(repoDir, flags) {
let commitSHA = '';
try {
commitSHA = await GitHelper.verifyCommitSha(repoDir, flags.to);
}
catch {
Logger.warn(`Failed to get commit SHA from ${flags.to}, trying to get latest commit from HEAD.`);
try {
commitSHA = await GitHelper.verifyCommitSha(repoDir, COMMAND_DEFAULTS.GIT_TO_REF);
}
catch {
Logger.error('Failed to get any valid commit SHA');
}
}
return commitSHA;
}
/**
* Create a file map from SARIF data by parsing runs and results
*
* @param sarifData Parsed SARIF data from PMD or SCA analysis
* @returns Map of normalized file paths to review hints with rule violations
* @example
* ```typescript
* const fileMap = AIPRReview.createFileMap(sarifData);
* console.log(`Found issues in ${fileMap.size} files`);
* ```
*/
static createFileMap(sarifData) {
const fileMap = new Map();
sarifData.runs.forEach((run) => {
run.results?.forEach((result) => {
if (!result.ruleId || !result.message?.text || !result.locations)
return;
result.locations.forEach((location) => {
if (!location.physicalLocation?.artifactLocation?.uri ||
!location.physicalLocation?.region)
return;
const filePathBase = location.physicalLocation.artifactLocation.uriBaseId ?? '';
let filePath = location.physicalLocation.artifactLocation.uri;
// Handle file:// URI format
if (filePath.startsWith('file:')) {
try {
// Convert URI to local path
const url = new URL(filePath);
filePath = url.pathname;
}
catch {
// If URL parsing fails, try simple string replacement
filePath = filePath.replace(/^file:\/?\/?/, '');
}
}
// Join with base path if provided
if (filePathBase) {
filePath = path.join(filePathBase, filePath);
}
// Remove any duplicate path segments that might have been created
filePath = path.normalize(filePath);
const region = location.physicalLocation.region;
const hint = {
ruleId: result.ruleId ?? REVIEW_COMMAND_HINTS_DEFAULTS.RULE_ID,
message: result.message.text ?? REVIEW_COMMAND_HINTS_DEFAULTS.MESSAGE,
region,
className: filePath.split('/').pop() ?? filePath, // Use file name as className
};
if (!fileMap.has(filePath)) {
fileMap.set(filePath, [hint]);
}
else {
fileMap.get(filePath).push(hint);
}
});
});
});
return fileMap;
}
/**
* Main command execution method that orchestrates the entire PR review workflow
*
* @returns Promise that resolves when the command execution is complete
* @throws Error if any critical step in the workflow fails
* @example
* ```typescript
* const diffCommand = new AIPRReview();
* await diffCommand.run();
* ```
*/
async run() {
// Obtain process start date/time
const startTime = new Date();
// Parse command line flags
const flags = (await this.parse(AIPRReview)).flags;
// Initialize observability for Datadog logging and metrics
ObservabilityInitializer.initialize(flags);
// Initialise the
const { aiProvider, gitProvider } = AIPRReview.initializeProviders(flags);
// Create the Git repository directory with diff files
const repoDir = flags['repo-dir'];
const diffDir = await GitHelper.createDirectoryWithDiff(repoDir, flags.from, flags.to);
Logger.debug(`Starting hybrid SCA+PMD code analysis workflow on: \n repoDir = ${repoDir} \n diffDir = ${diffDir}`);
// Get changed line ranges for filtering vulnerabilities to only changed lines
const changedLinesRanges = await GitHelper.getChangedLineRanges(repoDir, flags.from, flags.to);
Logger.debug(`Changed line ranges found for ${changedLinesRanges.size} files`, Array.from(changedLinesRanges.entries()));
// Run hybrid scan: SCA for Salesforce files, PMD for others
const combinedFileMap = await this.runHybridAnalysis(diffDir, flags);
Logger.debug(`Combined analysis found issues in ${combinedFileMap.size} files`, JSON.stringify(combinedFileMap));
// Get the commit SHA for PR comments
const commitSha = await AIPRReview.getCommitSha(repoDir, flags);
// If no commit SHA is found, perform completion
if (!commitSha) {
await this.performCompletion(diffDir, startTime);
return;
}
// Process combined results and generate AI-powered review
if (combinedFileMap.size > 0) {
await this.processPmdResults(combinedFileMap, diffDir, aiProvider, flags, commitSha, gitProvider, changedLinesRanges);
}
else {
Logger.debug('No issues found in either SCA or PMD analysis, so will delete existing bot comments if any');
// Handle the case where no violations are found - delete existing comments directly if present
if (flags['pull-request-id'] && gitProvider) {
await gitProvider.deleteBotComment(flags['pull-request-id'], true // General comment
);
}
}
// Perform completion since all operations are completed
await this.performCompletion(diffDir, startTime);
}
/**
* Perform cleanup and completion tasks after the review process
*
* @param diffDir Directory containing the diff files
* @param startTime Start time of the process
*/
async performCompletion(diffDir, startTime) {
// Delete the temporary diff directory if it exists
if (fs.existsSync(diffDir)) {
fs.rmdirSync(diffDir, { recursive: true });
}
// Set process duration runtime metric
const processDuration = (new Date().getTime() - startTime.getTime()) / 1000;
Metrics.gauge('ai_review.duration', processDuration);
Metrics.gauge('ai_review.process_completed_successfully', 1);
Logger.debug(`Process started at ${startTime.toISOString()} completed successfully within ${processDuration} seconds`);
// Close metrics client so that all metrics are flushed to DD
Metrics.close();
}
/**
* Process PMD/SCA results through AI analysis and generate comprehensive review comments
*
* @param fileMap Map of file paths to review hints from static analysis
* @param diffDir Directory containing the diff files to analyze
* @param aiProvider AI provider instance for code review generation
* @param flags Command line flags containing configuration options
* @param commitSha Git commit SHA for PR comment posting
* @param gitProvider Optional git provider for posting PR comments
* @param changedLinesRanges Map of file paths to changed line ranges for filtering
* @returns Promise that resolves when processing is complete
* @throws Error if file reading or AI analysis fails
*/
async processPmdResults(fileMap, diffDir, aiProvider, flags, commitSha, gitProvider, changedLinesRanges) {
Logger.debug(`Processing PMD results for AI review with ${fileMap.size} files`);
const fileContexts = await this.readAndProcessFiles(fileMap, diffDir);
// Create comprehensive code snippet for AI analysis
const comprehensiveCodeSnippet = this.buildCodeSnippet(fileContexts);
// Flatten all hints from file contexts for AI analysis
const allHintsForAI = [];
for (const context of fileContexts) {
allHintsForAI.push(...context.hints);
}
// Get AI Recommendations for the comprehensive code snippet
const aiResponse = await aiProvider.reviewCode(comprehensiveCodeSnippet, allHintsForAI);
Logger.debug(`Received AI Provider response: ${aiResponse.comments.length} comments for AI-assisted review`, {
aiResponseCommentsCount: aiResponse.comments?.length || 0,
aiResponseComments: aiResponse.comments,
});
Metrics.gauge('ai_review.ai_comments_count', aiResponse.comments?.length || 0);
// Build comprehensive PMD review message
const comprehensiveMessage = this.buildReviewComment(aiResponse, fileContexts, changedLinesRanges);
// Post the comprehensive review comment to PR or output locally
await this.postPRComment(comprehensiveMessage, flags, commitSha, gitProvider);
}
/**
* Read and process files from the file map, handling path resolution and duplicate prevention
*
* @param fileMap Map of file paths to review hints from static analysis
* @param diffDir Directory containing the diff files to read
* @returns Promise resolving to object containing file contexts with hints
* @throws Error if file reading operations fail critically
* @example
* ```typescript
* const fileContexts = await this.readAndProcessFiles(fileMap, diffDir);
* ```
*/
async readAndProcessFiles(fileMap, diffDir) {
const fileContexts = [];
const processedFilePaths = new Set();
// Read all files and collect hints
const fileReadPromises = Array.from(fileMap.entries()).map(async ([filePath, hints]) => {
// Normalize the file path to prevent duplicates
const normalizedPath = FileUtils.normalizePath(filePath);
const shortFileName = FileUtils.getFileName(normalizedPath);
// Skip if we've already processed this file
if (processedFilePaths.has(shortFileName)) {
return null;
}
processedFilePaths.add(shortFileName);
return this.tryReadFile(normalizedPath, diffDir, hints);
});
const fileReadResults = await Promise.all(fileReadPromises);
const fileNames = [];
for (const result of fileReadResults) {
if (result) {
fileNames.push(result.file.split('/').pop());
fileContexts.push(result);
}
}
Logger.debug(`Files to process for AI-review: \n ${fileNames.join(',')}`, fileNames);
return fileContexts;
}
/**
* Try to read a file from multiple possible paths with fallback strategies
*
* @param filePath Original file path from static analysis
* @param diffDir Directory containing diff files
* @param hints Review hints associated with this file
* @returns Promise resolving to FileContext object if successful, null if all attempts fail
* @example
* ```typescript
* const fileContext = await this.tryReadFile('src/test.js', diffDir, hints);
* ```
*/
async tryReadFile(filePath, diffDir, hints) {
// Try multiple path resolution strategies
const possiblePaths = [
path.resolve(diffDir, filePath),
path.resolve(filePath),
filePath.startsWith('/') ? filePath : path.resolve(diffDir, filePath),
];
// Remove duplicates
const uniquePaths = [...new Set(possiblePaths)];
const readFileAttempts = uniquePaths.map((fullFilePath) => {
try {
const fileContent = fs.readFileSync(fullFilePath, 'utf-8');
return { file: filePath, content: fileContent, hints, success: true };
}
catch (error) {
Logger.debug(`Failed to read ${fullFilePath}, trying next path.`, error);
return { file: filePath, content: '', hints, success: false };
}
});
const results = await Promise.all(readFileAttempts);
const successfulResult = results.find((result) => result.success);
if (successfulResult) {
return {
file: successfulResult.file,
content: successfulResult.content,
hints: successfulResult.hints,
};
}
Logger.warn(`Failed to read file ${filePath} from any of the attempted paths:`, uniquePaths);
return null;
}
/**
* Separate hints into those that occur on changed lines and those that don't, grouped by file
*
* @param fileContexts Array of file contexts containing file information and hints
* @param changedLinesRanges Map of file paths to changed line ranges
* @returns Object containing changedLinesHintsPerFile and remainingHintsPerFile maps
* @example
* ```typescript
* const { changedLinesHintsPerFile, remainingHintsPerFile } = this.separateHintsByChangedLines(fileContexts, changedLinesRanges);
* ```
*/
separateHintsByChangedLines(fileContexts, changedLinesRanges) {
const changedLinesHintsPerFile = new Map();
const remainingHintsPerFile = new Map();
// If no changed line ranges, all hints are remaining
if (!changedLinesRanges || changedLinesRanges.size === 0) {
for (const { file, hints } of fileContexts) {
const shortFileName = FileUtils.getFileName(file);
if (hints.length > 0) {
remainingHintsPerFile.set(shortFileName, [...hints]);
}
}
return { changedLinesHintsPerFile, remainingHintsPerFile };
}
// Create a map of file names to their contexts for quick lookup
const fileContextMap = new Map();
for (const context of fileContexts) {
const shortFileName = FileUtils.getFileName(context.file);
fileContextMap.set(shortFileName, context);
}
// Process hints from all file contexts and separate them
for (const context of fileContexts) {
const shortFileName = FileUtils.getFileName(context.file);
const filePath = context.file;
const normalizedPath = FileUtils.normalizePath(filePath);
// Try to find changed line ranges using different path formats
let changedRanges;
// Try exact path match first
changedRanges = changedLinesRanges.get(normalizedPath);
// If not found, try relative path variants
if (!changedRanges) {
const fileName = FileUtils.getFileName(normalizedPath);
for (const [changedPath] of changedLinesRanges) {
if (changedPath.endsWith(fileName) ||
changedPath.includes(fileName)) {
changedRanges = changedLinesRanges.get(changedPath);
break;
}
}
}
// Separate hints for this file into changed and remaining
const changedHints = [];
const remainingHints = [];
for (const hint of context.hints) {
// Find the file context for this hint using className
const hintFileName = hint.className || '';
const fileContext = fileContextMap.get(hintFileName);
if (!fileContext || !changedRanges || changedRanges.length === 0) {
// If no file context or no changed ranges, it's a remaining hint
remainingHints.push(hint);
continue;
}
// Check if this hint's line falls within any changed range
const hintStartLine = hint.region.startLine ?? 1;
const hintEndLine = hint.region.endLine ?? hintStartLine;
const isInChangedLines = changedRanges.some((range) => (hintStartLine >= range.start && hintStartLine <= range.end) ||
(hintEndLine >= range.start && hintEndLine <= range.end) ||
(hintStartLine <= range.start && hintEndLine >= range.end));
if (isInChangedLines) {
changedHints.push(hint);
}
else {
remainingHints.push(hint);
}
}
// Store the separated hints for this file
if (changedHints.length > 0) {
changedLinesHintsPerFile.set(shortFileName, changedHints);
}
if (remainingHints.length > 0) {
remainingHintsPerFile.set(shortFileName, remainingHints);
}
}
return { changedLinesHintsPerFile, remainingHintsPerFile };
}
/**
* Separate AI analysis entries into changed lines and remaining entries
*
* @param allTableEntries All AI analysis table entries
* @param changedLinesHints Hints that are on changed lines
* @returns Object containing changedLineEntries and remainingEntries
*/
separateAIAnalysisByChangedLines(allTableEntries, changedLinesHints) {
const changedLinesAIAnalysis = [];
const remainingIssuesAIAnalysis = [];
// Create a set of changed line hint identifiers for quick lookup
const changedLineIdentifiers = new Set(changedLinesHints.map((hint) => `${hint.className}-${hint.region.startLine ?? 1}`));
for (const entry of allTableEntries) {
const entryIdentifier = `${entry.className}-${entry.lineNumber}`;
if (changedLineIdentifiers.has(entryIdentifier)) {
changedLinesAIAnalysis.push(entry);
}
else {
remainingIssuesAIAnalysis.push(entry);
}
}
return { changedLinesAIAnalysis, remainingIssuesAIAnalysis };
}
/**
* Build comprehensive code snippet for AI analysis by concatenating all file contents
*
* @param fileContexts Array of file contexts containing file names and content
* @returns Formatted code snippet string with file separators for AI consumption
* @example
* ```typescript
* const codeSnippet = this.buildCodeSnippet(fileContexts);
* ```
*/
buildCodeSnippet(fileContexts) {
let comprehensiveCodeSnippet = '';
for (const { file, content } of fileContexts) {
const shortFileName = FileUtils.getFileName(file);
comprehensiveCodeSnippet += `\n=== FILE: ${shortFileName} ===\n`;
comprehensiveCodeSnippet += content;
comprehensiveCodeSnippet += '\n\n';
}
return comprehensiveCodeSnippet;
}
/**
* Post PR comment with the review results, either to git provider or local output
*
* @param message The comprehensive review message to post
* @param flags Command line flags containing PR and git configuration
* @param commitSha Git commit SHA for associating the comment with specific commit
* @param gitProvider Optional git provider for posting comments to remote repository
* @returns Promise that resolves when comment posting is complete
* @throws Error if PR comment posting fails when git provider is configured
*/
async postPRComment(message, flags, commitSha, gitProvider) {
const prCommentRequest = {
prNumber: flags['pull-request-id'] ?? '',
message,
suggestedCodeChange: undefined,
sourceFile: '',
startLine: 0,
endLine: 0,
commitId: commitSha,
};
if (flags['pull-request-id'] && gitProvider) {
try {
await gitProvider.upsertPRComment(prCommentRequest);
Metrics.gauge('ai_review.pr_comment_posted_success', 1);
}
catch (error) {
Logger.error('Failed to add comprehensive PR comment:', error);
Metrics.gauge('ai_review.pr_comment_posted_failure', 1);
}
}
else {
Logger.debug(`No PR ID or git provider configured, outputting review locally. Generated review: ${message}`);
}
}
/**
* Build comprehensive PMD review comment including AI analysis and Docusign-specific issues
* Shows both total vulnerabilities and vulnerabilities only on changed lines.
*
* @param aiResponse Response from AI provider containing code review comments
* @param fileContexts Array of file contexts with content and associated hints
* @returns Formatted markdown message for PR comment with issues breakdown and AI analysis table
* @example
* ```typescript
* const message = this.buildReviewComment(aiResponse, fileContexts);
* ```
*/
buildReviewComment(aiResponse, fileContexts, changedLinesRanges) {
// Separate hints into changed lines and remaining hints in one pass
const { changedLinesHintsPerFile, remainingHintsPerFile } = this.separateHintsByChangedLines(fileContexts, changedLinesRanges);
// Get Docusign specific issues per file
const docuSignIssuesPerFile = this.getDocuSignIssues(fileContexts);
// Calculate counts from pre-computed maps
let changedLinesIssueCount = 0;
for (const fileHints of changedLinesHintsPerFile.values()) {
changedLinesIssueCount += fileHints.length;
}
let remainingIssueCount = 0;
for (const fileHints of remainingHintsPerFile.values()) {
remainingIssueCount += fileHints.length;
}
// Calculate total DocuSign issues count
let docuSignIssuesCount = 0;
for (const fileIssues of docuSignIssuesPerFile.values()) {
docuSignIssuesCount += fileIssues.length;
}
// Calculate total hints count from all file contexts
const totalHintsCount = changedLinesIssueCount + remainingIssueCount;
const totalIssues = totalHintsCount + docuSignIssuesCount;
// Build the review message header
let changedLinesIssuesMessage = '';
let docusignIssuesMessage = '';
let remainingIssuesMessage = '';
let message = '';
// Build the summary message with total issues
let summaryMessage = '### 📊 Vulnerability Summary ⬇️ \n' +
`- **Total Vulnerabilities:** ${totalIssues} across ${fileContexts.length} file(s)\n`;
if (changedLinesIssueCount > 0) {
summaryMessage += `- **Vulnerabilities in New/Modified Code:** ${changedLinesIssueCount} (Most critical; review first)\n`;
changedLinesIssuesMessage += `<details><summary>‼️ ${changedLinesIssueCount} Vulnerabilities in New/Modified Code (Fix these first – they're in the code you just changed!)</summary>\n\n`;
}
if (docuSignIssuesCount > 0) {
summaryMessage += `- **Docusign-Specific Vulnerabilities:** ${docuSignIssuesCount} (High priority due to specific requirements)\n`;
docusignIssuesMessage += `<details><summary>🏢 ${docuSignIssuesCount} Docusign-Specific Vulnerabilities (These are crucial for DocuSign compliance – address them next!)</summary>\n\n`;
}
if (remainingIssueCount > 0) {
summaryMessage += `- **Vulnerabilities in Existing unchanged Code:** ${remainingIssueCount} (Address as time permits)\n`;
remainingIssuesMessage += `<details><summary>📄 ${remainingIssueCount} Vulnerabilities in Existing Code (Found in unchanged code; fix these when you have time)</summary>\n\n`;
}
message += summaryMessage += '\n---\n\n';
// Build file analysis content for Changed lines, Docusign issues and remaining issues
let codeAnalysisMessage = '### 🔎 Detailed Vulnerability Breakdown ⬇️\n\n';
const processedFiles = new Set();
const metricsPerType = {
issueViolationsPerType: {},
issueFilesPerType: {},
docusignIssuesPerType: {},
docusignFilesPerType: {},
};
for (const { file, hints } of fileContexts) {
const shortFileName = FileUtils.getFileName(file);
const fileType = FileUtils.getFileExtension(file);
// Skip if we've already processed this file
if (processedFiles.has(shortFileName)) {
Logger.debug(`Skipping duplicate file: ${shortFileName}`);
continue;
}
processedFiles.add(shortFileName);
// Get Changed, DocuSign specific and Remaining issues for this file
const changedHints = changedLinesHintsPerFile.get(shortFileName) ?? [];
const remainingHints = remainingHintsPerFile.get(shortFileName) ?? [];
const fileDocuSignIssues = docuSignIssuesPerFile.get(shortFileName) ?? [];
// Count violations and #files per type from file type
metricsPerType.issueFilesPerType[fileType] =
(metricsPerType.issueFilesPerType[fileType] ?? 0) + 1;
metricsPerType.issueViolationsPerType[fileType] =
(metricsPerType.issueViolationsPerType[fileType] ?? 0) + hints.length;
// Count Docusign violations and #files per type from file type
if (fileDocuSignIssues.length > 0) {
metricsPerType.docusignFilesPerType[fileType] =
(metricsPerType.docusignFilesPerType[fileType] ?? 0) + 1;
metricsPerType.docusignIssuesPerType[fileType] =
(metricsPerType.docusignIssuesPerType[fileType] ?? 0) +
fileDocuSignIssues.length;
}
// Add changed line issues for the file
if (changedHints.length > 0) {
changedLinesIssuesMessage += `#### 🆕 ${shortFileName} (${changedHints.length} issues)\n`;
for (const hint of changedHints) {
changedLinesIssuesMessage += `- **Line ${hint.region.startLine ?? 1}**: [${hint.ruleId}] ${hint.message}\n`;
}
changedLinesIssuesMessage += '\n';
}
// Add DocuSign specific issues for this file
if (fileDocuSignIssues.length > 0) {
docusignIssuesMessage += `#### 🏢 ${shortFileName} (${fileDocuSignIssues.length} issues)\n`;
for (const issue of fileDocuSignIssues) {
docusignIssuesMessage += `- **Line ${issue.lineNumber}**: [${issue.ruleType}] ${issue.issue}\n`;
}
docusignIssuesMessage += '\n';
}
// Add remaining issues (not in changed lines)
if (remainingHints.length > 0) {
remainingIssuesMessage += `#### 📄 ${shortFileName} (${remainingHints.length} issues)\n`;
for (const hint of remainingHints) {
remainingIssuesMessage += `- **Line ${hint.region.startLine ?? 1}**: [${hint.ruleId}] ${hint.message}\n`;
}
}
Logger.debug(`Processed File ${shortFileName} has ${changedHints.length} changed line hints, ${remainingHints.length} remaining hints, and ${fileDocuSignIssues.length} Docusign issues`, {
changedHints,
remainingHints,
docuSignIssues: fileDocuSignIssues,
});
}
// Section 1: Changed Lines Analysis (PRIORITY - shown first)
if (changedLinesIssuesMessage.trim()) {
codeAnalysisMessage += changedLinesIssuesMessage + '</details>';
}
// Section 2: Docusign Specific Issues
if (docusignIssuesMessage.trim()) {
codeAnalysisMessage += docusignIssuesMessage + '</details>';
}
// Section 3: Remaining Issues Analysis
if (remainingIssuesMessage.trim()) {
codeAnalysisMessage += remainingIssuesMessage + '</details>';
}
codeAnalysisMessage += '\n\n---\n\n';
message += codeAnalysisMessage;
// Log the metrics for observability
Logger.debug(`Total issues calculated: ${totalIssues} = SCA (${totalHintsCount}) + DocuSign (${docuSignIssuesCount}). ` +
`Changed line issues: ${changedLinesIssueCount}`, metricsPerType);
Metrics.gauge('ai_review.total_issues', totalIssues);
Metrics.gauge('ai_review.changed_lines_issues', changedLinesIssueCount);
Metrics.gauge('ai_review.docusign_specific_issues', docuSignIssuesCount);
Metrics.gauge('ai_review.remaining_issues', remainingIssueCount);
for (const [metricCategory, metricData] of Object.entries(metricsPerType)) {
for (const [type, count] of Object.entries(metricData)) {
if (!count || count === 0)
continue; // Skip zero counts
// Convert camelCase to snake_case for metric names
const metricName = metricCategory
.replace(/([A-Z])/g, '_$1')
.toLowerCase();
Metrics.gauge(`ai_review.${metricName}.${type}`, count);
}
}
// AI Suggestion Table - Include AI comments AND DocuSign specific issues
const hasAiComments = aiResponse.comments && aiResponse.comments.length > 0;
if (hasAiComments || docuSignIssuesCount > 0) {
// Add Docusign specific entries to AI Suggestion table
const docusignIssuesAIAnalysis = this.addDocuSignAnalysis(fileContexts);
let allTableEntries = [];
// Add AI-generated entries
if (hasAiComments) {
allTableEntries = this.addAIGeneratedAnalysis(aiResponse.comments, fileContexts);
}
// Convert changed lines hints map to array for the method
const changedLinesHintsArray = [];
for (const fileHints of changedLinesHintsPerFile.values()) {
changedLinesHintsArray.push(...fileHints);
}
// Split AI analysis into changed lines and remaining
const { changedLinesAIAnalysis, remainingIssuesAIAnalysis } = this.separateAIAnalysisByChangedLines(allTableEntries, changedLinesHintsArray);
// Build the AI analysis message with sections
let aiAnalysisMessage = '### 💡 AI Recommendations ⬇️\n\n';
// Section 1: AI Suggestion for Changed Lines Issues
aiAnalysisMessage += this.generateAIAnalysisSection('‼️ Vulnerabilities in New/Modified Code (Check this first)', changedLinesAIAnalysis);
// Section 2: AI Suggestion for Docusign Specific Issues
aiAnalysisMessage += this.generateAIAnalysisSection('🏢 Docusign-Specific Vulnerabilities (Address these next)', docusignIssuesAIAnalysis);
// Section 3: AI Suggestion for Other Issues
aiAnalysisMessage += this.generateAIAnalysisSection('📄 Vulnerabilities in Existing Code (Fix when time permits)', remainingIssuesAIAnalysis);
message += aiAnalysisMessage;
}
Logger.debug(`Final AI-review message: \n ${message}`, message);
return message;
}
/**
* Generate a formatted AI analysis table section with header, description, and entries
*
* @param sectionTitle The title for the AI analysis section (e.g., "Changed Files", "Docusign Specific Issues")
* @param entries Array of table entries to display
* @param description Optional custom description for the section
* @returns Formatted markdown string for the AI analysis section
* @example
* ```typescript
* const section = this.generateAIAnalysisSection("Changed Files", changedLineEntries, "issues in your modifications");
* ```
*/
generateAIAnalysisSection(sectionTitle, entries) {
if (entries.length === 0) {
return '';
}
let section = `<details> <summary>${sectionTitle} (${entries.length})</summary>\n\n\n`;
section += REVIEW_COMMAND_HINTS_DEFAULTS.AI_ANALYSIS_TABLE_HEADERS;
// Sort by class name, then by line number
const sortedEntries = [...entries].sort((a, b) => {
if (a.className !== b.className) {
return a.className.localeCompare(b.className);
}
return a.lineNumber - b.lineNumber;
});
// Generate table rows
for (const entry of sortedEntries) {
const lineInfo = entry.lineNumber > 0 ? entry.lineNumber.toString() : 'N/A';
section += `| ${entry.className} | ${lineInfo} | ${entry.ruleType} | ${entry.issue} | ${entry.suggestedFix} |\n`;
}
section += '</details>\n\n';
return section;
}
/**
* Add Docusign-specific entries to the analysis table, including custom pattern checks
* for JavaScript/TypeScript files and ESLint violations from static analysis hints
*
* @param fileContexts Array of file contexts containing file information, content, and hints
* @param tableEntries Array to populate with Docusign-specific table entries
* @returns tableEntries - Array of TableEntry objects with Docusign-specific issues
* @example
* ```typescript
* this.addDocuSignAnalysis(fileContexts, tableEntries);
* ```
*/
addDocuSignAnalysis(fileContexts) {
const tableEntries = [];
for (const { file, content } of fileContexts) {
// Process Apex files
if (FileUtils.isApexFile(file)) {
const shortFileName = FileUtils.getFileName(file);
// Test framework check
if (FileUtils.isTestFile(file, content) &&
DocuSignRuleUtils.hasOldTestFramework(content)) {
tableEntries.push({
className: `${shortFileName}`,
lineNumber: 1,
ruleType: DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.ruleType,
issue: DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.issue,
suggestedFix: DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.suggestedFix,
});
}
// Schema.getGlobalDescribe check
const globalDescribeUsages = DocuSignRuleUtils.hasGlobalDescribeUsage(content);
if (globalDescribeUsages.length > 0) {
for (const issue of globalDescribeUsages) {
const lineNumber = issue.lineNumber ?? 1;
tableEntries.push({
className: `${shortFileName}`,
lineNumber,
ruleType: DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.ruleType,
issue: DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.issue.replace('$line_number', lineNumber.toString()),
suggestedFix: DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.suggestedFix,
});
}
}
}
}
return tableEntries;
}
/**
* Add AI-generated entries to the analysis table, filtering by PMD class name matches
*
* @param comments Array of AI-generated review comments
* @param fileContexts Array of file contexts for class name matching
* @param tableEntries Array to populate with AI-generated table entries
* @returns void - method modifies tableEntries array in place
* @example
* ```typescript
* this.addAIGeneratedAnalysis(aiResponse.comments, fileContexts, tableEntries);
* ```
*/
addAIGeneratedAnalysis(comments, fileContexts) {
const tableEntries = [];
if (!comments) {
return tableEntries;
}
// Build set of PMD classNames (with extension) for exact matching
const pmdClassNames = new Set();
for (const { file, hints } of fileContexts) {
const shortFileName = file.split('/').pop() ?? file;
// Add the full filename with extension
pmdClassNames.add(shortFileName);
// Also add className from hints if available (with extension)
for (const hint of hints) {
if (hint.className) {
pmdClassNames.add(hint.className);
}
}
}
for (const comment of comments) {
// Simple matching: only add AI comment if its className (with extension) matches a PMD className
if (comment.className && pmdClassNames.has(comment.className)) {
const messageText = comment.message ?? '';
const formattedMessage = messageText.replace(/\n/g, ' ');
const suggestionText = comment.suggestion ?? '';
const formattedSuggestion = suggestionText
? suggestionText.replace(/\n/g, ' ')
: REVIEW_COMMAND_HINTS_DEFAULTS.SUGGESTION;
tableEntries.push({
className: comment.className,
lineNumber: comment.startLine ?? 1,
ruleType: REVIEW_COMMAND_HINTS_DEFAULTS.SUGGESTION_RULE_TYPE,
issue: formattedMessage,
suggestedFix: formattedSuggestion,
});
}
}
return tableEntries;
}
/**
* Get Docusign-specific issues for display in Code Quality Review section, grouped by file
*
* @param fileContexts Array of file contexts to analyze for Docusign-specific patterns
* @returns Map of file names to arrays of DocuSign-specific issues
* @example
* ```typescript
* const issuesPerFile = this.getDocuSignIssues(fileContexts);
* console.log(`Found DocuSign issues in ${issuesPerFile.size} files`);
* ```
*/
getDocuSignIssues(fileContexts) {
const issuesPerFile = new Map();
for (const { file, content } of fileContexts) {
const shortFileName = file.split('/').pop() ?? file;
const fileIssues = [];
// Test framework check
if (shortFileName.toLowerCase().includes(TEST_PATTERNS.TEST_FILE_PATTERN) ||
content.includes(TEST_PATTERNS.APEX_TEST_ANNOTATION)) {
const hasNewTestFramework = content.includes(TEST_PATTERNS.SOBJECT_MOCKER) ||
content.includes(TEST_PATTERNS.CLASS_MOCKER) ||
content.includes(TEST_PATTERNS.GENERATE_MOCK_SOBJECT);
if (!hasNewTestFramework) {
fileIssues.push({
lineNumber: 1,
ruleType: DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.ruleType,
issue: DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.issue,
});
}
}
// Schema.getGlobalDescribe check
if (content.includes('Schema.getGlobalDescribe()')) {
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('Schema.getGlobalDescribe()')) {
fileIssues.push({
lineNumber: i + 1,
ruleType: DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.ruleType,
issue: DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.issue.replace('$line_number', (i + 1).toString()),
});
break; // Only show first occurrence
}
}
}
// Add to map only if there are issues for this file
if (fileIssues.length > 0) {
issuesPerFile.set(shortFileName, fileIssues);
}
}
return issuesPerFile;
}
/**
* Run hybrid analysis workflow combining SCA for Salesforce files and PMD for other file types
*
* @param repoDir Path to the repository directory containing files to analyze
* @param flags Command line flags containing configuration file paths
* @returns Promise resolving to a map of file paths to review hints from combined analysis
* @throws Error if analysis workflow fails critically
* @example
* ```typescript
* const fileMap = await this.runHybridAnalysis(repoDir, flags);
* ```
*/
async runHybridAnalysis(repoDir, flags) {
const combinedFileMap = new Map();
try {
// Find all supported files and categorize them
const allFiles = this.findAllSupportedFiles(repoDir);
if (allFiles.length === 0) {
Logger.debug('No supported files found for analysis, so skipping the process');
return combinedFileMap;
}
// Categorize files into Salesforce and non-Salesforce groups
const { salesforceFiles, nonSalesforceFiles } = this.categorizeFiles(allFiles);
Metrics.gauge('ai_review.code_analyzer_file_count', salesforceFiles.length);
Metrics.gauge('ai_review.pmd_analysis_file_count', nonSalesforceFiles.length);
// Run SCA on Salesforce files
if (salesforceFiles.length > 0) {
// Get SCA analysis
const scaResults = await this.runScaAnalysis(repoDir, salesforceFiles, flags['sf-config-file']);
this.mergeFileMaps(combinedFileMap, scaResults);
// Get SCA violations count for metrics
let scaViolationsCount = 0;
for (const hints of scaResults.values()) {
scaViolationsCount += hints.length;
}
if (scaViolationsCount > 0) {
Metrics.gauge('ai_review.sca_violations_count', scaViolationsCount);
}
Logger.debug(`SCA found issues in ${scaResults.size} files with ${scaViolationsCount} violations`);
}
// Run PMD on non-Salesforce files
if (nonSalesforceFiles.length > 0) {
const pmdResults = await this.runPmdAnalysis(repoDir, nonSalesforceFiles, flags['pmd-config-file']);
this.mergeFileMaps(combinedFileMap, pmdResults);
// Get SCA violations count for metrics
let pmdViolationsCount = 0;
for (const hints of pmdResults.values()) {
pmdViolationsCount += hints.length;
}
if (pmdViolationsCount > 0) {
Metrics.gauge('ai_review.pmd_violations_count', pmdViolationsCount);
}
Logger.debug(`PMD found issues in ${pmdResults.size} files with ${pmdViolationsCount} violations`);
}
return combinedFileMap;
}
catch (error) {
Logger.error('Error running hybrid analysis:', error);
return combinedFileMap;
}
}
/**
* Categorize files into Salesforce and non-Salesforce groups based on file extensions
*
* @param files Array of file paths to categorize
* @returns Object containing arrays of salesforceFiles and nonSalesforceFiles
* @example
* ```typescript
* const { salesforceFiles, nonSalesforceFiles } = this.categorizeFiles(allFiles);
* ```
*/
categorizeFiles(files) {
const salesforceExtensions = SUPPORTED_SALESFORCE_EXTENSIONS;
const salesforceFiles = [];
const nonSalesforceFiles = [];
for (const file of files) {
const extension = path.extname(file).toLowerCase();
if (salesforceExtensions.includes(extension)) {
salesforceFiles.push(file);
}
else {
nonSalesforceFiles.push(file);
}
}
return { salesforceFiles, nonSalesforceFiles };
}
/**
* Run Salesforce Code Analyzer (SCA) on Salesforce-specific files
*
* @param repoDir Path to the repository directory
* @param salesforceFiles Array of Salesforce file paths to analyze
* @param sfConfigFile Optional path to SCA configuration file
* @returns Promise resolving to a map of file paths to review hints from SCA analysis
* @throws Error if SCA analysis fails or temp directory operations fail
*/
async runScaAnalysis(repoDir, salesforceFiles, sfConfigFile) {
const fileMap = new Map();
try {
// Create a temporary directory with only Salesforce files for SCA
const tempDir = path.join(repoDir, 'sca-temp');
await fse.ensureDir(tempDir);
// Copy Salesforce files to temp directory maintaining structure
const copyPromises = salesforceFiles.map(async (file) => {
const relativePath = path.relative(repoDir, file);
const destPath = path.join(tempDir, relativePath);
await fse.ensureDir(path.dirname(destPath));
await fse.copy(file, destPath);
});
await Promise.all(copyPromises);
// Use default SCA config file if none provided
const scaConfigFile = sfConfigFile ?? this.getDefaultScaConfigFile();
// Run SCA on the temp directory
const scatProvider = SCATProviderFactory.getInstance(SCATProviderType.SFCodeAnalyzer, tempDir, scaConfigFile);
Logger.debug(`SCA using config file: \n ${scaConfigFile}`);
const sarifData = await scatProvider.run();
// Convert SARIF to ReviewHint format
const scaFileMap = AIPRReview.createFileMap(sarifData);
// Map temp paths back to original paths
for (const [tempFilePath, hints] of scaFileMap) {
const originalPath = tempFilePath.replace(tempDir, repoDir);
fileMap.set(originalPath, hints);
}
// Clean up temp directory
fs.rmdirSync(tempDir, { recursive: true });
}
catch (error) {
Logger.error('Error running SCA analysis:', error);
// Clean up temp directory on error
const tempDir = path.join(repoDir, 'sca-temp');
try {
fs.rmdirSync(tempDir, { recursive: true });
}
catch (cleanupError) {
Logger.error('Error cleaning up temp directory:', cleanupError);
}
}
return fileMap;
}
/**
* Run PMD static analysis on non-Salesforce files using SCATProvider
*
* @param repoDir Path to the repository directory
* @param nonSalesforceFiles Array of non-Salesforce file paths to analyze
* @param pmdConfigFile Optional path to PMD configuration file
* @returns Promise resolving to a map of file paths to review hints from PMD analysis
* @throws Error if PMD analysis fails beyond expected violation exit codes
*/
async runPmdAnalysis(repoDir, nonSalesforceFiles, pmdConfigFile) {
const fileMap = new Map();
try {
if (nonSalesforceFiles.length === 0) {
Logger.debug('No non-Salesforce files to analyze with PMD');
return fileMap;
}
// Use default PMD config file if none provided
const pmdConfig = pmdConfigFile ?? this.getDefaultPmdConfigFile();
// Create PMD provider instance using SCAT factory
const pmdProvider = SCATProviderFactory.getInstance(SCATProviderType.PMDCodeAnalyzer, repoDir, pmdConfig, nonSalesforceFiles);
Logger.debug(`PMD using config file: \n ${pmdConfig}`);
const sarifData = await pmdProvider.run();
// Convert SARIF to ReviewHint format
const pmdFileMap = AIPRReview.createFileMap(sarifData);
// Copy the results to our return map
for (const [filePath, hints] of pmdFileMap) {
fileMap.set(filePath, hints);
}
}
catch (error) {
Logger.error('Error running PMD analysis on non-Salesforce files:', error);
}
return fileMap;
}
/**
* Merge two file maps by combining hints for existing files and adding new file entries
*
* @param target Target map to merge results into (modified in place)
* @param source Source map containing additional results to merge
* @returns void - modifies target map in place
* @example
* ```typescript
* this.mergeFileMaps(combinedFileMap, scaResults);
* ```
*/
mergeFileMaps(target, source) {
for (const [filePath, hints] of source) {
if (target.has(filePath)) {
// Merge hints if file already exists
target.get(filePath).push(...hints);
}
else {
// Add new file entry
target.set(filePath, [...hints]);
}
}
}
/**
* Find all supported files for analysis by scanning directory recursively
*
* @param repoDir Path to repository directory to scan
* @returns Array of file paths that match supported extensions for analysis
* @example
* ```typescript
* const supportedFiles = this.findAllSupportedFiles('/path/to/repo');
* console.log(`Found ${supportedFiles.length} files to analyze`);
* ```
*/
findAllSupportedFiles(repoDir) {
const supportedFiles = [];
// Create an array from all supported extensions dynamically
const allExtensions = Object.values(SUPPORTED_FILE_EXTENSIONS).flatMap((extensions) => extensions.map((ext) => ext.toLowerCase()));
try {
const findFiles = (dir) => {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
// Skip common non-source directories
if (![...EXCLUDED_DIRECTORIES].includes(item.name)) {
findFiles(fullPath);
}
}
else if (allExtensions.some((ext) => item.name.toLowerCase().endsWith(ext))) {
supportedFiles.push(fullPath);
}
}
};
findFiles(repoDir);
}
catch (error) {
Logger.error('Error scanning for supported files:', error);
}
return supportedFiles;
}
/**
* Get the default SCA configuration file path from the built library directory
*
* @returns Path to the default SCA configuration file
* @throws Error if the default configuration file is not found
* @example
* ```typescript
* const configPath = this.getDefaultScaConfigFile();
* ```
*/
getDefaultScaConfigFile() {
// Use the built version path (lib directory)
const builtConfigPath = path.join(currentDirname, COMMAND_DEFAULTS.SCA_RULESET);
if (fs.existsSync(builtConfigPath)) {
return builtConfigPath;
}
// If built config file doesn't exist, throw an error
throw new Error(`Default SCA configuration file not found at: ${builtConfigPath}. ` +
'Please ensure the build process copies configuration files correctly.');
}
/**
* Get the default PMD configuration file path from the built library directory
*
* @returns Path to the default PMD configuration file
* @throws Error if the default configuration file is not found
* @example
* ```typescript
* const configPath = this.getDefaultPmdConfigFile();
* ```
*/
getDefaultPmdConfigFile() {
// Use the built version path (lib directory)
const builtConfigPath = path.join(currentDirname, COMMAND_DEFAULTS.PMD_RULESET);
if (fs.existsSync(builtConfigPath)) {
return builtConfigPath;
}
// If built config file doesn't exist, throw an error
throw new Error(`Default PMD configuration file not found at: ${builtConfigPath}. ` +
'Please ensure the build process copies configuration files correctly.');
}
}