claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
301 lines (262 loc) • 9.4 kB
JavaScript
/**
* 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
};