ds-sfcoe-ailabs
Version:
AI-powered code review tool with static analysis integration for comprehensive code quality assessment.
1,012 lines • 62.3 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, 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()));
// Get file statuses to identify new files for DocuSign-specific checks
const fileStatuses = await GitHelper.getFileStatuses(repoDir, flags.from, flags.to);
Logger.debug(`File statuses found for ${fileStatuses.size} files`, Array.from(fileStatuses.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.processSCAResults(combinedFileMap, diffDir, fileStatuses, 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 fileStatuses Map of file paths to their status (modified, added, deleted)
* @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 repoDir Path to the repository directory
* @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 processSCAResults(fileMap, diffDir, fileStatuses, 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 = await this.buildReviewComment(aiResponse, fileContexts, changedLinesRanges, fileStatuses, diffDir);
// 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 and status
* @throws Error if file reading operations fail critically
* @example
* ```typescript
* const fileContexts = await this.readAndProcessFiles(fileMap, diffDir);
* ```
*/
async readAndProcessFiles(fileMap, diffDir) {
const fileContexts = [];
// 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);
return this.tryReadFile(normalizedPath, diffDir, hints, undefined);
});
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
* @param fileStatus Git status of the file (A=added, M=modified, etc.)
* @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, 'A');
* ```
*/
async tryReadFile(filePath, diffDir, hints, fileStatus) {
// Try multiple path resolution strategies and remove duplicates
const uniquePaths = [
...new Set([
path.resolve(diffDir, filePath),
path.resolve(filePath),
filePath.startsWith('/') ? filePath : path.resolve(diffDir, filePath),
]),
];
// Attempt to read the file from each path
const readFileAttempts = uniquePaths.map((fullFilePath) => {
try {
const fileContent = fs.readFileSync(fullFilePath, 'utf-8');
return {
file: fullFilePath,
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,
fileStatus,
};
}
Logger.warn(`Failed to read file ${filePath} from any of the attempted paths:`, uniquePaths);
return null;
}
/**
* Add missing new Apex files to fileContexts for DocuSign analysis
* Reuses existing tryReadFile method for consistent file reading behavior
*
* @param fileContexts Array of file contexts to potentially add new files to (modified in place)
* @param fileStatuses Map of file paths to their status for identifying new files
* @param diffDir Directory containing diff files for fallback file reading
* @returns Promise that resolves when all new Apex files have been processed
* @example
* ```typescript
* await this.addMissingNewApexFiles(fileContexts, fileStatuses, diffDir);
* ```
*/
async addMissingNewApexFiles(fileContexts, fileStatuses, diffDir) {
const newApexFilePromises = [];
// Filter for new Apex files that are not already in fileContexts
for (const [filePath, status] of fileStatuses) {
if (status === 'A' && FileUtils.isApexFile(filePath)) {
// Reuse existing tryReadFile method for consistent behavior
const promise = this.tryReadFile(filePath, diffDir, [], status);
newApexFilePromises.push(promise);
}
}
// If no new Apex files found, exit early
if (newApexFilePromises.length === 0) {
return;
}
// Process all new Apex files concurrently
const results = await Promise.all(newApexFilePromises);
for (const result of results) {
// check if fileContexts already have this file
if (result &&
!fileContexts.some((context) => context.file === result.file)) {
fileContexts.push(result);
}
}
}
/**
* 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
* @param changedLinesRanges Map of file paths to changed line ranges for filtering
* @param fileStatuses Map of file paths to their status (modified, added, etc.)
* @param diffDir Directory containing diff files for reading new Apex files
* @returns Formatted markdown message for PR comment with issues breakdown and AI analysis table
* @example
* ```typescript
* const message = await this.buildReviewComment(aiResponse, fileContexts, changedLinesRanges, fileStatuses, diffDir);
* ```
*/
async buildReviewComment(aiResponse, fileContexts, changedLinesRanges, fileStatuses, diffDir) {
// Separate hints into changed lines and remaining hints in one pass
const { changedLinesHintsPerFile, remainingHintsPerFile } = this.separateHintsByChangedLines(fileContexts, changedLinesRanges);
/* Add all new Apex files to fileContexts if they don't already exist,
ensures DocuSign-specific checks run on all new Apex files, even without vulnerabilities */
await this.addMissingNewApexFiles(fileContexts, fileStatuses, diffDir);
// 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 $files 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`;
}
// 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: {},
};
let totalVulnerableFiles = 0;
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) ?? [];
// Add file to vulnerable files count if it has any issues
if (changedHints.length !== 0 ||
remainingHints.length !== 0 ||
fileDocuSignIssues.length !== 0) {
totalVulnerableFiles++;
}
// 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,
});
}
// Add summary message with total vulnerable files
message +=
summaryMessage.replace('$files', totalVulnerableFiles.toString()) +
'\n---\n\n';
// Build the code analysis message with sections
// 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} with ${totalVulnerableFiles} vulnerable files. `, 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);
Metrics.gauge('ai_review.total_vulnerable_files', totalVulnerableFiles);
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, hints, and status
* @returns tableEntries - Array of TableEntry objects with Docusign-specific issues
* @example
* ```typescript
* this.addDocuSignAnalysis(fileContexts);
* ```
*/
addDocuSignAnalysis(fileContexts) {
const tableEntries = [];
for (const { file, content, fileStatus } of fileContexts) {
// Process Apex files
if (FileUtils.isApexFile(file)) {
const shortFileName = FileUtils.getFileName(file);
// Check if this is a new file (added in this commit)
const isNewFile = fileStatus === 'A';
// Get all DocuSign issues for this file
const docuSignIssues = DocuSignRuleUtils.getDocuSignIssuesForFile(file, content, isNewFile);
// Convert DocuSign issues to table entries
for (const issue of docuSignIssues) {
let suggestedFix = '';
// Map issue types to their suggested fixes
switch (issue.issue) {
case DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.issue:
suggestedFix =
DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.suggestedFix;
break;
case DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.issue:
suggestedFix =
DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.suggestedFix;
break;
case DOCUSIGN_SPECIFIC_ISSUES.TRANSACTION_LOGGER_MISSING.issue:
suggestedFix =
DOCUSIGN_SPECIFIC_ISSUES.TRANSACTION_LOGGER_MISSING
.suggestedFix;
break;
default:
suggestedFix = '';
}
tableEntries.push({
className: shortFileName,
lineNumber: issue.lineNumber,
ruleType: issue.ruleType,
issue: issue.issue,
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
* @param fileStatuses Map of file paths to their git status for identifying new files
* @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, fileStatus } of fileContexts) {
const shortFileName = file.split('/').pop() ?? file;
// Check if this is a new file (added in this commit)
const isNewFile = fileStatus === 'A';
// Get all DocuSign issues for this file using the utility method
const docuSignIssues = DocuSignRuleUtils.getDocuSignIssuesForFile(file, content, isNewFile);
// Convert to the format expected by this method
const fileIssues = docuSignIssues.map((issue) => ({
lineNumber: issue.lineNumber,
ruleType: issue.ruleType,
issue: issue.issue,
}));
// 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