UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

301 lines (262 loc) 9.4 kB
/** * File: prompt-builder.js * Purpose: Builds analysis prompts from templates and file changes * * Key responsibilities: * - Load prompt templates * - Replace template placeholders * - Build structured prompts for code analysis * - Format file diffs for Claude * * Dependencies: * - fs/promises: For reading template files * - path: For template file paths * - logger: Debug and error logging */ import fs from 'fs/promises'; import path from 'path'; import logger from './logger.js'; import { getRepoRoot } from './git-operations.js'; /** * Custom error for prompt builder failures */ class PromptBuilderError extends Error { constructor(message, { templatePath, cause, context } = {}) { super(message); this.name = 'PromptBuilderError'; this.templatePath = templatePath; this.cause = cause; this.context = context; } } /** * Loads a template file * Why: Templates are stored in .claude/ directory for customization * Why absolute path: Ensures templates are found regardless of cwd (cross-platform) * * @param {string} templateName - Name of template file * @param {string} baseDir - Base directory (default: .claude/prompts, fallback to templates) * @returns {Promise<string>} Template content * @throws {PromptBuilderError} If template not found */ const loadTemplate = async (templateName, baseDir = '.claude/prompts') => { // Why: Use repo root for absolute path (works on Windows/PowerShell/Git Bash) const repoRoot = getRepoRoot(); let templatePath = path.join(repoRoot, baseDir, templateName); // Try .claude first, fallback to templates/ if not found try { await fs.access(templatePath); } catch { // Try templates/ directory as fallback templatePath = path.join(repoRoot, 'templates', templateName); } logger.debug( 'prompt-builder - loadTemplate', 'Loading template', { templateName, repoRoot, templatePath } ); try { const content = await fs.readFile(templatePath, 'utf8'); logger.debug( 'prompt-builder - loadTemplate', 'Template loaded successfully', { templatePath, contentLength: content.length } ); return content; } catch (error) { logger.error( 'prompt-builder - loadTemplate', `Template not found: ${templatePath}`, error ); throw new PromptBuilderError('Template file not found', { templatePath, cause: error }); } }; /** * Replaces placeholders in template * Why: Templates use {{PLACEHOLDER}} syntax for dynamic values * * @param {string} template - Template string with placeholders * @param {Object} variables - Object with placeholder values * @returns {string} Template with replaced values * * Example: * replaceTemplate('Repo: {{REPO_NAME}}', { REPO_NAME: 'my-repo' }) * => 'Repo: my-repo' */ const replaceTemplate = (template, variables) => { logger.debug( 'prompt-builder - replaceTemplate', 'Replacing template placeholders', { variableCount: Object.keys(variables).length } ); return Object.entries(variables).reduce((result, [key, value]) => { // Why: Escape special regex characters to prevent regex injection const escapedValue = String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return result.replace(new RegExp(`{{${key}}}`, 'g'), value); }, template); }; /** * Loads a prompt template and replaces variables * Why: High-level interface that combines loading and variable replacement * * @param {string} templateName - Name of template file * @param {Object} variables - Variables to replace in template * @param {string} baseDir - Base directory (default: .claude/prompts) * @returns {Promise<string>} Prompt with replaced variables * @throws {PromptBuilderError} If template not found * * Example: * const prompt = await loadPrompt('COMMIT_MESSAGE.md', { * FILE_LIST: 'file1.js\nfile2.js', * FILE_COUNT: 2, * INSERTIONS: 10, * DELETIONS: 5 * }); */ const loadPrompt = async (templateName, variables = {}, baseDir = '.claude/prompts') => { logger.debug( 'prompt-builder - loadPrompt', 'Loading prompt with variables', { templateName, variableCount: Object.keys(variables).length } ); try { // Load template const template = await loadTemplate(templateName, baseDir); // Replace variables const prompt = replaceTemplate(template, variables); logger.debug( 'prompt-builder - loadPrompt', 'Prompt loaded successfully', { templateName, promptLength: prompt.length } ); return prompt; } catch (error) { logger.error( 'prompt-builder - loadPrompt', `Failed to load prompt: ${templateName}`, error ); throw error; } }; /** * Formats file information for prompt * Why: Structures file data in readable format for Claude * * @param {Object} fileData - File data object * @param {string} fileData.path - File path * @param {string} fileData.diff - File diff * @param {string} fileData.content - Full content (for new files) * @param {boolean} fileData.isNew - Whether file is newly added * @returns {string} Formatted file section */ const formatFileSection = ({ path, diff, content, isNew }) => { logger.debug( 'prompt-builder - formatFileSection', 'Formatting file section', { path, isNew, hasDiff: !!diff, hasContent: !!content } ); let section = `\n--- Archivo: ${path} ---\n`; if (diff) { section += `\nDiff:\n${diff}\n`; } if (isNew && content) { section += `\nComplete content (new file):\n${content}\n`; } return section; }; /** * Builds analysis prompt for code review * Why: Combines template, guidelines, and file changes into complete prompt * * @param {Object} options - Build options * @param {string} options.templateName - Prompt template filename * @param {string} options.guidelinesName - Guidelines filename * @param {Array<Object>} options.files - Array of file data objects * @param {Object} options.metadata - Additional metadata (repo name, branch, etc.) * @param {Object} options.subagentConfig - Subagent configuration (optional) * @returns {Promise<string>} Complete analysis prompt * * File data object structure: * { * path: string, // File path * diff: string, // Git diff * content: string, // Full content (for new files) * isNew: boolean // Whether file is newly added * } */ const buildAnalysisPrompt = async ({ templateName = 'CLAUDE_ANALYSIS_PROMPT.md', guidelinesName = 'CLAUDE_PRE_COMMIT.md', files = [], metadata = {}, subagentConfig = null, baseDir = '.claude/prompts' } = {}) => { logger.debug( 'prompt-builder - buildAnalysisPrompt', 'Building analysis prompt', { templateName, guidelinesName, fileCount: files.length, subagentsEnabled: subagentConfig?.enabled, baseDir } ); try { // Load template and guidelines const [template, guidelines] = await Promise.all([ loadTemplate(templateName, baseDir), loadTemplate(guidelinesName, baseDir) ]); // Start with template let prompt = template; // Add subagent instruction if enabled and 3+ files if (subagentConfig?.enabled && files.length >= 3) { try { const subagentInstruction = await loadTemplate('SUBAGENT_INSTRUCTION.md', baseDir); const subagentVariables = { BATCH_SIZE: subagentConfig.batchSize || 3, MODEL: subagentConfig.model || 'haiku' }; prompt += '\n\n' + replaceTemplate(subagentInstruction, subagentVariables) + '\n'; logger.info(`🚀 Batch optimization enabled: ${files.length} files, ${subagentVariables.BATCH_SIZE} per batch, ${subagentVariables.MODEL} model`); } catch (error) { logger.warning('Subagent instruction template not found, proceeding without parallel analysis'); } } // Add guidelines section prompt += '\n\n=== EVALUATION GUIDELINES ===\n'; prompt += guidelines; // Add changes section prompt += '\n\n=== CHANGES TO REVIEW ===\n'; // Add each file files.forEach(fileData => { prompt += formatFileSection(fileData); }); // Replace any metadata placeholders if (Object.keys(metadata).length > 0) { prompt = replaceTemplate(prompt, metadata); } logger.debug( 'prompt-builder - buildAnalysisPrompt', 'Prompt built successfully', { promptLength: prompt.length, fileCount: files.length } ); return prompt; } catch (error) { logger.error( 'prompt-builder - buildAnalysisPrompt', 'Failed to build analysis prompt', error ); throw error; } }; export { PromptBuilderError, loadTemplate, replaceTemplate, loadPrompt, formatFileSection, buildAnalysisPrompt };