UNPKG

@elsikora/commitizen-plugin-commitlint-ai

Version:
324 lines (320 loc) 16.1 kB
'use strict'; var Anthropic = require('@anthropic-ai/sdk'); var numeric_constant = require('../../domain/constant/numeric.constant.js'); var commitMessage_entity = require('../../domain/entity/commit-message.entity.js'); var anthropicModel_enum = require('../../domain/enum/anthropic-model.enum.js'); var commitBody_valueObject = require('../../domain/value-object/commit-body.value-object.js'); var commitHeader_valueObject = require('../../domain/value-object/commit-header.value-object.js'); /** * Anthropic implementation of the LLM service */ class AnthropicLlmService { /** * Generate a commit message using Anthropic Claude * @param {ILlmPromptContext} context - The context for generating the commit message * @param {LLMConfiguration} configuration - The LLM configuration * @returns {Promise<CommitMessage>} Promise resolving to the generated commit message */ async generateCommitMessage(context, configuration) { const anthropic = new Anthropic({ apiKey: configuration.getApiKey().getValue(), }); const prompt = this.buildPrompt(context); const response = await anthropic.messages.create({ max_tokens: numeric_constant.OPENAI_MAX_TOKENS, messages: [ { content: prompt, role: "user", }, ], model: configuration.getModel() ?? anthropicModel_enum.EAnthropicModel.CLAUDE_SONNET_4, temperature: numeric_constant.OPENAI_TEMPERATURE, }); const content = response.content[0]; if (!content || content.type !== "text") { throw new Error("No response from Anthropic"); } // Type guard ensures content is TextBlock here return this.parseCommitMessage(content.text); } /** * Check if the service supports the given configuration * @param {LLMConfiguration} configuration - The LLM configuration to check * @returns {boolean} True if the service supports the configuration */ supports(configuration) { return configuration.getProvider() === "anthropic"; } /** * Build the prompt for Anthropic * @param {ILlmPromptContext} context - The prompt context * @returns {string} The prompt */ buildPrompt(context) { let prompt = ""; // Check if this is a fix operation const isFixing = !!(context.rules && typeof context.rules === "object" && !Array.isArray(context.rules) && context.rules.validationErrors && context.rules.previousAttempt && context.diff === undefined); if (isFixing) { prompt = "Fix the following commit message to comply with validation rules. Maintain the original meaning and content while fixing only the format issues.\n\n"; } else { prompt = "Generate a conventional commit message based on the following context and rules.\n\n"; } // Add validation error context if present if (context.rules?.validationErrors && Array.isArray(context.rules.validationErrors)) { prompt += "IMPORTANT: The previous commit message had validation errors that must be fixed:\n"; for (const error of context.rules.validationErrors) { prompt += `- ${error}\n`; } prompt += "\nMake sure the new commit message fixes all these errors.\n\n"; } // Include previous attempt if (context.rules?.previousAttempt && typeof context.rules.previousAttempt === "string") { if (isFixing) { prompt += `Commit message to fix:\n${context.rules.previousAttempt}\n\n`; } else { prompt += `Previous attempt (with errors):\n${context.rules.previousAttempt}\n\n`; } } // Add all commitlint rules if (context.rules && typeof context.rules === "object" && !Array.isArray(context.rules)) { const formattedRules = this.formatCommitlintRules(context.rules); if (formattedRules) { prompt += "Commit message rules:\n" + formattedRules + "\n\n"; } } if (context.typeEnum && context.typeEnum.length > 0) { prompt += `Available commit types: ${context.typeEnum.join(", ")}\n`; } if (context.typeDescriptions) { prompt += "\nType descriptions:\n"; for (const [type, desc] of Object.entries(context.typeDescriptions)) { const emoji = desc.emoji ? ` ${desc.emoji}` : ""; prompt += `- ${type}: ${desc.description}${emoji}\n`; } } if (context.subject.maxLength) { prompt += `\nSubject must be at most ${context.subject.maxLength} characters.`; } if (context.subject.minLength) { prompt += `\nSubject must be at least ${context.subject.minLength} characters.`; } // Extract body/footer line length rules let bodyMaxLength; let footerMaxLength; if (context.rules && typeof context.rules === "object" && !Array.isArray(context.rules)) { // Extract body-max-line-length const bodyRule = context.rules["body-max-line-length"]; if (Array.isArray(bodyRule) && bodyRule.length >= numeric_constant.RULE_CONFIG_LENGTH && bodyRule[0] > numeric_constant.VALIDATION_LEVEL_DISABLED && typeof bodyRule[numeric_constant.RULE_VALUE_INDEX] === "number") { bodyMaxLength = bodyRule[numeric_constant.RULE_VALUE_INDEX]; } // Extract footer-max-line-length const footerRule = context.rules["footer-max-line-length"]; if (Array.isArray(footerRule) && footerRule.length >= numeric_constant.RULE_CONFIG_LENGTH && footerRule[0] > numeric_constant.VALIDATION_LEVEL_DISABLED && typeof footerRule[numeric_constant.RULE_VALUE_INDEX] === "number") { footerMaxLength = footerRule[numeric_constant.RULE_VALUE_INDEX]; } } // Add body formatting rules if line length rules exist if (bodyMaxLength || footerMaxLength) { prompt += "\n\nIMPORTANT: Body formatting rules:"; prompt += "\n- The 'body' field in the JSON corresponds to the commit message body/footer"; if (bodyMaxLength) { prompt += `\n- Each line in the body must be wrapped to not exceed ${bodyMaxLength} characters`; } if (footerMaxLength) { prompt += `\n- Footer lines must be wrapped to not exceed ${footerMaxLength} characters`; } prompt += "\n- The 'breaking' field also follows the same line length rules"; prompt += "\n- Use line breaks (\\n) to wrap long lines"; prompt += "\n- Empty lines between paragraphs are allowed"; } // Only include changes if we're generating (not fixing) if (!isFixing) { prompt += "\n\nChanges:\n"; if (context.diff) { prompt += `Diff:\n${context.diff}\n\n`; } if (context.files) { prompt += `Files changed:\n${context.files}\n\n`; } } if (context.rules && typeof context.rules === "object" && !Array.isArray(context.rules) && context.rules.instructions && typeof context.rules.instructions === "string") { prompt += `${context.rules.instructions}\n\n`; } prompt += "\n\nGenerate a commit message in the following JSON format:\n"; prompt += '{\n "type": "commit type",\n "scope": "optional scope",\n "subject": "commit subject",\n "body": "optional body",\n "breaking": "optional breaking change description"\n}'; prompt += "\n\nRespond ONLY with the JSON object, no additional text, no markdown code blocks, just the raw JSON."; prompt += "\n\nIMPORTANT: Follow ALL the rules listed above. The commit message MUST pass validation."; return prompt; } /** * Format commitlint rules into human-readable instructions * @param {Record<string, unknown>} rules - The commitlint rules object * @returns {string} Formatted rules as string */ formatCommitlintRules(rules) { const formattedRules = []; for (const [ruleName, ruleConfig] of Object.entries(rules)) { if (!Array.isArray(ruleConfig) || ruleConfig.length < numeric_constant.MIN_RULE_LENGTH) continue; const [level, condition, value] = ruleConfig; if (level === numeric_constant.VALIDATION_LEVEL_DISABLED) continue; // Skip disabled rules const isError = level === numeric_constant.VALIDATION_LEVEL_ERROR; const prefix = isError ? "MUST" : "SHOULD"; const conditionString = String(condition); switch (ruleName) { case "body-max-line-length": { if (typeof value === "number") { formattedRules.push(`Body lines ${prefix} be at most ${value} characters (wrap long lines with line breaks)`); } break; } case "footer-max-line-length": { if (typeof value === "number") { formattedRules.push(`Footer lines ${prefix} be at most ${value} characters (Note: the 'body' field is treated as footer, wrap long lines)`); } break; } case "header-max-length": { if (typeof value === "number") { formattedRules.push(`Header (type(scope): subject) ${prefix} be at most ${value} characters`); } break; } case "scope-case": { if (Array.isArray(value)) { formattedRules.push(`Scope ${prefix} be in ${value.join(" or ")} case`); } break; } case "scope-enum": { if (conditionString === "always" && Array.isArray(value)) { formattedRules.push(`Scope ${prefix} be one of: ${value.join(", ")}`); } break; } case "subject-case": { if (Array.isArray(value)) { formattedRules.push(`Subject ${prefix} be in ${value.join(" or ")} case`); } break; } case "subject-empty": { formattedRules.push(`Subject ${prefix} ${conditionString === "never" ? "not be empty" : "be empty"}`); break; } case "subject-full-stop": { formattedRules.push(`Subject ${prefix} ${conditionString === "never" ? "not end with a period" : "end with a period"}`); break; } case "subject-max-length": { if (typeof value === "number") { formattedRules.push(`Subject ${prefix} be at most ${value} characters`); } break; } case "subject-min-length": { if (typeof value === "number") { formattedRules.push(`Subject ${prefix} be at least ${value} characters`); } break; } case "type-case": { if (Array.isArray(value)) { formattedRules.push(`Type ${prefix} be in ${value.join(" or ")} case`); } break; } case "type-enum": { if (conditionString === "always" && Array.isArray(value)) { formattedRules.push(`Type ${prefix} be one of: ${value.join(", ")}`); } break; } default: { // Handle other rules generically if (condition && value !== undefined) { formattedRules.push(`${ruleName}: ${conditionString} ${JSON.stringify(value)}`); } } } } return formattedRules.join("\n"); } /** * Parse the commit message from the LLM response * @param {string} content - The response content * @returns {CommitMessage} The parsed commit message */ parseCommitMessage(content) { try { // Clean up the content - remove markdown code blocks if present let cleanContent = content.trim(); // Remove markdown code blocks - fixed regex cleanContent = cleanContent.replace(/^```(?:json)?\s*/i, "").replace(/```$/m, ""); // Try to extract JSON if it's wrapped in other text - use safer approach const firstBrace = cleanContent.indexOf("{"); const lastBrace = cleanContent.lastIndexOf("}"); if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { cleanContent = cleanContent.slice(firstBrace, lastBrace + 1); } const parsed = JSON.parse(cleanContent); // Validate required fields if (!parsed.type || !parsed.subject) { throw new Error("Missing required fields: type and subject"); } const header = new commitHeader_valueObject.CommitHeader(parsed.type, parsed.subject, parsed.scope); // Strip "BREAKING CHANGE:" prefix if included in the breaking field let breakingChange = parsed.breaking; if (breakingChange?.startsWith("BREAKING CHANGE:")) { breakingChange = breakingChange.slice("BREAKING CHANGE:".length).trim(); } const body = new commitBody_valueObject.CommitBody(parsed.body, breakingChange); return new commitMessage_entity.CommitMessage(header, body); } catch { // Fallback: try to parse as plain text const lines = content.trim().split("\n"); if (lines.length === 0 || !lines[0]) { throw new Error("No content to parse"); } const headerLine = lines[0]; // Parse header: type(scope): subject const headerMatch = /^(\w+)(?:\(([^)]+)\))?: (.+)$/.exec(headerLine); if (!headerMatch) { throw new Error(`Invalid commit message format. Could not parse: "${headerLine}"`); } const HEADER_TYPE_INDEX = 1; const HEADER_SCOPE_INDEX = 2; const HEADER_SUBJECT_INDEX = 3; const type = headerMatch[HEADER_TYPE_INDEX]; const scope = headerMatch[HEADER_SCOPE_INDEX]; const subject = headerMatch[HEADER_SUBJECT_INDEX]; if (!type || !subject) { throw new Error("Missing required fields: type and subject"); } const header = new commitHeader_valueObject.CommitHeader(type, subject, scope); // Parse body and breaking changes let bodyContent = ""; let breakingChange; for (let index = 1; index < lines.length; index++) { const line = lines[index]; if (!line) continue; if (line.startsWith("BREAKING CHANGE:")) { breakingChange = line.slice("BREAKING CHANGE:".length).trim(); } else if (line.trim()) { bodyContent += line + "\n"; } } const body = new commitBody_valueObject.CommitBody(bodyContent.trim() || undefined, breakingChange); return new commitMessage_entity.CommitMessage(header, body); } } } exports.AnthropicLlmService = AnthropicLlmService; //# sourceMappingURL=anthropic-llm.service.js.map