claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
302 lines (260 loc) • 9.77 kB
JavaScript
/**
* File: resolution-prompt.js
* Purpose: Generates AI-friendly resolution prompts when code analysis fails
*
* Key responsibilities:
* - Parse blocking issues from analysis results
* - Load resolution template
* - Replace placeholders with issue data
* - Include affected file contents
* - Write resolution prompt file
*
* Dependencies:
* - fs/promises: For file operations
* - path: For file paths
* - git-operations: For repository info
* - logger: Debug and error logging
*/
import fs from 'fs/promises';
import path from 'path';
import { getRepoName, getCurrentBranch, getRepoRoot } from './git-operations.js';
import logger from './logger.js';
/**
* Custom error for resolution prompt failures
*/
class ResolutionPromptError extends Error {
constructor(message, { templatePath, cause, context } = {}) {
super(message);
this.name = 'ResolutionPromptError';
this.templatePath = templatePath;
this.cause = cause;
this.context = context;
}
}
/**
* Formats blocking issues for resolution prompt
* Why: Structures issues in readable markdown format for AI processing
*
* @param {Array<Object>} blockingIssues - Array of blocking issues
* @returns {string} Formatted markdown
*
* Issue object structure:
* {
* description: string, // Human-readable description
* file: string, // Relative file path
* line: number, // Line number
* method: string, // Method or class name
* severity: string // 'blocker' | 'critical'
* }
*/
const formatBlockingIssues = (blockingIssues) => {
logger.debug(
'resolution-prompt - formatBlockingIssues',
'Formatting blocking issues',
{ issueCount: blockingIssues.length }
);
if (!Array.isArray(blockingIssues) || blockingIssues.length === 0) {
return 'No blocking issues found.';
}
return blockingIssues
.map((issue, index) => {
const issueNum = index + 1;
const severity = (issue.severity || 'unknown').toUpperCase();
return `### Issue #${issueNum} [${severity}]
**Description:** ${issue.description || 'No description'}
**Location:** ${issue.file || 'unknown'}:${issue.line || '?'}
**Method/Class:** ${issue.method || 'unknown'}
`;
})
.join('\n');
};
/**
* Gets unique file paths from blocking issues
* Why: Need to include full content of affected files
*
* @param {Array<Object>} blockingIssues - Array of blocking issues
* @returns {Array<string>} Unique file paths
*/
const getAffectedFiles = (blockingIssues) => {
if (!Array.isArray(blockingIssues)) {
return [];
}
// Extract unique file paths
const filePaths = blockingIssues
.map(issue => issue.file)
.filter(file => file && typeof file === 'string');
const uniqueFiles = [...new Set(filePaths)];
logger.debug(
'resolution-prompt - getAffectedFiles',
'Extracted affected files',
{ totalIssues: blockingIssues.length, uniqueFiles: uniqueFiles.length }
);
return uniqueFiles;
};
/**
* Reads and formats affected file contents
* Why: AI needs full file context to suggest fixes
* Why absolute path: Ensures files are found regardless of cwd (cross-platform)
*
* @param {Array<string>} filePaths - Array of file paths (relative to repo root)
* @returns {Promise<string>} Formatted file contents in markdown
*/
const formatFileContents = async (filePaths) => {
logger.debug(
'resolution-prompt - formatFileContents',
'Reading file contents',
{ fileCount: filePaths.length }
);
// Why: Use repo root for absolute paths (works on Windows/PowerShell/Git Bash)
const repoRoot = getRepoRoot();
const fileContents = await Promise.allSettled(
filePaths.map(async (filePath) => {
try {
const absolutePath = path.join(repoRoot, filePath);
const content = await fs.readFile(absolutePath, 'utf8');
return `### File: ${filePath}
\`\`\`
${content}
\`\`\`
`;
} catch (error) {
logger.error(
'resolution-prompt - formatFileContents',
`Failed to read file: ${filePath}`,
error
);
return `### File: ${filePath}
**Error:** Could not read file
`;
}
})
);
const formatted = fileContents
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
.join('\n');
logger.debug(
'resolution-prompt - formatFileContents',
'File contents formatted',
{ successfulReads: fileContents.filter(r => r.status === 'fulfilled').length }
);
return formatted;
};
/**
* Generates resolution prompt file from analysis results
* Why: Creates AI-friendly prompt that can be copied to Claude for automatic fixes
* Why absolute path: Ensures files are found/created regardless of cwd (cross-platform)
*
* @param {Object} analysisResult - Analysis result with blocking issues
* @param {Array<Object>} analysisResult.blockingIssues - Array of blocking issues
* @param {Object} options - Generation options
* @param {string} options.outputPath - Output file path (default: 'claude_resolution_prompt.md')
* @param {string} options.templatePath - Template path (default: '.claude/prompts/CLAUDE_RESOLUTION_PROMPT.md')
* @param {number} options.fileCount - Number of files analyzed
* @returns {Promise<string>} Path to generated resolution prompt
* @throws {ResolutionPromptError} If generation fails
*/
const generateResolutionPrompt = async (
analysisResult,
{
outputPath = null,
templatePath = '.claude/prompts/CLAUDE_RESOLUTION_PROMPT.md',
fileCount = 0
} = {}
) => {
// Why: Use repo root for absolute paths (works on Windows/PowerShell/Git Bash)
const repoRoot = getRepoRoot();
// Load config to get default output path
const { getConfig } = await import('../config.js');
const config = await getConfig();
const finalOutputPath = outputPath || config.output.resolutionFile;
const absoluteOutputPath = path.join(repoRoot, finalOutputPath);
const absoluteTemplatePath = path.join(repoRoot, templatePath);
logger.debug(
'resolution-prompt - generateResolutionPrompt',
'Generating resolution prompt',
{
repoRoot,
outputPath: absoluteOutputPath,
templatePath: absoluteTemplatePath,
blockingIssuesCount: analysisResult.blockingIssues?.length || 0
}
);
try {
// Load template
const template = await fs.readFile(absoluteTemplatePath, 'utf8');
// Get repository context
const repoName = getRepoName();
const branchName = getCurrentBranch();
const commitSha = 'pending';
// Format blocking issues
const issuesFormatted = formatBlockingIssues(analysisResult.blockingIssues || []);
// Get and format affected files
const affectedFiles = getAffectedFiles(analysisResult.blockingIssues || []);
const fileContentsFormatted = await formatFileContents(affectedFiles);
// Replace placeholders
// Why: Use simple string replacement instead of complex regex for clarity
let prompt = template
.replace(/{{REPO_NAME}}/g, repoName)
.replace(/{{BRANCH_NAME}}/g, branchName)
.replace(/{{COMMIT_SHA}}/g, commitSha)
.replace(/{{FILE_COUNT}}/g, String(fileCount))
.replace(/{{BLOCKING_ISSUES}}/g, issuesFormatted)
.replace(/{{FILE_CONTENTS}}/g, fileContentsFormatted);
// Ensure output directory exists
const outputDir = path.dirname(absoluteOutputPath);
await fs.mkdir(outputDir, { recursive: true });
// Write resolution prompt file
await fs.writeFile(absoluteOutputPath, prompt, 'utf8');
logger.debug(
'resolution-prompt - generateResolutionPrompt',
'Resolution prompt generated successfully',
{ outputPath: absoluteOutputPath, promptLength: prompt.length }
);
return absoluteOutputPath;
} catch (error) {
logger.error(
'resolution-prompt - generateResolutionPrompt',
'Failed to generate resolution prompt',
error
);
if (error.code === 'ENOENT' && error.path === absoluteTemplatePath) {
throw new ResolutionPromptError('Resolution template not found', {
templatePath: absoluteTemplatePath,
cause: error
});
}
throw new ResolutionPromptError('Failed to generate resolution prompt', {
templatePath: absoluteTemplatePath,
cause: error
});
}
};
/**
* Checks if resolution prompt should be generated
* Why: Only generate when there are actual blocking issues
*
* @param {Object} analysisResult - Analysis result
* @returns {boolean} True if should generate prompt
*/
const shouldGeneratePrompt = (analysisResult) => {
const hasBlockingIssues = Array.isArray(analysisResult.blockingIssues) &&
analysisResult.blockingIssues.length > 0;
logger.debug(
'resolution-prompt - shouldGeneratePrompt',
'Checking if resolution prompt needed',
{
hasBlockingIssues,
blockingIssuesCount: analysisResult.blockingIssues?.length || 0
}
);
return hasBlockingIssues;
};
export {
ResolutionPromptError,
formatBlockingIssues,
getAffectedFiles,
formatFileContents,
generateResolutionPrompt,
shouldGeneratePrompt
};